Skip to content

fix(metadata-protocol,metadata): the runtime save door refuses a view container whose name disagrees with its save name, through the one judge every door calls (#21412) - #21483

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21412-save-door-container-name
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21412-save-door-container-name

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21412
Clause-②: yes (narrowing)

The runtime save door (saveMetaItem, which REST PUT /api/v1/meta/view/:name and the dispatcher's metadata save both call) now refuses an aggregated view container whose body name disagrees with the name it is saved under. It answers VALIDATION_ERROR / 400 before anything is stored or registered, and it refuses through the same judge the source registrars call. Before this change, the card's probe (row crm_lead, body name lead_views) was accepted, stored under crm_lead, and registered under lead_views plus crm_lead.default, so one document answered under two names.

This round follows the seat's answer on the card (comment 5961930912) to the dev's needs_decision report (5961864645): Q1 A, Q2 A, Q3 A and Q4 A.

What changed

  • One judge, in @objectstack/metadata. New subpath @objectstack/metadata/view-container-name (packages/metadata/src/view-container-name.ts). The judgement: a container's own name, when set, equals the key the door files the container under. It has two entries, which share one gate, one envelope and one message template:
    • viewContainerNameRefusal(container, sourceLabel, ownerId) is the source registrars' entry. Its key is DERIVED from the binding (deriveViewContainerObject). It is the function that used to live in packages/objectql, moved, and its words are byte for byte the same (proof below).
    • savedViewContainerNameRefusal(container, saveName) is the save door's entry. Its key is the save name.
  • Why the save door's key is the save name, not the binding. The door keeps a container saved under a name other than its object (the rest/meta: getViewsByObject / GET /meta/view?object= omits a runtime-authored view CONTAINER — #7163/#7736 expansion fix does not cover this path #13407 pin) and expands one on another package's object under its own name (metadata: a view container with a bare list on another package's object silently replaces that object's packaged default view on GET /meta/view?object= — while the by-name read still serves the original #21334's ruled arm). The dev's probes measured that the derived key would refuse the body the door itself stores for that shape when it is sent back, and would pass two bodies whose name disagrees with the row (P3, P4).
  • Why @objectstack/metadata. @objectstack/core cannot host the judge: the judge needs deriveViewContainerObject, which lives in @objectstack/metadata, and @objectstack/metadata lists core. @objectstack/metadata is the one layer all three doors already depend on. The judge is a subpath of its own, not the ./view-container leaf, because that leaf imports nothing and the judge needs isAggregatedViewContainer from @objectstack/spec. It is not on the root entry either, because the root loads the manager and the filesystem machinery that objectql's ADR-0076 lean entry must not reach. check:lean-entry-closure holds: @objectstack/objectql/core is 15 packages, the admitted set held exactly.
  • The save door (packages/metadata-protocol/src/protocol.ts, saveMetaItem) calls savedViewContainerNameRefusal(request.item, request.name) for view first, before normalizeViewMetadata can keep an authored name. Containers only. normalizeViewMetadata's docblock says so.
  • The artifact/HMR door's container branch (packages/metadata/src/plugin.ts) calls viewContainerNameRefusal(item, 'artifact', packageId) after it derives the key and before memLoader.save / manager.register. The probe document is now refused through the judge, in the judge's words. Row 1 (assertMetadataRegisterContract) is unchanged for every type; on this shape it is simply no longer reached.
  • @objectstack/objectql keeps viewContainerNameRefusal and the ViewContainerNameRefusal type as a re-export (src/view-container-name-refusal.ts). Its module header is rewritten: the judge's home, why it moved, why the source registrars' entry derives the key and the save door's takes it. engine.ts and packages/cli are untouched.
  • Rider 5954896314 (comment only): the comment above ViewSchema's guidance: in packages/spec/src/ui/view.zod.ts no longer says saveMetaItem sends the name, artifact-shipped containers do, and the sweep injects it. It says the door's own stamp (normalizeViewMetadata) is the only platform writer of the key and states the one rule, worded to Q1 A. No schema change.

The message: one template, two renderings

The per-door words are one value, the door's key origin. It fills four slots: the subject, the key clause, the text after the key, and the cross-reference after the shared reason. Everything else is shared.

  • The source registrars' rendering is byte-identical to the old objectql function. This was proven by a temporary test that compared old and new outputs over 10 fixtures, 3 of them refusals: message, code, status and httpStatus were all equal, Tests 1 passed. The test was deleted afterwards.
  • The save door's rendering differs in three places, and each has a reason:
    • Its subject is view container and not `views:` container from SOURCE 'OWNER': the save door has no views: collection and no owning manifest.
    • Its key clause is the name it is saved under.
    • It drops the cross-reference the artifact/HMR loader refuses this same document, which would be false at this door for P3 and P4: the artifact loader accepts a body whose name equals its binding.
  • The ruling said the renderings differ "only in the clause that names where the key came from". Read literally, that is one slot. All four slots carry that one fact, so this is reported as a measured reading, not chosen silently.

Pins (triage pins, restated to Q1 A)

  • packages/metadata-protocol/src/view-container-runtime-expansion.test.ts, the filing's own stub engine:
    • P1, the card's probe, is refused VALIDATION_ERROR / 400. No row is stored and nothing is registered.
    • P1's message is exactly savedViewContainerNameRefusal's, so the refusal goes through the judge.
    • P1's code / status equal the artifact/HMR registrar's for the same document.
    • P3 and P4 are refused, with nothing stored or registered.
    • P2 (name equal to the row, bound elsewhere) passes. The container is registered under exactly one key, its row key (Q4 A), and the object door serves crm_lead.default.
    • P2b (no name) passes, is stamped with the row name, and the stamped body passes when it is read and sent back.
    • A control with name, row and binding all equal passes, under one key.
  • packages/metadata/src/view-container-name.test.ts covers:
  • The boot loop's and os validate's refusal words are unchanged, and their pins are unedited: packages/objectql/src/view-container-name-refusal.test.ts (green) and packages/cli/test/validate-view-container-name.test.ts (CI; see Tests).
  • Edited because the ruling moves them: in packages/objectql/src/view-container-divergent-name-registrars.test.ts, the artifact door's two message assertions now read the judge's words (binds to, 'crm_lead', `name` is 'lead_views') instead of row 1's. The envelope-equality pin is unchanged.

Reverse verification (both from committed HEAD, through scripts/ablation-replace.mjs)

  • Save door. The call if (nameRefusal) throw nameRefusal; in protocol.ts was disabled (anchor 1 to 0, blob 478daa416f90 to 6f9ae10ffb7b), and view-container-runtime-expansion.test.ts went 5 failed | 65 passed (70): P1 three times, P3 and P4. P2, P2b and the control stayed green. The tool then restored the file to HEAD (blob == HEAD (478daa416f90), git diff HEAD empty). Both test and subject resolve the protocol from src, so no build was involved.
  • Artifact door. The same call in plugin.ts was disabled (blob 6924156b5fda to cef5ec76af37), and view-container-name.test.ts went 1 failed | 10 passed (11). The refusal came from row 1 instead (no httpStatus), so the assertion failed. The file was restored to HEAD. The test imports ./plugin.js from src.

Tests

Code at a70d0d61a4 is identical to 7d4ee0ac46 outside .changeset/. All runs went through scripts/pm/os-verify-lock.sh, and each gave VERDICT command-exit 0:

  • @objectstack/metadata: pnpm test gave Test Files 57 passed (57), Tests 847 passed (847), and pnpm typecheck exited 0. Its tsconfig includes src/**/*, so the tests are type-checked.
  • @objectstack/metadata-protocol: pnpm test gave Test Files 205 passed | 3 skipped (208), Tests 3092 passed | 19 skipped (3111), and pnpm typecheck exited 0 (tests included).
  • @objectstack/objectql: the local project gave Test Files 366 passed (366), Tests 7383 passed (7383), test:repo gave 1 passed, and typecheck exited 0. That covers tsc, the scripts project, and check:test-typecheck OK, held in the debt ledger.
  • Build: turbo run build --filter=@objectstack/objectql^... --filter=@objectstack/objectql gave 14 successful. The new subpath loads under both conditions (require and import each return both entries, VALIDATION_ERROR 400), and dist/view-container-name.d.ts / .d.cts are emitted.
  • Not run here: packages/cli, the os validate pins. @objectstack/objectql's export keeps its name, signature and bytes of output, and the cli imports it unchanged. Building the cli closure is 60 tasks, 11 of them cached. CI runs them. The byte-identity proof above is the local evidence.
  • One count control moved: packages/metadata/src/serializers/typescript-serializer-annotation.test.ts pins how many exports entries it visits (5 to 6), so the new entry is checked too.

Gates

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands was derived at a70d0d61a4 and gave 99 commands. All 99 were run at a70d0d61a4:

  • 98 exited 0.
  • 1 is NOT MEASURED: pnpm check:dual-build-cjs-loads exited 3 with PREREQUISITE NOT MET, because it needs every package's dist/ (the direct load check above stands in, but is not the gate).
  • Of note: check-adr-0087-registration --base origin/main: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. check-changeset-no-major: no major; the level axis needs a PR payload. check:lean-entry-closure: admitted set held. check:published-files, check:dts-closure, check:issue-citations (22 resolve), check:doc-authoring, check:spec-docblock-symbol-anchors, check:nul-bytes, check:test-source-alias, check:cross-package-test-inputs: all green.

Lint, a proven narrowing at a70d0d61a4: eslint --no-inline-config --format json over the 10 touched .ts files reports 10 files, 0 errors, 0 warnings, and none ignored. Every one is in eslint's population (--print-config resolves each). eslint.config.mjs never enables type-aware linting (its own note near line 327: no parserOptions.project, no typed rules), so this diff cannot move the verdict on any untouched file. The repo-wide pnpm lint is CI's.

Changesets

  • @objectstack/metadata minor: the new subpath, and the artifact door's refusal speaking through the judge.
  • @objectstack/metadata-protocol minor with a BREAKING banner: an accept-set narrowing at the save door, graded like the boot loop's refusal of the same divergence. Its ADR-0087 disposition is not-required (no-migration-prescription).
  • @objectstack/objectql patch: the re-export.
  • @objectstack/spec patch: comment only. src/**/*.zod.ts ships as source, and the comment ships in the ui JavaScript output (measured: the new sentence is in 20 dist files; the old one is in none).

The Clause-② line above is the claim's, copied as dispatched. By scripts/pm/clause2-line.mjs's own definitions, this diff both widens a public surface (the new @objectstack/metadata subpath, with two functions and a type) and narrows an accept set (the save door). That reads as yes (narrowing). This is raised to the seat in the dev report; the line here is not changed by the dev.

Acceptance notes


Generated by Claude Code

claude added 5 commits October 2, 2026 21:47
…es a container

The judge moves into @objectstack/metadata as the ./view-container-name
subpath: a container's own name, when set, must equal the key the door
files it under. The source registrars derive that key from the binding;
the runtime save door files under the save name. The artifact door's
container branch now refuses through the judge before the loader write.

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
…name disagrees with its save name

saveMetaItem now calls the one judge before normalizeViewMetadata can
keep an authored container name, so a container is never stored under
its row name and registered under its body's. objectql re-exports the
judge from @objectstack/metadata; the ViewSchema comment states who
writes a container's name and the rule every door applies to it.

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
…l, the re-export and the ViewSchema comment

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation protocol:ui tests tooling labels Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/metadata-protocol, @objectstack/metadata, @objectstack/objectql, @objectstack/spec, touching 12 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/metadata/package.json, packages/metadata/tsup.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json aa4632235ba571ef800b95e6bc18d00a30aa1d57.

⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/metadata/package.json, packages/metadata/tsup.config.ts) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 142 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 aa4632235ba571ef800b95e6bc18d00a30aa1d57 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0445fa50f4896ad2abfbfecb22c5224388f1b935 — the merge of head 0e7d0bdb71fd3b4a2c5249414fff6e2b6866556b into base aa4632235ba571ef800b95e6bc18d00a30aa1d57, 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 0445fa50f4896ad2abfbfecb22c5224388f1b935 && git checkout 0445fa50f4896ad2abfbfecb22c5224388f1b935
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aa4632235ba571ef800b95e6bc18d00a30aa1d57 0e7d0bdb71fd3b4a2c5249414fff6e2b6866556b && git checkout -B drift-repro aa4632235ba571ef800b95e6bc18d00a30aa1d57 && git merge --no-ff 0e7d0bdb71fd3b4a2c5249414fff6e2b6866556b

node scripts/docs-audit/affected-docs.mjs --json aa4632235ba571ef800b95e6bc18d00a30aa1d57

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs aa4632235ba571ef800b95e6bc18d00a30aa1d57 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0e7d0bdb71fd3b4a2c5249414fff6e2b6866556b
Local-runs: none

Inputs: card #21412 (body and all 7 comments: triage 5954055712, rider 5954896314, claim 5961640434 with revisions 2 and 3, needs_decision 5961864645, seat answer 5961930912, reports 5962619241 and 5962685002), PR #21483 (body, 15-file list, net diff against main at merge base 49524f69), and the check-runs on the head. Read-only; nothing built, run or re-run.

① Derived judgments

Accept-set changes the diff implies:

  1. Runtime save door (saveMetaItem, packages/metadata-protocol/src/protocol.ts, types view / views): an aggregated container (list / form / listViews / formViews, no viewKind) whose own name is a non-empty string that differs from request.name is now refused VALIDATION_ERROR / 400 through savedViewContainerNameRefusal, before normalizeViewMetadata can keep the authored name and before any store or register. RIGHT: this is Q1 A (key = the save name), Q2 A (containers only) and triage pins 1 to 3. Placement checked against main: saveMetaItem opens at line 17270 and carries no return ahead of the normalize block, so every writer that reaches the door (REST PUT, the dispatcher, migrateStoredMetadata at 18829, duplicatePackage at 21755, draft mode) passes the judge; request.name is MetadataItemNameSchema, a non-empty string, so the judge's falsy-key precondition cannot stand down at this door. P1, P3, P4 refused with nothing stored and nothing registered; P2, P2b and the equal-everywhere control pass under exactly one container key (Q4 A); the stamped body round-trips. A standalone ViewItem and every non-view type stay unjudged, which is right: the every-type half is finding(metadata-protocol): the runtime save door accepts any metadata body whose name differs from its row name, and registers it under the body name (the every-type half of #21412) #21470.

  2. Artifact/HMR door, container branch (packages/metadata/src/plugin.ts, _registerArtifactBodyCollections): the judge runs after deriveViewContainerObject and slots.skip, before applyProtection, memLoader.save and manager.register. Accept set UNCHANGED: the judge's refusal set is a subset of row 1's (assertMetadataRegisterContract refused the same document after the loader write and still runs after the judge, unedited for every type). What moves is the refusal's words (the judge's, source label artifact) and its moment (before the loader write). RIGHT: Q3 A as the seat corrected triage's literal form. The views: subject is accurate at this door, since ARTIFACT_FIELD_TO_TYPE maps the views field to view.

  3. Boot loop and os validate / os compile: unchanged. I compared the old objectql template against the new refusal() filled with the derived entry's KeyOrigin slot by slot: for every (sourceLabel, ownerId, name, key) the bytes are identical. The predicate order moved (the key is derived before the name test), but deriveViewContainerObject is total on unknown and judge() keeps the same four tests (container, non-empty string name, truthy key, inequality), so the boot precondition (a falsy derived key refuses nothing) is carried. The unedited pins (packages/objectql/src/view-container-name-refusal.test.ts, packages/cli/test/validate-view-container-name.test.ts) hold this. RIGHT.

Public-surface changes the diff implies:

  1. @objectstack/metadata gains the subpath ./view-container-name (package.json exports plus a tsup entry) exporting viewContainerNameRefusal, savedViewContainerNameRefusal and the type ViewContainerNameRefusal. A widening. RIGHT: Q3 A, and the home is correctly reasoned on measured edges (core cannot host it because metadata lists core; objectql and metadata-protocol both list metadata; metadata lists spec). Its own entry rather than the zero-import ./view-container leaf or the root is right too: the judge needs isAggregatedViewContainer from @objectstack/spec, and the root loads the manager and filesystem machinery objectql's ADR-0076 lean entry must not reach. The lean closure's package set does not move: spec and metadata were already in objectql's closure through the old module.

  2. @objectstack/objectql: viewContainerNameRefusal and its type are kept as re-exports at the same module path; src/index.ts (112 to 113) and engine.ts (260) are untouched, as the claim required. No surface change. RIGHT.

  3. @objectstack/spec: comment-only edit above ViewSchema's guidance: (the rider 5954896314, worded to Q1 A). No schema, parse, export or accept-set change; a comment inside a schema literal cannot move the d.ts surface. RIGHT.

  4. packages/metadata/src/serializers/typescript-serializer-annotation.test.ts: the exports-entry control is derived from package.json exports, which go from 5 entries to 6. RIGHT.

  5. packages/objectql/src/view-container-divergent-name-registrars.test.ts: the artifact door's two message assertions now read the judge's words; the convergence pin (boot code and status equal the artifact door's) is unedited and still holds, both now coming from the judge. RIGHT.

Nothing judged wrong.

② Semver level

Four changesets, each judged against what its package publishes:

  • @objectstack/metadata minor, Clause-②: yes: the new subpath is an additive widening; minor is the floor. Right. The artifact door's message change is not an accept-set change (the envelope is the contract), so no banner is owed there.
  • @objectstack/metadata-protocol minor with a BREAKING banner, Clause-②: no (narrowing), ADR-0087 disposition not-required (no-migration-prescription): an accept-set narrowing at a write door is breaking and ships minor under the launch-window convention (check-changeset-no-major.mjs's own header). The category is right: no key is removed, renamed or re-shaped, there is no tombstone and nothing mechanical for migrate meta to rewrite, and which of the two names a divergent container meant is authoring intent no conversion entry can decide; the other four categories are closed in the marker on facts. The body states what was accepted before, what is refused now and the remedy, which is the migration text the rule asks for; its remedy sentence is imperative prose, not a FROM-to-TO rewrite, so it does not contradict the category. Right.
  • @objectstack/objectql patch, Clause-②: no: a re-export with the same name, signature and output bytes. Right.
  • @objectstack/spec patch, Clause-②: no: the comment ships in the ui JavaScript output, so a patch is the honest grade for bytes that ship; not skip-changeset. Right.

The PR body's declaration reads Clause-②: yes (narrowing), which is right by scripts/pm/clause2-line.mjs's definitions: the diff widens one published surface (the subpath) AND narrows an accept set (the save door). no (narrowing) would deny the widening; a bare yes would hide the break. The claim's revision 3 carries the same line. The per-changeset lines are each truthful for their own package and union to the PR's line; a yes (narrowing) copied onto the objectql or spec patch changesets would be false for those packages, and the ADR-0087 gate reads the arm (narrowing) and the banner, both of which the breaking changeset carries.

Gate verdicts on the head: Check Changeset (the job that runs check-empty-changeset, check-adr-0087-registration and check-changeset-no-major) completed success on 0e7d0bdb.

③ Boundary flags

Dev flags from the PR body and the reports 5962619241 and 5962685002:

  1. Clause-② copied as no, measured as yes (narrowing) (report open question 1): answered. The seat revised the claim (revision 3), the PR body reads yes (narrowing), and round 2 gave each changeset its own package's arm. Closed; judged right in ②.
  2. The per-door words fill four slots, not one clause (open question 2): answered, accepted. The governing text requires one judge, no second rule, no copy of the message, and the boot loop's and os validate's words byte for byte. All four hold: one judge(), one refusal() template, a KeyOrigin whose slots carry only where the key came from, and a source-registrar rendering identical to the old bytes. The views: manifest subject and the artifact cross-reference cannot be true at the save door, so the save-door rendering drops them. The seat's "only the clause naming where the key came from" is met in substance; recorded as a measured reading, not escalated.
  3. Edits outside the four pin files the ruling named (registrars test, serializer count test, package.json, tsup.config.ts): answered, accepted as forced consequences, and the claim's revision 3 already covers them.
  4. The artifact door's judge sits after the residual-sweep slots.skip: answered, accepted. A skipped duplicate files nothing either way, and the judge judges what the branch would file.
  5. Not run locally: the packages/cli validate pins and check:dual-build-cjs-loads: answered. These are CI's. At read time Build Core, Test Core 1 to 6 and Lint & Repo Gates are in progress on this head (list below); a check still running is read as running, not as a pass.
  6. Carrier finding(metadata-protocol): the runtime save door accepts any metadata body whose name differs from its row name, and registers it under the body name (the every-type half of #21412) #21470, noted not filed (PR acceptance notes; report out-of-scope finding): at-rest container rows that already carry a divergent name keep registering under it at boot, and revertCommit / rollbackMetaItem re-persist stored versions without the save seam; the row count is not measured. Answered: carried by finding(metadata-protocol): the runtime save door accepts any metadata body whose name differs from its row name, and registers it under the body name (the every-type half of #21412) #21470 per the seat (5961930912, "Carried"), outside this card by Q2 A. Not escalated. One observation added for that card's census, not a defect of this diff: migrateStoredMetadata (protocol.ts 18829) and duplicatePackage (21755) re-enter saveMetaItem, so such a pre-existing divergent row now refuses loudly on a stored-migration or duplicate pass instead of re-persisting; the row keeps its bytes, which is what the changeset promises.
  7. origin/main moved during the round (the branch is 2 behind at read): no overlap on the touched files was claimed or found; the queue's rebase lap carries it.

Open questions on the final report (5962685002): none. The earlier Q1 to Q4 (5961864645) were answered by the seat (5961930912) and the diff implements A, A, A, A as judged in ①.

CI on the head at read time: 14 check-runs completed success (filter, Check Changeset, Type Check source gates, Spec property liveness, Governed Surface Queue Guard, Validate Package Dependencies, Check PR Size, Check Documentation Links, Flag docs affected by code changes, Auto Label, and the four claim and single-writer guards); 3 skipped (Packed-tarball smoke, Console Pin Gate, Build Docs); 16 in progress (Build Core; Test Core 1 to 6; Type Check workspace, debt ledger and consumer gates; Lint & Repo Gates; Temporal Conformance; Dogfood Verify CLI; Dogfood Regression Gate 1 to 3). This record's PASS is on the diff, the card and the completed checks; landing still waits for every check green.

Implemented-by: claude/issue-21412-save-door-container-name
Reviewed-by: session_01DDZNkDVwPQnevTFcYE47H3

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 23:24
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 5555047 Oct 2, 2026
38 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21412-save-door-container-name branch October 2, 2026 23:57
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ody whose name disagrees with its row name, for every type, through the one judge (objectstack-ai#21470) (objectstack-ai#21536)

Fixes objectstack-ai#21470
Clause-②: yes (narrowing)

Every runtime door that writes a `sys_metadata` row now refuses a body
whose own `name` disagrees with the name it writes the row under, for
every metadata type, with `VALIDATION_ERROR` / 400, before anything is
stored or registered. The refusal goes through the one judge objectstack-ai#21412
landed (`@objectstack/metadata/view-container-name`). The doors are
`saveMetaItem`, `rollbackMetaItem`, the restore limb of `revertCommit`,
and the draft promotion that `publishMetaItem` and
`publishPackageDrafts` share.

It follows the seat's answer on the card (5964758196: Q1 A on a premise,
Q2 A), the claim's revision 2 (5964548518), and triage's direction
5962080068.

## The judge

- `savedItemNameRefusal(type, item, saveName, door)` replaces
`savedViewContainerNameRefusal(container, saveName)` on the subpath.
`door` is `'save'`, `'restore'` or `'publish'`.
- **The rule is row 1's predicate** (`assertMetadataRegisterContract`):
a `name` the body carries (`!== undefined`) must equal the name the row
is written under. There is one exception, and it belongs to the door,
not the type: the save door stamps a missing view `name` after the judge
runs. So for a `view` at `'save'`, a `name` counts as set only when it
is a non-empty string. That is the container case's behaviour from
objectstack-ai#21412, unchanged.
- **Unchanged:** `viewContainerNameRefusal` (the source registrars'
entry), its words, `objectql`'s re-export, the boot loop and `os
validate`.
- **Published surface.** The subpath has never shipped:
- `npm view @objectstack/metadata@latest exports --json | grep -c
view-container-name` prints `0`. The control: `"./view-container"`
counts `1`, and latest is `17.6.0`.
- `git ls-tree origin/main
.changeset/21412-metadata-view-container-name-judge.md` prints the blob
line `c8df04b2…`.
- Both were re-checked before this push, on `origin/main` `c98a72d69e`.
- So no published export is removed. The PR's line reads `Clause-②: yes
(narrowing)` because the subpath's export set changes against `main`.

### The container case is byte for byte unchanged

I rendered the save-door words for P1, P3 and P4 and the derived entry's
words for P1, before the change (`savedViewContainerNameRefusal`) and
after it (`savedItemNameRefusal('view', …, 'save')`), each from the
built `dist`. Both renders give the same sha256,
`0f65c121514e148caae28f82f03b64db2fcd21c04b561ccb75f73af4bb629352`, and
`cmp` reports them identical. The control: after the build, the `dist`
has 0 hits for the old name and 2 for the new one.

### The words for every other body and door (rendered from `dist`)

```text
Invalid dashboard: its own `name` is 'dash_b', which disagrees with the name it is saved under, 'dash_a'. A disagreement is almost always an authoring bug, and resolving it silently in either direction can file the item under a key the caller never wrote (refuse loudly, locate the mismatch). Register under one name: set `name` to 'dash_a', or save the item under 'dash_b'.
Invalid view: its own `name` is 'crm_lead.other', which disagrees with the name it is saved under, 'crm_lead.mine'. … Register under one name: drop `name`, or set it to 'crm_lead.mine'.
Invalid field: its own `name` is 'zz_probe', which disagrees with the name it is saved under, 'crm_task.zz_probe'. … Register under one name: drop `name` (a `field` row is named object.field, which its column `name` cannot spell).
Invalid dashboard version: its own `name` is 'dash_b', which disagrees with the name it is restored under, 'dash_a'. … Register under one name: save the item with `name` set to 'dash_a', or under 'dash_b', instead of restoring this version.
Invalid dashboard draft: its own `name` is 'dash_b', which disagrees with the name it is published under, 'dash_a'. … Register under one name: save the draft again with `name` set to 'dash_a', or under 'dash_b', then publish it.
```

Each remedy is true for its type and its door:

- **`drop name`** is offered only where dropping works: a view (the save
door stamps a missing name), and a `field`. `FieldSchema` does not
require `name`, and its `name` is dot-free, so it can never equal an
`object.field` row name.
- **`set name`** is offered for every other type, together with the
opposite direction: save the item under its own `name`. A save name the
type's schema cannot spell leaves only that direction. Measured: 22 of
the 27 schema'd registry types refuse a dotted body `name`, and the save
door's grammar admits dotted row names.
- **At the restore and publish doors**, the remedy is the save that
fixes the stored body, because their caller cannot edit a stored version
or draft in place.

## Callers in `protocol.ts`

- **`saveMetaItem`**: the existing call site, now for every type, still
before `normalizeViewMetadata`. ⛔ PR objectstack-ai#21473's public-form lines are not
touched; its nearest hunk sits about 230 lines above.
- **`rollbackMetaItem` and `revertCommit`'s restore limb**: through a
new private `restoredBodyWriter(type, name)`. It is the
`deriveRestoredBody` that `repo.restoreVersion` calls on the very
history body it read, before it reads the active row and before `put`.
It runs the judge first and then the existing credential-channel strip
(objectstack-ai#20790 R2). A refused version writes nothing:
  - `rollbackMetaItem` rethrows the refusal;
  - `revertCommit` reports `failed[]` with `code: 'VALIDATION_ERROR'`.
- **`promoteDraftForPublish`**: judges the `draftForGate` body before
`repo.promoteDraft` writes, beside the existing authoring gate and in
the same way. ⛔ PR objectstack-ai#21473's promotion gate inside `publishMetaItem`
(about `:19410`) is not touched.

## Census of at-rest rows (triage step 1)

Taken on `objectstack-ai/objectstack` at `e9dec3dab`. Each bootable
example booted with `--fresh` and its seeds. Every `sys_metadata` and
`sys_metadata_history` row was read straight from the fresh SQLite file
(read-only), and each body's `name` was compared with its row's `name`.

| app | boot | seeds | `sys_metadata` rows | body name differs |
`sys_metadata_history` rows | body name differs |
|---|---|---|---|---|---|---|
| app-crm | `pnpm dev:crm -- --fresh` | 28 | 0 | 0 | 0 | 0 |
| app-todo | `pnpm dev:todo -- --fresh` | 8 | 0 | 0 | 0 | 0 |
| app-showcase | `pnpm dev -- --fresh` | 132 | 0 | 0 | 0 | 0 |
| app-multi-package | `pnpm --filter @objectstack/example-multi-package
dev -- --fresh` (no root script) | none printed | 0 | 0 | 0 | 0 |
| embed-objectql | not bootable (a vitest demo) | n/a | no
`sys_metadata` table: no metadata protocol in its closure | n/a | n/a |
n/a |
| hosted tenant | **NOT MEASURED**: no cloud access in this session | |
| | | |

- **Control (the reader sees WAL-resident data):** `sys_user` reads 1 /
1 / 3 / 1 and `sys_permission_set` reads 10 / 8 / 17 / 8 in the same
four files.
- **Step 3 needs no conversion on this corpus.** The census finds 0
rows, so on the measured corpus there is nothing to convert first.
- **A stored row stays readable.** A row stored before this change keeps
its bytes. The write doors now refuse to re-write it: a
`migrateStoredMetadata` pass reports it `failed`, and a rollback, revert
or publish of it is refused with the remedy.

## Measured before the change (`origin/main` `e9dec3dab`, a probe
battery since deleted)

- **P6** (record view, row `crm_lead.mine`, body `name`
`crm_lead.other`): accepted. The registry key was `crm_lead.other` only.
- **P7** (dashboard, row `dash_a`, body `name` `dash_b`): accepted. The
registry key was `dash_b` only.
- **R1** (`rollbackMetaItem` to a stored version whose body `name` is
`dash_b`): it restored that version with 0 `saveMetaItem` calls. The key
was `dash_b`.
- **R2** (`revertCommit`, `prevVersion` that version): `revertedCount:
1` with 0 `saveMetaItem` calls. The key was `dash_b`.
- **D1** (`publishMetaItem` of a draft row `dash_d` whose body `name` is
`dash_e`): promoted with 0 `saveMetaItem` calls. The key was `dash_e`.
- **An empty or non-string `name` on a non-container type** (the seat's
added pin), measured per type against `getMetadataTypeSchema`:
- 24 of the 27 schema'd registry types already refuse `''`, `7` and
`null` (422).
  - `seed` declares no `name` at all, so it refuses any `name`.
  - `view` stamps a falsy `name` (and the schema refuses `7`).
  - `translation` **accepts `name: ''`**.
  - `external_catalog` has no schema, so it accepts anything.
- So `''` reaches persistence for `translation`, and it is keyed `''` in
the registry; any value reaches persistence for `external_catalog`. The
judge therefore refuses a set non-view `name` whatever its value. See
the first item under the decisions below.

## Pins

All in
`packages/metadata-protocol/src/protocol.item-name-every-door.test.ts`.
It is a stub engine that stores rows and history, whose registry keys an
item by its `name`, and whose transaction rolls back on a throw
(ADR-0067 D2). It runs on the topology where non-`object` types write
through to the shared registry.

- **P6, P7, and `translation` with `name: ''`:** refused with
`VALIDATION_ERROR` / 400. Nothing is stored and nothing is registered.
- **Equal or absent `name` passes:** a dashboard stored and keyed
`dash_a`. A nameless record view is stamped and keyed by its row. A
nameless dashboard passes the judge and meets its schema's own 422.
- **One registry key per row:** asserted on every control.
- **R1, R2 and D1 refused with P6/P7's envelope, nothing written.** The
active row, the history length and the registry are unchanged; there are
no `saveMetaItem` calls; the draft is kept. Each has a clean control
through the same door.
- **The `publishPackageDrafts` batch case:** one refused draft aborts
the batch, as the authoring gate's refusal does. The outcome is
`refused` and `failed[]` lists the refused draft with `VALIDATION_ERROR`
and its sibling as aborted. Nothing goes live, and the registry is
empty. A clean control publishes both.
- **The judge's own pins**
(`packages/metadata/src/view-container-name.test.ts`): every type; every
door; what counts as set (row 1's predicate, the view stamp only at the
save door); the remedy per type and per door; and `field`.
- The SCOPE pin ("a standalone ViewItem is not judged here") flips by
design.

The stored bodies that R1, R2, D1 and the batch case need are staged the
way they exist in a deployment. A clean body goes through the real door,
and its stored bytes are then rewritten in the double, because after
this change no door writes one.

## Ablation and reverse verification (each from a committed state,
through `scripts/ablation-replace.mjs`)

Each run's direction was declared before it ran, and each observed
result matched:

| run | mutation | result |
|---|---|---|
| A1 | neutralize the `saveMetaItem` call (src) | the every-door file 3
red / 9 green (P6, P7, `translation`);
`view-container-runtime-expansion.test.ts` 5 red (objectstack-ai#21412's P1 ×3, P3,
P4) |
| A2 | neutralize the restore writer's judge (src) | 2 red / 10 green
(R1, R2) |
| A3 | neutralize the publish judge (src) | 2 red / 10 green (D1, the
batch case) |
| Reverse, through `dist` | the judge's write-door entry put back to
container-only, in `packages/metadata/src`, then rebuilt |
`ablation-dist-preflight` found the marker in 2 built files; the
every-door file 7 red / 5 green (every refusal red, every control
green); the container file stays green |

- **Every run restored cleanly.** Each restore leg ended with the blob
equal to `HEAD` and an empty `git diff HEAD`.
- **The reverse run's restore leg:** a rebuild, the `--absent`
preflight, and 12 / 12 green.
- **A refused first attempt:** the first reverse attempt was refused by
the tool before anything ran, because its replacement contained the
anchor. It was recorded as a non-run and rerun with a non-overlapping
replacement.

## Tests (at `181408e1e5`; core pins again at `056df2c896` after the
last merge of `main`)

- `@objectstack/metadata`: `src/view-container-name.test.ts` passes 19 /
19, and the full suite earlier passed 858 / 858.
- `@objectstack/metadata-protocol`: the full suite passes 3163, with 19
skipped. At `056df2c896` the every-door and container files pass 131 /
131.
- **Downstream files that exercise the write doors,** run at the earlier
head with a rebuilt `metadata-protocol` `dist`:
  - `objectql`: 42 files, 534 tests;
  - `rest`: 53 files, 1363 tests;
  - `runtime`: 46 files, 1557 tests;
  - `plugin-security`: 7 files, 150 tests;
  - `service-automation`: 2 files, 8 tests;
- `plugin-email`, `service-cluster`, `mcp`, and `cli` (unit tier): 2 + 1
+ 2 + 2 files.
  - All green after the fixture triage below.
- `typecheck`: `metadata`, `metadata-protocol` and `objectql` all green
(the last including `check:test-typecheck`, 65 pinned signatures held).
`--listFiles` confirms the new and edited test files are compiled.
- **Lint, a declared narrowing.** `eslint --no-inline-config --format
json` over the 13 touched `.ts` files gave 13 results, 0 errors, 0
warnings, and none ignored. The touched population is all of them:
`eslint.config.mjs` lints `**/*.{ts,…}` minus its `NEVER_LINTED` set.
Untouched files cannot move, because the config enables no type-aware
linting (no `parserOptions.project`, as its own header states).
- **Left to CI:** `packages/qa/dogfood` (25 files touch these doors; the
PUT bodies I read there name their row or echo a GET),
`qa/http-conformance`, and the `cli` integration tier.

## Gates

`dispatch-gates --commands` on the final diff derived 74 families, a
superset of the PM's lead. The 74 are its 56 plus 18: the changeset,
objectql and ledger families. `--ran` with each exit code recorded
answered `74 derived famil(ies) accounted for — 74 run, 0 NOT-MEASURED`.
73 exited 0. At `056df2c896` the ratchet family was rerun:
`engine-double-contract`, `objectql-double-limit`,
`query-options-erasure`, `slot-lookup`, `where-matcher`,
`type-check-debt`, `type-check-coverage`, `doc-authoring`,
`cross-package-test-inputs`, `test-source-alias`, `nul-bytes`,
`keyed-text-bounds`, `undeclared-dep-imports`, `adr-0087-registration`
and `changeset-no-major`. All exited 0.

- **`check:engine-double-contract`** asked for the new test's pinned
double to be recorded (`--write`). That is the 15-line addition to
`scripts/engine-double-contract.pinned.json`, and nothing else moved.
- **`check-empty-changeset` exits 1, deliberately: it is a DELIBERATE
CORRECTION.** It needs confirmation on this PR (see below).
- **`check-changeset-no-major`'s clause-② axis** reads `NOT APPLICABLE`
locally (there is no `pull_request` payload); CI reads it on this PR.

## Changesets

- `.changeset/21470-metadata-write-door-item-name-judge.md`:
`@objectstack/metadata`, minor, `Clause-②: yes`.
- `.changeset/21470-metadata-protocol-every-write-door-item-name.md`:
`@objectstack/metadata-protocol`, minor, with the **BREAKING** banner
and `Clause-②: no (narrowing)`. Its ADR-0087 disposition is
`not-required (no-migration-prescription)`, with the census (0 rows) in
the marker, as PR objectstack-ai#21483's was.
- **Two pending objectstack-ai#21412 release notes are corrected, because this PR
makes a sentence in each false.** They are named here for confirmation,
as `check-empty-changeset` asks:
- `.changeset/21412-metadata-view-container-name-judge.md`: the sentence
naming `savedViewContainerNameRefusal(container, saveName)` now names
`savedItemNameRefusal(type, item, saveName, door)` and says it judges
every type. The seat ordered this one (5964758196).
- `.changeset/21412-metadata-protocol-save-door-container-name.md`: its
last line read "Not judged here: a standalone view record (`viewKind`)
and every other metadata type". The same release now judges them, so the
line points to this PR's entry. ⚠ The seat's answer named only the first
note. This second one is my addition, under "every changeset sentence
must be true".

## Decisions the review should check

1. **An empty or non-string `name` on a non-view type is refused by the
judge,** which runs before the schema. Where the type's schema already
refused such a body (`''`, `7` or `null` on 24 types; any `name` on
`seed`), the answer moves from the schema's `INVALID_METADATA` / 422 to
`VALIDATION_ERROR` / 400. Nothing is stored either way.
- The seat's text said "If the schema already refuses it, record that
and add nothing". I did not make the judge schema-aware to honour that
per type, because that would be a second rule keyed on schema knowledge.
One predicate (row 1's) covers both `translation`'s `''` and
`external_catalog`'s anything.
   - The changeset says so.
2. **`field`.** A `field` row is named `object.field`, and its canonical
body carries the dot-free column `name`. Registered, the row answered
under the column name, and every object's `title` field collided on one
key. That is pin 3's defect, so the judge refuses it, and the remedy is
"drop `name`".
- The type is code-only (objectstack-ai#5086) and was ruled REMOVE (objectstack-ai#7893), so this is
reachable only through the `OS_METADATA_WRITABLE` operator hatch.
- The hatch-path fixtures in two test files now send a nameless field
body: `protocol.code-only-types.test.ts` and
`protocol.destructive-gate-reachable-types.test.ts`. What those tests
measure (the hatch's routing, the destructive gate's reach) is
unchanged.
- The alternatives were to exempt `field` (which keeps the collision) or
to judge it against the column half of its row name (a type-specific key
derivation).
3. **The `door` parameter and the two-direction remedy go beyond the
seat's suggested `savedItemNameRefusal(type, item, saveName)`.** They
exist so the remedy is true at a door whose caller cannot edit the
stored body, and for a save name the type cannot spell.

## Fixture triage (bodies that only used a constant `name`)

The rule's consumer radius covers other packages' fixtures, so they were
swept and re-judged. Each fixture below only used the `name`, so each
was rewritten to name its row, or to send none where the door stamps
one. What each test measures is unchanged.

- `metadata-protocol`: `protocol.item-name-grammar.test.ts` (`VIEW_BODY`
is now nameless; the door stamps the request name) and
`protocol.runtime-gate-stored-universe.test.ts` (`oneWidgetBoard` takes
the row name).
- **`objectql`:**
  - `protocol-recorded-by-null.test.ts` (`viewBody` takes the row name);
  - `protocol-save-meta-repo-path.test.ts` (`view_one` becomes `v`);
- the two `*-meta-response-conformance.test.ts` files (`cleanFlow` is
named `bounded_purge`, the row it is saved under).
- `field`: see the decisions above.

## Not in this PR

- **Boot hydration** (`loadMetaFromDb`, then
`hydrateOverlayIntoRegistry`) keeps registering a row stored before this
change under its body `name`.
- It is residue only: once the write doors judge, no new row can
diverge.
- The census found 0 such rows on the examples; the hosted tenant is NOT
MEASURED.
- It is a reader, outside this claim (⛔ not `getMetaItem`,
`readFlattenedMetaItems` or `hydrateExpandedViewItems`; objectstack-ai#21510 and
objectstack-ai#21511 are queued behind this card).
  - A measured hosted instance would be the trigger to file it.
- **A non-view body with no `name`** still registers nothing at all
(`hydrateOverlayIntoRegistry` skips a nameless body). This is
pre-existing, and triage's pin 2 says an absent `name` passes.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation protocol:ui size/l tests tooling

Projects

None yet

2 participants