Skip to content

fix(objectql): make the [Registry] Collision warning fire in the cold-boot order too - #12430

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-12027-registry-collision-order
Aug 26, 2026
Merged

fix(objectql): make the [Registry] Collision warning fire in the cold-boot order too#12430
os-warren merged 1 commit into
mainfrom
claude/issue-12027-registry-collision-order

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#12027

The [Registry] Collision artifact-vs-DB warning existed and worked — in the order that
happens less often. In the order a cold boot actually produces it said nothing. So "the
warning is missing" is the wrong description of the defect, and it changes what the fix has
to prove: the pin has to fire on the COMMON order, not merely on some order.

Which order is the common cold boot — established, not taken on the card's word

The card says artifact-first is "likely" the common order. It is stronger than that: it is
structural. Kernel Bootstrap runs Phase 1: init all plugins, then Phase 2: start all
plugins
(packages/core/src/kernel.ts), and the two registrations sit on opposite sides
of that line:

phasewho registerscallstorage key
1 · initAppPlugin.init -> manifest.register -> ObjectQL.registerApp -> registerMetadataCollectionsregisterItem(type, item, 'name', ownerId)pkgid:name
2 · startObjectQLPlugin.start -> restoreMetadataFromDb -> protocol.loadMetaFromDb -> hydrateOverlayIntoRegistryregisterItem(type, item, 'name')no packageIdbare name

Phase 1 strictly precedes Phase 2, so at boot the overlay is ALWAYS the second arrival —
the exact order the packageId && half of the guard excluded. Confirmed on a real boot as
well as in the source: booting @objectstack/example-crm through @objectstack/verify
logs Phase 1: Init plugins at 01:33:17.716 and Registering App Service {"appId": "com.example.crm"} at 01:33:18.192, then Phase 2: Start plugins at 01:33:18.209.

The corollary is worth stating plainly: the direction that DID warn is the
late-registration one — a marketplace install, a post-start()manifest.register, an HMR
reload. At a cold boot the existing guard is structurally unreachable, which is why
ADR-0005's claim that the shadowing is "discoverable in startup logs" was false in the only
order startup produces.

The asymmetry, reproduced

Both directions, same collision, real SchemaRegistry (the ablation below is the
measurement — the pre-fix code is the mutated tree):

artifact first, then the sys_metadata row -> 0 collision lines <- pre-fix, silent
the sys_metadata row first, then artifact -> 1 collision line <- pre-fix, warns

On a real CRM boot, one stored view overlay of a packaged view produced 0 collision
lines and 4 silent shadowings — the container plus its three expanded ViewItems
(crm_opportunity, .all, .pipeline, .default).

Two messages, not one message widened to fit both

Both orders end in the same state — getItem checks the bare key first, so the runtime row
wins either way. What differs is the EVENT, and the event is the part an operator acts on:

  • package arrives second (existing text, unchanged): a package that is dead on arrival
    behind a row that predates it. "…already exists in sys_metadata. The runtime row WILL shadow the package value…"
  • stored row arrives second (new): a stored definition taking over one this process
    just loaded from code. "…has just been registered from sys_metadata. The runtime row NOW shadows the package value … every read of TYPE/NAME serves the stored row, not the packaged definition."

A single message would have to drop which one arrived second, which is exactly the fact
that tells the reader whether they are looking at a failed install or at a customization
taking effect. distinguishable messages in the pin file is what stops a later
simplification from quietly merging them.

The new message also says the shadowing MAY be deliberate, and that is not hedging. In the
existing direction, !dbOnly._packageId narrows to a package-LESS row — an accidental name
collision. This direction cannot make that distinction: the protocol merges the artifact's
_packageId/_provenance envelope onto the overlay body (ADR-0010 section 3.3,
mergeArtifactProtection) before it ever reaches registerItem, so a deliberate
customization and an accidental collision are byte-identical here. The line therefore states
the consequence and both readings rather than accusing.

Volume — measured, because #12015 is one warning over

#12015 was narrowed after its first shape fired 116 times on a stock boot and said something
untrue about the dominant population. This one was measured against that trap before it was
written:

  • 0 lines on a stock boot. Measured: loadMetaFromDb on a freshly booted CRM returns
    loaded: 0 — a stock sys_metadata holds no overlay of a packaged name, so there is
    nothing to shadow. That is the discriminator against driver-sql: a declared field named id / created_at / updated_at is silently discarded by initObjects — declared type, length and constraints all ignored with no diagnostic #12015: its 116 lines were about the
    platform's own baseline population, and this one's population is operator-created and
    opt-in.
  • Thereafter once per shadowed name per process.!collection.has(bareKey) is what
    bounds it: the line marks the TRANSITION (a bare slot that was empty is now taken), not
    the state. Without that, the read-side hydration (getMetaItems) and the write-through
    would re-emit it on every later read and write of a customized item — a line per GET.
    Ablation leg B below is that bound under test.
  • The known amplification, stated rather than discovered later: a customized view
    container expands into its ViewItems, so 1 customization produced 4 lines in the CRM
    measurement. Each expanded item is a genuinely separate registry entry that is genuinely
    shadowed, so the count is honest; deduplicating it would require view-specific knowledge
    in the registry, which is worse.

Locked packaged items narrow the population further and for free: saveMetaItem refused
flow/crm_convert_lead_wizard and page/crm_welcome with NOT_OVERRIDABLE during the same
measurement, so those can never reach this seam at all.

Clause 2: no

This adds a console.warn on a path that printed nothing. collection.set(storageKey, item)
is untouched, getItem is untouched, and no accept/reject behaviour changes. The pin asserts
it in both orders (the diagnostic repair moves nothing): the runtime row still wins, and
getArtifactItem still reaches the packaged definition. Refusing the collision instead of
warning about it was considered and rejected — it would break the sanctioned ADR-0005
customization path, and it would be an accept/reject door, i.e. a different card.

Verification

All of the below at 1fc7e3b056, working tree clean, foreground, exit codes captured before
any pipe.

Suitespnpm --filter @objectstack/objectql exec vitest run: Test Files 236 passed (236) / Tests 4185 passed (4185); typecheck green (tsc --noEmit). Downstream sweep,
deliberately targeted rather than the full consumer closure and declared as such: the two
packages that consume this seam through dist and are most exposed to a new warning line —
@objectstack/metadata-protocol (the caller of the bare-key hydration path)
Test Files 141 passed | 2 skipped (143) / Tests 1950 passed | 10 skipped (1960), and
@objectstack/service-automation (whose flow-name-shadowing fixture is written against
this warning) Test Files 90 passed (90) / Tests 1080 passed (1080). A repo-wide grep for
tests that assert not.toHaveBeenCalled near a registration named 16 files; the 8 in
@objectstack/objectql are covered by the suite above and the rest sit in those two packages
plus rest/triggers, which CI runs.

Gate union derived, not recallednode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, run after the changeset existed, self-reporting
gate list derived from the tree of 'objectstack-ai/objectstack' at commit 1fc7e3b056:
20 matched families plus 6 convention-triggered ones. All green, each quoting its own
verdict line where it prints one:

  • check-type-check-coverage: OK — 65/78 workspace packages type-checked (plus the root), 13 in the DEBT ledger (382 frozen raw errors…), 1 exempt.
  • check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 249.1s, 1843 raw tsc error(s) total, none above its recorded number. / surplus: none — every entry sits exactly at its measurement, so any new error is red. (run on the fully built workspace closure — turbo run build, Tasks: 70 successful, 70 total — so the new test file really was measured)
  • check-nul-bytes: OK
  • check-engine-double-contract, check-where-matcher, check-query-options-erasure, check-test-source-alias, check-cross-package-test-inputs (both the pnpm and the node invocation), check-ci-filter-parity, check-comment-mask-adoption, check-durability-log-level, check-page-declaration-shape, check-published-files, check-slot-lookup, check-type-source-resolution, check-engine-split-ratio, check-plugin-teardown-shape, check-affected-docs, check-drift-comment, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-objectui-changeset, check-changeset-gate-self-tests, release-rehearsal-clone --self-test — every one exit 0, captured into its own log before any pipe.

Repo-wide lint, not narrowedpnpm lint (eslint . --no-inline-config) over the whole
tree, 63s, exit 0. No narrowing is claimed and none is owed.

Ablation — two legs, direction predicted in writing before either run, implementation
committed first.
The pin imports ./registry — a relative source specifier inside its own
package, so vitest resolves TypeScript source and not exports -> dist; no rebuild is owed
between mutation and measurement, and the reds are evidence about the mutation rather than
about a stale artifact. Each leg proved the mutation on disk with single-line grep -cF
anchor counts BEFORE any result was read, and each restore leg proved absence the same way
plus an empty git diff. The script carried trap ... EXIT INT TERM, so a foreground
timeout could not leave the tree mutated (it fired on both legs: TRAP-RESTORED).

legmutationon-disk proofpredictedobserved
Adelete the whole cold-boot guard (= origin/main)let shadowed: any; 1 -> 0, just been registered from sys_metadata. 1 -> 04 of 11 red, named individually4 failed / 7 passed — exactly the four named, first one expected [] to have a length of 1 but got +0
Bkeep the guard, drop only && !collection.has(bareKey)guard line 1 -> 0, if (!packageId) { 0 -> 11 of 11 red — only warns once, not once per registration1 failed / 10 passed, that case, expected [ …(2) ] to deeply equal []

Leg A is the card's measurement reproduced: same collision, 0 lines in the artifact-first
order while the LATE-REGISTRATION ORDER case stayed green in the same run. Leg B is the one
that matters for the grading — five of the six silence cases pass with OR without the guard,
so they are narrowing pins against the opposite mutation rather than restatements of the
defect, and without leg B nothing in the file would fail if the warning started printing on
every read-side hydration.

Changeset

.changeset/registry-collision-order-symmetric.md, graded patch for
@objectstack/objectql. The grade is the argument: this adds a diagnostic to a path that
printed nothing — no API change, no accept/reject change, no precedence change. It is not
minor because nothing new is authorable or callable and no data moves. The one
operator-visible effect is the log itself, and the changeset says so rather than leaving it
to be found: a deployment that customizes packaged metadata will see one new line per
shadowed name per process where it previously saw none.

Serial constraints

Surface is packages/objectql/src/registry.ts plus one new pin file in the same directory
and one changeset. PR #12396 declares packages/objectql/**, but its actual changed-file set
is hook-wrappers.ts, hook-input-mutation-traps.test.ts,
packages/runtime/src/sandbox/body-runner.ts, hook-input-delete-writeback.test.ts and a
changeset — disjoint from registry.ts, checked by listing that PR's files rather than
trusting the glob. Nothing here touches packages/metadata/** (#12399),
packages/drivers/driver-sql/** (#12401) or HotReload config (#12340).

Generated by Claude Code


Generated by Claude Code

…-boot order
The artifact-vs-DB collision warning was guarded on `packageId &&`, so it only
ever spoke when the PACKAGE registered second. A kernel boot cannot produce
that order: the artifact reaches the registry in Phase 1 (AppPlugin.init ->
manifest.register), and the sys_metadata overlay is rehydrated in Phase 2
(ObjectQLPlugin.start -> loadMetaFromDb) under the bare name with no package
id. init-all-then-start-all means the overlay is always the second arrival at
boot, which is the exact order the guard excluded — so ADR-0005's claim that
the shadowing is "discoverable in startup logs" was false in the only order
startup produces.
The cold-boot direction now warns with its own message rather than a widened
version of the existing one: both orders end in the same state, but the event
an operator has to act on differs. Resolution order is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7b2f94133af3c142f62df5637f8d0a1cdf6cdab5packageMentionDocs.

Which tree this was computed on

This run read content/docs from 40a67f5607f0452797d051119ea9aaf19d1a50c7 — the merge of head 1fc7e3b05667fb577dc1ff539a81d33c1d99cc65 into base 7b2f94133af3c142f62df5637f8d0a1cdf6cdab5, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 40a67f5607f0452797d051119ea9aaf19d1a50c7 && git checkout 40a67f5607f0452797d051119ea9aaf19d1a50c7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7b2f94133af3c142f62df5637f8d0a1cdf6cdab5 1fc7e3b05667fb577dc1ff539a81d33c1d99cc65 && git checkout -B drift-repro 7b2f94133af3c142f62df5637f8d0a1cdf6cdab5 && git merge --no-ff 1fc7e3b05667fb577dc1ff539a81d33c1d99cc65
node scripts/docs-audit/affected-docs.mjs --json 7b2f94133af3c142f62df5637f8d0a1cdf6cdab5

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 26, 2026
@os-warren
os-warren marked this pull request as ready for review August 26, 2026 02:52
@os-warren
os-warren added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit fe72aa5Aug 26, 2026
34 checks passed
@os-warren
os-warren deleted the claude/issue-12027-registry-collision-order branch August 26, 2026 05:34
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Registry] Collision warning is order-asymmetric: silent in the common cold-boot order (artifact first, then sys_metadata)

2 participants

@os-warren@claude