fix(objectql): the boot loop refuses a view container whose name disagrees with its derived object key (#14666) - #15319
Conversation
…s derived object key (#14666) The ObjectQL boot loop's SOURCE registrar (`registerMetadataCollections`) silently rewrote an aggregated view container's own `name` to the key derived from its `object` binding, discarding the author's field with no diagnostic. The other SOURCE registrar — the metadata artifact/HMR door — refused the very same document through `assertMetadataRegisterContract` (#7378 row 1), `VALIDATION_ERROR` / 400. One document, two registrars, opposite outcomes, and which one an author got depended on how their package was loaded. Ruled direction 2 (maintainer, 2026-09-03): the boot loop converges onto the refusal. It now throws the same envelope the artifact door raises, naming the container's own `name`, the object key it derived, and both remedies. Scope, the ruling's named main risk: the gate is `views` + aggregated container + `name` present + `name` different. A container with no `name`, one whose `name` already agrees, one that declares no binding elsewhere, every non-`views` metadata kind, and the assembled `viewItems:` channel are all untouched, each with a control test. Measured, and why only one of the method's two identical `toRegister` ternaries moved: a view container cannot reach the `viewItems:` site at all — `AssembledViewArtifactSchema` is the view vocabulary minus the container branch, and every body it admits is `isAggregatedViewContainer === false`. That ternary can only mint a name onto an overlay that has none, never discard an authored one. `view-container-divergent-name-registrars.test.ts` inverts its two boot-loop assertions (it pinned today's behaviour, not which door was right); the artifact-door pins stay and are now the reference the boot loop's envelope is asserted equal to. `content/docs/permissions/system-context.mdx` line anchors re-anchored with `check-system-context-census.mjs --fix` — pure +71 line rot, no prose change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 16 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 bb6b544f5a3ea928ae7540f694b2c1d97f66d062 && git checkout bb6b544f5a3ea928ae7540f694b2c1d97f66d062
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 88a35c280a00db85855461e04483af13918b2f1a 1d212866a0977af0a6fbb82b37998fbea1bc0036 && git checkout -B drift-repro 88a35c280a00db85855461e04483af13918b2f1a && git merge --no-ff 1d212866a0977af0a6fbb82b37998fbea1bc0036
node scripts/docs-audit/affected-docs.mjs --json 88a35c280a00db85855461e04483af13918b2f1a
|
Seat review — ⭐ you FALSIFIED my central assumption, and that is the best outcome this dispatch could have had. ⛔ This is not the contract review.
1. The falsification is the findingI dispatched this card with a warning I was confident about: Half of that was right and the load-bearing half was wrong. Both ternaries do exist where I read them — but my reading that they are two candidate container registrars is false, and you did not argue it away, you instrumented the live and then explained why structurally rather than leaving it as an observation: ⭐ And the sharpest consequence, which I did not ask for and which retires the whole worry: at ⇒ Both traps I named are avoided by measurement, not by judgement, and you pinned the measurement with a scope control so a later hand cannot copy the refusal onto a site with nothing to refuse. ⛔ I would have accepted a defensible argument here; you produced evidence instead. 2. Three more places you went past the brief
3. The convention-scoped blind spot bit exactly as warned, and you handled it right
Two exit-1s correctly read as NOT MEASURED, which is the subtler half of the exit-3 discipline: 4. The changesetYou falsified the ruling's own guess with a positive control — Fixture triage answers the ruling's "fires on nothing shipped" independently of the director seat's cross-repo measurement, and reproduces its objectstack half. 5. ⛔ Status — what happens now, and what does not⛔ This comment is not the contract review. Clause-② is CI has just started on Generated by Claude Code |
Docs drift: 10 pages listed, 0 falsified. Checked by the seat so the reviewer does not have to redo it.
The drift bot listed 9 hand-written pages plus 1 release-owned page, and every single row is the same anchor: This PR adds an EMITTER of an existing code. It does not change what the code means or what its envelope looks like. In fact matching the existing envelope was the point: the new boot-loop refusal is asserted equal to the one The one way a listed page could be falsified is if it enumerates emitters exhaustively. None does:
⇒ No page states where the code can arise, so a new arising site cannot make one wrong.
⛔ ⇒ No docs round is owed on this PR, and nothing here changes its status: still draft, still gated on a Generated by Claude Code |
One note for the contract reviewer that is in the dev's report but not in the PR body
The refusal is gated on four conjoined terms: ⭐ The dev's own reading — offered as a caveat against its own change, not as a boast: two of those terms are structurally un-observable belt-and-braces. For a non-container item, or a non- Two consequences the reviewer should weigh, and neither is a defect:
⛔ I am not asking for a change on this. It is a judgment about defence-in-depth on a published accept-set narrowing, which is the reviewer's call at tier and ⛔ not mine off it. Two more items from the dev's report, for completeness:
Status unchanged: draft, Generated by Claude Code |
Fixes #14666
Ruled direction 2 (maintainer, via the director seat, 2026-09-03, comment
5520462984): the boot loop's SOURCE registrar refuses a view container whosenameis set and differs from the key derived from itsobjectbinding, with the same refusal the artifact/HMR door already raises, and stops rewriting the author's field silently.1. A2.1 first — which site(s) actually register a view container on the boot path
Answer: exactly one —
engine.ts:5298(the ternary) /:5327(theregisterItemcall), inside the genericMETADATA_ARRAY_KEYSloop. The second, identical-looking ternary at:5380is alive but cannot carry a view container at all, so it was NOT touched.Driven, not inferred. I wrapped the live
SchemaRegistry.registerItemon a real booted engine and captured a stack frame at every call, then drove a divergent container through each channel.Registering
views: [divergentContainer]throughengine.registerApp::5327is the container registration (its document is the one:5298rewrote);:5339is the expansion. Zero frames at:5381.Driving the same container through the
viewItems:channel instead:It is refused by the schema before the
:5380ternary is reached. The structural reason, confirmed by an admission matrix over every branch of the union:nameisAggregatedViewContainer(parsed)crm_lead.hotcrm_lead.allundefinedviewKindAssembledViewArtifactSchemais built asVIEW_METADATA_BRANCHESminuscontainer, and every body it admits isisAggregatedViewContainer === false. So at:5380,resolveMetadataItemName('views', body)takes the non-container path and readsbody.nameFIRST — meaning the ternary is the identity branch for every body that has aname, and can only mint a name onto an overlay that has none (thelistOverlay NO namerow). It can never discard an authoredname, which is the thing this card is about.Consequently changed: the
views:container branch at:5298only.Consequently NOT changed: the
viewItems:ternary at:5380, the artifact/HMR door,resolveMetadataItemName,deriveViewContainerObject, andpackages/spec.Both traps named at dispatch are therefore avoided by measurement rather than by judgement: there is no surviving container rewrite at
:5380because no container reaches it, and the refusal is not generic because it is gated inside theviewsbranch. A scope control pins this measurement so a later hand cannot copy the refusal onto a site with nothing to refuse, nor widen that channel back open.2. Zone 2, item by item
A2.1 — CONFIRMED with a correction of emphasis. Both ternaries exist exactly where dispatch read them (
:5298,:5380). The reading that they are two candidate container registrars is FALSIFIED: only one can register a container. Evidence above.A2.2 — CONFIRMED. The artifact door is NOT in
packages/objectql/src/plugin.ts.It is
packages/metadata/src/plugin.ts:1110and:1162, exactly as dispatch read it. Its refusal is raised at:1121(this.manager.register('view', viewObject, item, ...)with the item unchanged) byassertMetadataRegisterContract(packages/core/src/metadata-service-contract.ts:153-176):REGISTER_REFUSAL_CODE = 'VALIDATION_ERROR',err.status = 400. That is the envelope the new boot-loop refusal matches, and the pin now asserts the two EQUAL rather than asserting each separately.A2.3 — CONFIRMED: they compute the same key, and the boot loop does not merely agree, it delegates.
resolveMetadataItemName(engine.ts:2041) opens withso for the container branch there is one derivation, not two. Measured across the shape matrix (boot-minted key vs
deriveViewContainerObject, same document):deriveViewContainerObjectnameandobjectdivergecrm_leadcrm_leadnamecrm_leadcrm_leadnameequalsobjectcrm_leadcrm_leadnameonly, no bindinglead_viewslead_viewslist.data.objectcrm_leadcrm_leadform.data.objectcrm_leadcrm_leadname, not a containersolosoloSo "diverges from the derived key" means ONE thing at both doors, and no authority question arises. This also hands the change its scope for free: the
name-only row derives its key FROM that samename, so it cannot disagree with itself and never reaches the refusal.A2.4 — CONFIRMED, and the red is recorded below. The landed pin does pin today's behaviour on both sides; a green run of it after the change would have meant nothing.
A2.5 — done; per-family table in section 4. The tool's blind spot bit here exactly as warned:
check:system-context-censusis NOT in the derived list (it keys on the docs path, which my diff does not touch) yet my edit reds it, because that page is a generated line-anchor table intoengine.tsand I inserted lines above the anchors. Repaired with--fixonly, twice — see section 4.A2.6 — nothing to report. No
content/docs/releases/**path is touched.The one docs path in this PR is
content/docs/permissions/system-context.mdx, which is the census's own generated anchor table, not a release page.3. Red before green — the pin inverting
With
engine.tschanged and the pin still in its landed form,pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/view-container-divergent-name-registrars.test.ts:Three reds, and they are precisely the three boot-loop assertions that drive
divergentContainer. The six that stayed green are the load-bearing half: the artifact-door assertions (untouched, as the ruling requires) and all four scope CONTROLs — noname,namealready equal,namewith no binding, and the standalone ViewItem. That the controls never went red is the scope evidence, taken before any test was edited.Then the two boot-loop assertions were inverted deliberately (each marked in place, carrying what it used to read), the expansion pin was repointed at the anonymous container, and three scope controls were added. After:
Tests 12 passed (12).4. Gate families, per family, with exit codes
73 family invocations run — the full derived set (
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which read my real change set from the merge base itself), minus the ones whose invocation is CI-only (RUNNER_TEMP,matrix.shard), plus the convention-scoped ones the path mapper cannot see. Every family not listed here exited 0. Non-zero, each resolved:check:system-context-censuscheck-system-context-census.mjs --fix, nothing else. Ran twice: the first pass predated a later 6-line comment addition, and the post-commit re-run caught the residue. Greencheck:doc-authoringengine.ts:5361check:dual-build-cjs-loadsturbo run build, 71/71). Re-ran: 0, a real measurementcheck:type-check-debtcheck:published-readme-exportscheck:react-declaration-paritysdui.manifest.json; this diff touches no React block and nopackages/spec. Reported, not chasednode scripts/pr-labels.mjs--self-test: 0Convention-scoped families run beyond the derived list, because my change's kind trips them (
engine.tsline shift, a declared-breaking changeset):check:system-context-census0,check:adr-anchors0,check:adr-symbol-anchors0,check:type-check-coverage0,check:type-check-debt0,check-engine-split-ratio.mjs0 (and--days 900),check-adr-0087-registration.mjs0 both--self-testand--base.The ratchet families were re-run on the final commit and again after merging
origin/main, at1d212866a:check:system-context-census0,check:doc-authoring0,check:nul-bytes0,check-engine-split-ratio.mjs0,check-adr-0087-registration.mjs --base origin/main0,check:changeset-gate-self-tests0,check:cross-package-test-inputs0,check:error-code-casing0,check:partof-closing-keyword0.5. Tests and typecheck
1d212866a:Tests 12 passed (12).pnpm --filter @objectstack/objectql testat1d212866a:Test Files 269 passed (269),Tests 4629 passed (4629).pnpm --filter @objectstack/objectql typecheck(which chainstsc --noEmit, the scripts project, andcheck:test-typecheck): exit 0, "the test layer compiles ... 44 files / 242 errors / 69 pinned signatures held in test-typecheck-debt.json (shrink-only)".tsconfig.jsonexcludes**/*.test.ts, so the base typecheck says nothing about the pin file. Confirmed the test layer really does cover both edited files rather than assuming it:tsc -p tsconfig.test.json --listFileslistsengine.ts(1) and the pin file (1), and neither contributes a single error line —grep -cE "^src/engine\.ts\("and the pin's equivalent both return 0, withsrc/datasource-mapping.test.tsreturning 17 as the firing positive control.6. Changeset — package derived, not copied
The ruling guessed
@objectstack/metadata-protocol"(or wherever the SOURCE registrar lives)". Falsified:The SOURCE registrar lives in
packages/objectql, whose manifest declares"name": "@objectstack/objectql","version": "17.2.0", noprivateflag,publishConfig.access: public— a published package, so the changeset is real andunpublishedis not available to it..changeset/objectql-boot-loop-refuses-divergent-view-name.md—@objectstack/objectql:minor, per the ruling, with a BREAKING banner naming the refused shape precisely and both remedies. Its ADR-0087 disposition isnot-required (no-migration-prescription), which the gate accepts (exit 0) and which the closest in-tree precedent uses for the same situation —calendar-visualization-requires-startdatefield.md, also aminoraccept-set narrowing with a BREAKING banner. The justification is that no key is removed, renamed or re-shaped (forbiddingViewSchema.namein spec was direction 3, explicitly refused), and which of three repairs a divergent container wants is authoring intent no migration can decide — the author may have meant the container name to go, meant it to become the object key, or mistypedobjectand want THAT corrected, and the stored document carries no evidence of which.7. Fixture triage — the refusal fires on nothing in this repo
Scanned by the rule's consumption radius rather than by the edited package. A region scan over every
views: [ ... ]array literal in the tree found 30 regions carrying both anameand anobject; the scanner flags a synthetic divergent container as its firing positive control. None of the 30 can reach this seam — none of their files callsregisterAppor constructs anObjectQL, with the pin file itself as the positive control for that filter. They belong topackages/lint,packages/cliand the artifact door, and most of the matched names are nestedlistViews:view names, not container names.Complementary caller-side enumeration: of the 295 files that boot an engine, exactly three carry an inline
views:container, all three inpackages/objectql, all green. The four example-app.view.tscontainers declare no top-levelnameat all (positive control: the same awk finds one in a synthetic file), which independently reproduces the objectstack half of the director seat's cross-repo measurement.8. What is deliberately not here
Not touched: the artifact/HMR door and its pins (the ruling says they stay),
packages/spec(off-surface, and direction 3 is refused), theviewItems:ternary at:5380(measured unreachable for containers), and the boot loop's minting of a derivednameonto a container that carries none — that is not a rewrite of an author's field, and the ruling leaves the no-namecontainer unaffected.Per Clause-2 this PR stays a draft, is not enqueued and has no auto-merge armed; it lands only after a PASS at
CONTRACT_REVIEW_TIER.needs:contract-reviewis hung on this PR and on #14666 now that a reviewable diff exists.Generated by Claude Code