Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): make the [Registry] Collision warning fire in the cold-boot order too - #12430
Conversation
…-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
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not 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
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12027
The
[Registry] Collisionartifact-vs-DB warning existed and worked — in the order thathappens 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
Bootstrapruns Phase 1: init all plugins, then Phase 2: start allplugins (
packages/core/src/kernel.ts), and the two registrations sit on opposite sidesof that line:
AppPlugin.init->manifest.register->ObjectQL.registerApp->registerMetadataCollectionsregisterItem(type, item, 'name', ownerId)pkgid:nameObjectQLPlugin.start->restoreMetadataFromDb->protocol.loadMetaFromDb->hydrateOverlayIntoRegistryregisterItem(type, item, 'name')— no packageIdnamePhase 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 aswell as in the source: booting
@objectstack/example-crmthrough@objectstack/verifylogs
Phase 1: Init pluginsat01:33:17.716andRegistering App Service {"appId": "com.example.crm"}at01:33:18.192, thenPhase 2: Start pluginsat01: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 HMRreload. 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 themeasurement — the pre-fix code is the mutated tree):
On a real CRM boot, one stored
viewoverlay of a packaged view produced 0 collisionlines 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 —
getItemchecks the bare key first, so the runtime rowwins either way. What differs is the EVENT, and the event is the part an operator acts on:
behind a row that predates it.
"…already exists in sys_metadata. The runtime row WILL shadow the package value…"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 messagesin the pin file is what stops a latersimplification from quietly merging them.
The new message also says the shadowing MAY be deliberate, and that is not hedging. In the
existing direction,
!dbOnly._packageIdnarrows to a package-LESS row — an accidental namecollision. This direction cannot make that distinction: the protocol merges the artifact's
_packageId/_provenanceenvelope onto the overlay body (ADR-0010 section 3.3,mergeArtifactProtection) before it ever reachesregisterItem, so a deliberatecustomization 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:
loadMetaFromDbon a freshly booted CRM returnsloaded: 0— a stocksys_metadataholds no overlay of a packaged name, so there isnothing to shadow. That is the discriminator against driver-sql: a declared field named
id/created_at/updated_atis silently discarded by initObjects — declared type, length and constraints all ignored with no diagnostic #12015: its 116 lines were about theplatform's own baseline population, and this one's population is operator-created and
opt-in.
!collection.has(bareKey)is whatbounds 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-throughwould 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.
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:
saveMetaItemrefusedflow/crm_convert_lead_wizardandpage/crm_welcomewithNOT_OVERRIDABLEduring the samemeasurement, so those can never reach this seam at all.
Clause 2: no
This adds a
console.warnon a path that printed nothing.collection.set(storageKey, item)is untouched,
getItemis untouched, and no accept/reject behaviour changes. The pin assertsit in both orders (
the diagnostic repair moves nothing): the runtime row still wins, andgetArtifactItemstill reaches the packaged definition. Refusing the collision instead ofwarning 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 beforeany pipe.
Suites —
pnpm --filter @objectstack/objectql exec vitest run:Test Files 236 passed (236)/Tests 4185 passed (4185);typecheckgreen (tsc --noEmit). Downstream sweep,deliberately targeted rather than the full consumer closure and declared as such: the two
packages that consume this seam through
distand 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(whoseflow-name-shadowingfixture is written againstthis warning)
Test Files 90 passed (90)/Tests 1080 passed (1080). A repo-wide grep fortests that assert
not.toHaveBeenCallednear a registration named 16 files; the 8 in@objectstack/objectqlare covered by the suite above and the rest sit in those two packagesplus
rest/triggers, which CI runs.Gate union derived, not recalled —
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, run after the changeset existed, self-reportinggate 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: OKcheck-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 exit0, captured into its own log before any pipe.Repo-wide lint, not narrowed —
pnpm lint(eslint . --no-inline-config) over the wholetree, 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 ownpackage, so vitest resolves TypeScript source and not
exports->dist; no rebuild is owedbetween 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 -cFanchor counts BEFORE any result was read, and each restore leg proved absence the same way
plus an empty
git diff. The script carriedtrap ... EXIT INT TERM, so a foregroundtimeout could not leave the tree mutated (it fired on both legs:
TRAP-RESTORED).origin/main)let shadowed: any;1 -> 0,just been registered from sys_metadata.1 -> 0expected [] to have a length of 1 but got +0&& !collection.has(bareKey)if (!packageId) {0 -> 1warns once, not once per registrationexpected [ …(2) ] to deeply equal []Leg A is the card's measurement reproduced: same collision,
0lines in the artifact-firstorder while the
LATE-REGISTRATION ORDERcase stayed green in the same run. Leg B is the onethat 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, gradedpatchfor@objectstack/objectql. The grade is the argument: this adds a diagnostic to a path thatprinted nothing — no API change, no accept/reject change, no precedence change. It is not
minorbecause nothing new is authorable or callable and no data moves. The oneoperator-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.tsplus one new pin file in the same directoryand one changeset. PR #12396 declares
packages/objectql/**, but its actual changed-file setis
hook-wrappers.ts,hook-input-mutation-traps.test.ts,packages/runtime/src/sandbox/body-runner.ts,hook-input-delete-writeback.test.tsand achangeset — disjoint from
registry.ts, checked by listing that PR's files rather thantrusting 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