Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/startup-registry-verdict-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
---

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.

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.

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.

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.
18 changes: 18 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,24 @@ jobs:
- name: Durability-degradation log-level guard
run: pnpm check:durability-log-level

# Startup registry-verdict guard (#4777). One showcase cold start produced
# three instances of one shape in three unrelated subsystems: 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. This gate walks the AST for that
# three-part shape: a pre-ready registry read (init()/constructor, or an
# ADR-0018 open capability registry before its seal) whose absent verdict
# is recorded. Reading is legal and must stay legal — a probe licensed by
# an ADR-0116 declaration, or one deferred into a closure/kernel:ready
# hook, is exactly the fix and is never flagged. Deliberately narrow: it
# cannot see a probe behind an indirection in another package, and it
# cannot see #4769 at all (that "registry" is a database table). Runs its
# own --self-test first.
- name: Startup registry-verdict guard
run: pnpm check:startup-registry-verdict

# Release-notes drift guard: the platform is one version-locked train, so
# every released @objectstack/spec major must have a curated, navigable
# release page at content/docs/releases/v<major>.mdx. Catches the gap that
Expand Down
66 changes: 66 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,72 @@ shrink-only.

---

## Startup registry reads — never record a verdict the boot can still contradict

A boot fills its registries incrementally. Asking a registry "is X there?" while
it is still filling is fine — the answer is simply not final yet. Turning that
not-yet into a **verdict and recording the verdict** is the defect, because the
provider registers a moment later and nothing goes back to undo the record.

Decide with **one question**, the counterpart of the degradation-log-level one:

> **At the moment this code concludes "X is not registered", can a provider
> still register X during this same boot? And is that conclusion RECORDED
> anywhere that outlives the moment?**
> **Yes and yes → defect.**

Three parts, all three or it is not a finding:

1. a read of a registry that is still filling — the service registry during
`init()`, or a plugin-extensible capability registry before it is sealed;
2. a terminal conclusion drawn from "absent";
3. that conclusion **recorded** — cached in an instance field or module binding,
asserted in a `warn`, or persisted.

Part 3 is what makes this a rule and not noise. **A read-only probe is
completely legal**: `AutomationEngine.getUnknownNodeTypeAudit()` reads the
executor registry on every call, records nothing, and is correct.

**Why this is a rule and not a preference.** One showcase cold start on
2026-08-03 produced three instances, in three unrelated subsystems, written by
three people at three times: plugin-auth froze an `undefined` cache handle into
its config for the life of the process, so rate-limit counters never reached the
shared store and the printed warning sent operators to provision Redis for a
problem they did not have (#4772); service-automation asserted that eight
approval flows "will fail at execution time" 0.8s before the executor that runs
them was registered, and a deployment that genuinely lacked the plugin emitted
the identical eight, so the signal could not tell the two apart (#4771); objectql
wrote an ADR-0104 attestation into `sys_migration` during the same boot that was
still seeding rows contradicting it, so the next restart rejected its
predecessor's data (#4769). Whether the kernel contract itself should be
tightened further is #4776.

**The three cures, in preference order:**

1. **Resolve where it is used, not where you start.** A lazy accessor or a
`kernel:ready` hook sees a provider that registered later —
`createLazyCacheRateLimitStorage()` in plugin-auth is the reference.
2. **Declare the ordering (ADR-0116).** `dependencies` / `optionalDependencies`
/ `requiresServices` make the kernel hoist the provider ahead or assert it
registered, which makes "absent" a *fact*. Tolerance belongs in the plugin's
own declaration, where the kernel enforces it — not in a checker's ledger.
3. **Seal the vocabulary, then judge.** For a registry that is open by contract
(ADR-0018 flow node types), the host declares the moment it can no longer
grow — `AutomationEngine.sealNodeTypeVocabulary()`, called at
`kernel:bootstrapped` — and only then is an absence worth reporting.

**It has teeth**: `pnpm check:startup-registry-verdict` walks the AST for that
three-part shape and fails on it; accepted exceptions live in the shrink-only,
hand-edited `scripts/startup-registry-verdict.baseline.json`. Like
`check:durability-log-level` it is deliberately narrow — it cannot *discover* a
new seam, only stop known ones from regressing, and it under-matches on purpose
rather than risk a false positive: `getService('cache')` is visible, a
`resolveCacheOrFallback()` three layers down another package is not, and #4769's
"registry" is a database table it can never see. Found a new open registry? Add
it to `OPEN_CAPABILITY_REGISTRIES` in the same PR that fixes it.

---

## Post-Task Checklist

1. `pnpm test` — verify nothing broke. Touched a type-check-covered package? `pnpm typecheck` too.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"check:wildcard-fallthrough": "node scripts/check-wildcard-fallthrough.mjs --self-test && node scripts/check-wildcard-fallthrough.mjs",
"check:init-service-contract": "node scripts/check-init-service-contract.mjs --self-test && node scripts/check-init-service-contract.mjs",
"check:durability-log-level": "node scripts/check-durability-degradation-log-level.mjs --self-test && node scripts/check-durability-degradation-log-level.mjs",
"check:startup-registry-verdict": "node scripts/check-startup-registry-verdict.mjs --self-test && node scripts/check-startup-registry-verdict.mjs",
"check:console-sha": "node scripts/check-console-sha.mjs",
"check:release-notes": "node scripts/check-release-notes.mjs",
"check:node-version": "node scripts/check-node-version.mjs",
Expand Down
Loading
Loading