Skip to content

Commit 799d098

Browse files
feat(devx): gate the "startup registry verdict" class — read the registry all you like, never record the "no" (#4777) (#4833)
One showcase cold start on 2026-08-03 produced three instances of one shape in three unrelated subsystems, written by three people at three times: ask a registry "is X there?" while the boot is still filling it, treat the "no" as final, and RECORD it — cached on the instance (#4772 plugin-auth), asserted in a warn (#4771 service-automation), or written to the database (#4769 objectql). The provider registers a moment later; nothing undoes the record. All three are fixed. This is what stops the class from coming back, built on the #4632 machinery (declared vocabulary + AST + shrink-only baseline + --self-test) rather than a parallel one. The gate matches the three-part shape, and part 3 is what makes it a rule and not noise: 1. a read of a registry still filling — the service registry during init()/constructor, or an ADR-0018 open capability registry before its seal; 2. a terminal conclusion drawn from "absent"; 3. that conclusion recorded — cached in an instance field / module binding, asserted in a warn, or persisted. A read-only probe stays completely legal, and every cure passes untouched: a probe deferred into a lazy accessor or a kernel:ready hook (nested bodies are not descended into), a probe whose ordering an ADR-0116 declaration already made final (dependencies / optionalDependencies / requiresServices — tolerance lives in the plugin, never in a checker ledger), and a verdict drawn at a declared seal (sealNodeTypeVocabulary). Rule B counts only registry ENUMERATION, so a keyed has()/get() on a runtime path is not mistaken for a boot-time membership verdict. Proven in both directions, because a gate that has only ever been green cannot be told apart from one that matches nothing (#4690): f2eb850^ (both defects live) → 2 violations (auth-plugin, engine.ts) 25784cf^ (#4772 fixed only) → 1 violation (engine.ts) main → clean, 47 seams seen, 41 read-only Coverage is stated, not implied: it under-matches on purpose. #4769 is a fixture, not a catch — its "registry" is the sys_migration table. Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent c4ab50b commit 799d098

6 files changed

Lines changed: 1414 additions & 0 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
---
3+
4+
Tooling-only: `pnpm check:startup-registry-verdict` — startup registry reads may not record a verdict the boot can still contradict (#4777). Adds `scripts/check-startup-registry-verdict.mjs` + the shrink-only `scripts/startup-registry-verdict.baseline.json` (empty on landing), a `Lint & Type Check` step, and an AGENTS.md section. Releases nothing — no package changes.
5+
6+
One showcase cold start on 2026-08-03 produced three instances of one shape in three unrelated subsystems written by three people at three times: ask a registry "is X there?" while the boot is still filling it, treat the "no" as final, and **record** it — cached on the instance (#4772 plugin-auth), asserted in a `warn` (#4771 service-automation), or written to the database (#4769 objectql). The provider registers a moment later and nothing undoes the record. All three are fixed; this is what stops the class from coming back.
7+
8+
The gate matches the three-part shape, and part 3 is what makes it a rule rather than noise — a read-only probe stays completely legal, and the cures are never flagged: a probe deferred into a lazy accessor or a `kernel:ready` hook, a probe whose ordering an ADR-0116 declaration (`dependencies` / `optionalDependencies` / `requiresServices`) has already made final, and a verdict drawn at a declared seal (`sealNodeTypeVocabulary()`) all pass.
9+
10+
Its reach is stated rather than implied: it under-matches on purpose. `getService('cache')` is visible, a `resolveCacheOrFallback()` three layers down another package is not, and #4769 is invisible to it entirely — that "registry" is the `sys_migration` table in a database. This stops the bleeding; it does not cure. Whether the kernel contract should be tightened further is #4776.

.github/workflows/lint.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,24 @@ jobs:
204204
- name: Durability-degradation log-level guard
205205
run: pnpm check:durability-log-level
206206

207+
# Startup registry-verdict guard (#4777). One showcase cold start produced
208+
# three instances of one shape in three unrelated subsystems: ask a
209+
# registry "is X there?" while the boot is still filling it, treat the
210+
# "no" as final, and RECORD it — cached on the instance (#4772
211+
# plugin-auth), asserted in a warn (#4771 service-automation), or written
212+
# to the database (#4769 objectql). The provider registers a moment later
213+
# and nothing undoes the record. This gate walks the AST for that
214+
# three-part shape: a pre-ready registry read (init()/constructor, or an
215+
# ADR-0018 open capability registry before its seal) whose absent verdict
216+
# is recorded. Reading is legal and must stay legal — a probe licensed by
217+
# an ADR-0116 declaration, or one deferred into a closure/kernel:ready
218+
# hook, is exactly the fix and is never flagged. Deliberately narrow: it
219+
# cannot see a probe behind an indirection in another package, and it
220+
# cannot see #4769 at all (that "registry" is a database table). Runs its
221+
# own --self-test first.
222+
- name: Startup registry-verdict guard
223+
run: pnpm check:startup-registry-verdict
224+
207225
# Release-notes drift guard: the platform is one version-locked train, so
208226
# every released @objectstack/spec major must have a curated, navigable
209227
# release page at content/docs/releases/v<major>.mdx. Catches the gap that

AGENTS.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,72 @@ shrink-only.
601601

602602
---
603603

604+
## Startup registry reads — never record a verdict the boot can still contradict
605+
606+
A boot fills its registries incrementally. Asking a registry "is X there?" while
607+
it is still filling is fine — the answer is simply not final yet. Turning that
608+
not-yet into a **verdict and recording the verdict** is the defect, because the
609+
provider registers a moment later and nothing goes back to undo the record.
610+
611+
Decide with **one question**, the counterpart of the degradation-log-level one:
612+
613+
> **At the moment this code concludes "X is not registered", can a provider
614+
> still register X during this same boot? And is that conclusion RECORDED
615+
> anywhere that outlives the moment?**
616+
> **Yes and yes → defect.**
617+
618+
Three parts, all three or it is not a finding:
619+
620+
1. a read of a registry that is still filling — the service registry during
621+
`init()`, or a plugin-extensible capability registry before it is sealed;
622+
2. a terminal conclusion drawn from "absent";
623+
3. that conclusion **recorded** — cached in an instance field or module binding,
624+
asserted in a `warn`, or persisted.
625+
626+
Part 3 is what makes this a rule and not noise. **A read-only probe is
627+
completely legal**: `AutomationEngine.getUnknownNodeTypeAudit()` reads the
628+
executor registry on every call, records nothing, and is correct.
629+
630+
**Why this is a rule and not a preference.** One showcase cold start on
631+
2026-08-03 produced three instances, in three unrelated subsystems, written by
632+
three people at three times: plugin-auth froze an `undefined` cache handle into
633+
its config for the life of the process, so rate-limit counters never reached the
634+
shared store and the printed warning sent operators to provision Redis for a
635+
problem they did not have (#4772); service-automation asserted that eight
636+
approval flows "will fail at execution time" 0.8s before the executor that runs
637+
them was registered, and a deployment that genuinely lacked the plugin emitted
638+
the identical eight, so the signal could not tell the two apart (#4771); objectql
639+
wrote an ADR-0104 attestation into `sys_migration` during the same boot that was
640+
still seeding rows contradicting it, so the next restart rejected its
641+
predecessor's data (#4769). Whether the kernel contract itself should be
642+
tightened further is #4776.
643+
644+
**The three cures, in preference order:**
645+
646+
1. **Resolve where it is used, not where you start.** A lazy accessor or a
647+
`kernel:ready` hook sees a provider that registered later —
648+
`createLazyCacheRateLimitStorage()` in plugin-auth is the reference.
649+
2. **Declare the ordering (ADR-0116).** `dependencies` / `optionalDependencies`
650+
/ `requiresServices` make the kernel hoist the provider ahead or assert it
651+
registered, which makes "absent" a *fact*. Tolerance belongs in the plugin's
652+
own declaration, where the kernel enforces it — not in a checker's ledger.
653+
3. **Seal the vocabulary, then judge.** For a registry that is open by contract
654+
(ADR-0018 flow node types), the host declares the moment it can no longer
655+
grow — `AutomationEngine.sealNodeTypeVocabulary()`, called at
656+
`kernel:bootstrapped` — and only then is an absence worth reporting.
657+
658+
**It has teeth**: `pnpm check:startup-registry-verdict` walks the AST for that
659+
three-part shape and fails on it; accepted exceptions live in the shrink-only,
660+
hand-edited `scripts/startup-registry-verdict.baseline.json`. Like
661+
`check:durability-log-level` it is deliberately narrow — it cannot *discover* a
662+
new seam, only stop known ones from regressing, and it under-matches on purpose
663+
rather than risk a false positive: `getService('cache')` is visible, a
664+
`resolveCacheOrFallback()` three layers down another package is not, and #4769's
665+
"registry" is a database table it can never see. Found a new open registry? Add
666+
it to `OPEN_CAPABILITY_REGISTRIES` in the same PR that fixes it.
667+
668+
---
669+
604670
## Post-Task Checklist
605671

606672
1. `pnpm test` — verify nothing broke. Touched a type-check-covered package? `pnpm typecheck` too.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"check:wildcard-fallthrough": "node scripts/check-wildcard-fallthrough.mjs --self-test && node scripts/check-wildcard-fallthrough.mjs",
4646
"check:init-service-contract": "node scripts/check-init-service-contract.mjs --self-test && node scripts/check-init-service-contract.mjs",
4747
"check:durability-log-level": "node scripts/check-durability-degradation-log-level.mjs --self-test && node scripts/check-durability-degradation-log-level.mjs",
48+
"check:startup-registry-verdict": "node scripts/check-startup-registry-verdict.mjs --self-test && node scripts/check-startup-registry-verdict.mjs",
4849
"check:console-sha": "node scripts/check-console-sha.mjs",
4950
"check:release-notes": "node scripts/check-release-notes.mjs",
5051
"check:node-version": "node scripts/check-node-version.mjs",

0 commit comments

Comments
 (0)