Repository navigation
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 |
…edger note; delete the unreachable list-view `tabs` walks (objectstack-ai#21423) Fixes objectstack-ai#20301 Clause-②: no Stage 2 of the card, re-scoped by claim amendment `5953063959` and its correction `5953104466`. Stage 1 (PR objectstack-ai#20357) retired `list.tabs`. Stage 2 does not retire the view container's body `name`, because the A2 stop fired: the metadata door writes that key itself (os-dev report `5953000865`). What lands here is the corrected ledger record plus the carried notes from the stage-1 landing record `5870707479`. ⛔ No tombstone, no conversion, no door change, no `view.zod.ts` key change. objectstack-ai#21412 (the runtime door accepts a contradicting container `name`) is not addressed here and remains open. ## What changed **1. `packages/spec/liveness/view.json`, the `name` row: the status stays `dead` and only the note is rewritten.** `verifiedAt` is now 2026-10-02. - **Why the verdict holds.** The ledger defines `live` as "authoring the property changes runtime behaviour". An authored container `name` either restates the key the container already registers under or contradicts it. So `os validate` / `os lint` keep their `liveness-dead-property` warning ("drop it"), which is still the right advice. - **What the note corrects.** It no longer says "a copy nobody reads", because the door writes and reads that copy. `saveMetaItem` runs `normalizeViewMetadata` ahead of the schema gate, and it stamps the save name onto every view body that has none, containers included. This is pinned by `view-container-runtime-expansion.test.ts`. The overlay paths then key on the stamped copy: `hydrateOverlayIntoRegistry` registers no body without a `name`, and `mergePackageAwareOverlay` slots a row by it. The ObjectQL boot loop also mints the derived key onto every stack container it registers. - **Why the key is kept, not retired.** A tombstone would refuse the platform's own saves. The 2026-09-03 ruling (PR objectstack-ai#15319) refused direction 3. Triage's guard on this card forbids retiring a key the platform's own writer still sends. The note follows the ledger's "kept deliberately" precedent. - **The old attribution, corrected.** The note used to say artifact-shipped containers and the metadata-validation sweep send the key. They do not: what was read as theirs is the door's stamp. - The ledger README's `view` cell carried the same false sentence and is corrected the same way. The `view.list.tabs` row's note now records that the two walks below are deleted. - **Counts.** `gen:liveness-counts` printed `0 shard(s) rewritten, 0 pruned`, and the totals are unchanged: `988 live · 3 experimental · 1 live-elsewhere · 109 dead · 10 planned = 1111`. **2. The carried notes, at their re-measured locations.** - `packages/spec/src/system/i18n-resolver.ts`: a comment still called `ListViewSchema.tabs` a live carrier. It now names the tombstone and says `UserFiltersSchema.tabs` is the one carrier. This is a comment-only change. - `packages/lint/src/validate-list-view-field-refs.ts`: the `checkTabs(listView.tabs, …)` call is deleted. This rule is `input: 'parsed'` in the authoring-rule registry, and every list-view shape tombstones `tabs`, so the key could never reach the call. The `userFilters.tabs` walk stays. Header prose that named `tabs[].filter` / `tabs[].view` as walked positions is updated. - `packages/metadata-protocol/src/metadata-diagnostics.ts`: the `view?.tabs` read in `computeViewReferenceDiagnostics` is deleted. The write door refuses the key, and stored and artifact bodies have it stripped by the conversion replay (`applyConversionsToStoredItem` / `applyArtifactForwardConversions`) before they are served. A body that still carries it is badged by `computeMetadataDiagnostics` with the tombstone prescription. - **Fixtures.** The list-tabs fixtures in `packages/lint/src/validate-list-view-field-refs.test.ts` and `packages/objectql/src/metadata-diagnostics.test.ts` are deleted. The objectql case that asserted the deleted read now asserts the surviving `userFilters.tabs` read on the same unknown field. `HARD_CODED_FILTER_WALKS` drops `tabs.filter.field`. The stage-1 tree-scoped absence pin (`view-list-tabs-retirement.test.ts`) drops those two files from its self-expiring `RESIDUE`, as that set's own assertion requires. The CLI i18n entry stays. **3. Patch changesets:** `@objectstack/spec` (liveness/ is in its `files[]`), `@objectstack/lint` and `@objectstack/metadata-protocol`. Each carries `Clause-②: no`. objectql changes only a test file, and its `files[]` ships `dist` only, so it gets no changeset. ## Verification HEAD `7ee481ef2d` (base `6d67ad5eca`). Every `os-verify-lock` run below reports `VERDICT command-exit 0`. | Command | Result | |:--|:--| | `pnpm --filter '@objectstack/objectql^...' build` | spec, lint, metadata-protocol and the closure built, with declarations | | `pnpm --filter @objectstack/spec check:generated` | `✓ All 15 generated artifacts are up to date` | | `pnpm --filter @objectstack/spec typecheck` | exit 0 | | `pnpm --filter @objectstack/spec check:liveness` | exit 0, `✓ packages/spec/liveness/state-counts/ is current` | | `pnpm --filter @objectstack/spec test` | `Test Files 600 passed (600)`, `Tests 17601 passed / 1 todo` | | `pnpm --filter @objectstack/lint test && … typecheck` | `Test Files 119 passed`, `Tests 5574 passed`, test-typecheck OK | | `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 && … typecheck` | `Test Files 201 passed / 3 skipped`, `Tests 2983 passed / 19 skipped` | | objectql: three test files, `typecheck`, `build` | `metadata-diagnostics`, `view-container-divergent-name-registrars`, `metadata-validation-sweep`: `Tests 23 passed`; typecheck OK | **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` printed 91 commands. Each was run, and its exit code was recorded before any pipe. `--ran` printed `✓ dispatch-gates --ran: 91 derived famil(ies) accounted for — 90 run, 1 NOT-MEASURED`. That includes `check:adr-0087-registration` (`✓ … no declared-breaking changeset (3 non-breaking changeset(s) seen)`), `check-changeset-no-major` (`✓ This diff introduces no major bump`), `check-empty-changeset`, `check:changeset-gate-self-tests`, `check:doc-authoring`, `check:nul-bytes`, `check:cross-package-test-inputs`, and every `@objectstack/spec` `check:*` the derivation named. Two gates did not measure anything locally: - `NOT MEASURED: check:dual-build-cjs-loads`. It exited 3 (`PREREQUISITE NOT MET`) because it needs every package built, which this run did not do. - `NOT MEASURED: check-engine-split-ratio --days 90`. It exited 2 with `cannot compute … this clone is shallow`. It is an ADR trigger metric over git history, and it is recorded as not measured rather than as a pass. `check:lean-entry-closure` first exited 3 for lack of objectql's `dist`. After objectql was built it exited 0 (`✓ … Admitted set held exactly`). **Narrowing, declared:** the branch is not merged with `origin/main`. `ceb4a939b4` is 7 commits ahead, and `git diff --stat 6d67ad5 ceb4a93` over this PR's 11 paths is empty. CI's merge ref judges the combination. ## Acceptance notes - objectui's `tabs?: ListViewSchema['tabs']` mirror (types `objectql.ts`) belongs to objectui. It picks up the stage-1 tombstone at its next pin bump, and this PR does not edit it. - `packages/spec/src/ui/view.zod.ts` (the `ViewSchema` guidance comment, about lines 4720-4728) still says artifact-shipped containers and the validation sweep send the container `name`. That is the same misattribution the ledger note corrects. The comment is left alone, per this stage's no-`view.zod.ts` scope. Carrier: the next PR to touch that block, or none. - `skills/**`: no hits for the touched surfaces. - The at-tier contract review follows this PR (seat's note on the claim amendment). --- _Generated by [Claude Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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