Skip to content

fix(metadata-protocol,metadata): every runtime write door refuses a body whose name disagrees with its row name, for every type, through the one judge (#21470) - #21536

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21470-save-door-every-type
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21470-save-door-every-type

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #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 #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 finding(metadata-protocol): the runtime save door accepts a view container whose body name contradicts its row name and registers it under both keys; the two source registrars refuse the same document #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)

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

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 (#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

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".
  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


Generated by Claude Code

claude added 9 commits October 3, 2026 02:53
…against its row name

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
… two pending 21412 sentences

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
…he every-type judge

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/metadata, touching 25 documentable anchor(s).

22 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 550f4cc2fd594f5965ef9b12da38538064ea9c9b.

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

What this run could not see
  • 5 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 — 17 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 550f4cc2fd594f5965ef9b12da38538064ea9c9b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 91c0345ad184cd4d59bb911b7180ae6409aba7b5 — the merge of head 056df2c896ad92dba3a1488107bebae8b4fd35fa into base 550f4cc2fd594f5965ef9b12da38538064ea9c9b, 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 91c0345ad184cd4d59bb911b7180ae6409aba7b5 && git checkout 91c0345ad184cd4d59bb911b7180ae6409aba7b5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 550f4cc2fd594f5965ef9b12da38538064ea9c9b 056df2c896ad92dba3a1488107bebae8b4fd35fa && git checkout -B drift-repro 550f4cc2fd594f5965ef9b12da38538064ea9c9b && git merge --no-ff 056df2c896ad92dba3a1488107bebae8b4fd35fa

node scripts/docs-audit/affected-docs.mjs --json 550f4cc2fd594f5965ef9b12da38538064ea9c9b

⚠️ 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 550f4cc2fd594f5965ef9b12da38538064ea9c9b → 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: 056df2c896ad92dba3a1488107bebae8b4fd35fa
Local-runs: none

PR #21536 for card #21470, isolated review subagent, inputs read at 2026-10-03T04:33Z: the card's body and all six comments (5962080068, 5963353123, 5964548518 revision 2, 5964734326, 5964758196, 5965475783), the PR body and its 18-file list, the net diff origin/main...origin/claude/issue-21470-save-door-every-type (18 files, +973 / −134, merge-base c98a72d69e, branch head equal to the sha above), the 31 check-runs on that head, and the Check Changeset job log. Nothing was built, run or re-run; the two registry reads the seat's answer asks for at ACCEPT (git ls-tree origin/main on the pending notes, npm view @objectstack/metadata@latest exports) were taken as reads and are reported under ②.

① Derived judgments

Each accept-set or public-surface change the diff implies, named and judged.

  1. @objectstack/metadata/view-container-name export set, against main: savedViewContainerNameRefusal(container, saveName) removed, savedItemNameRefusal(type, item, saveName, door) added; viewContainerNameRefusal(container, sourceLabel, ownerId) and the ViewContainerNameRefusal type unchanged. Right. It is the one every-type entry the seat ruled (5964758196 Q1 A); the subpath is unreleased (②), so no published export is removed. objectql's re-export (packages/objectql/src/view-container-name-refusal.ts) names only viewContainerNameRefusal and is untouched; the boot loop and os validate are untouched; grep of the old name at the head finds it only in the changesets.
  2. The container case is byte for byte unchanged, at both entries. Right, verified by reading the two renderers rather than the PR's sha256: the old refusal() printed the container's own and drop \name`, or set it to 'KEY'as fixed text; the new one printsorigin.ownerandorigin.remedy, which for a view container at the save door are CONTAINER_OWNERandstampedRemedy(key), the same bytes. viewContainerNameRefusal keeps its gate (isAggregatedViewContainer, a non-empty string name, a falsy derived key refuses nothing, equality passes). The judge pins P1 to P4 and the door pin in view-container-runtime-expansion.test.ts` (P1 message equals the judge's) still hold the container words.
  3. The save door (saveMetaItem) narrows for every type: a body whose own name is set and is not request.name is refused VALIDATION_ERROR / 400 before anything is stored or registered. Right. The predicate is row 1's (assertMetadataRegisterContract, packages/core/src/metadata-service-contract.ts: documentName !== undefined && documentName !== name), carried whole; the one exception belongs to the door, not the type: a view at 'save' counts name as set only when it is a non-empty string, because normalizeViewMetadata stamps a falsy one after the judge, which is finding(metadata-protocol): the runtime save door accepts a view container whose body name contradicts its row name and registers it under both keys; the two source registrars refuse the same document #21412's gate unchanged. Triage's pins: P6 and P7 refused; an equal or absent name passes; one registry key per row. All pinned in protocol.item-name-every-door.test.ts with controls (a nameless view is stamped and keyed by its row; a nameless dashboard passes the judge and meets its own schema's 422).
  4. The restore doors (rollbackMetaItem, revertCommit's restore limb) narrow the same way, and a refused version writes nothing. Right. restoredBodyWriter(type, name) is handed to repo.restoreVersion as deriveRestoredBody; in sys-metadata-repository.ts that hook runs on the history body it just read, before the active-row read and before put, so the throw leaves the row, the history and the registry untouched. rollbackMetaItem's catch rethrows anything but a ConflictError, so the caller gets the judge's envelope (R1); revertCommit's limb records it in failed[] with clientFacingFailureCode, which passes a ledger code such as VALIDATION_ERROR through (R2). The strip for security(flows): move a flow's inbound-hook secret out of flow metadata into the write-only secret seam #7799 established — no read, the generic data door included, returns it #20790 R2 runs after the judge, and a body with no credential channel comes out byte for byte, so passing the hook unconditionally changes nothing else. revertCommit judges under PLURAL_TO_SINGULAR[it.type] ?? it.type, the spelling the write-through registers under, as its neighbouring reads do.
  5. The publish door narrows once, for both callers. Right. promoteDraftForPublish is the one path publishMetaItem and publishPackageDrafts share (its two call sites at the head), and it judges draftForGate.body before repo.promoteDraft, beside the authoring gate. The seat's added pin, what the batch does on one refused draft, is held: outcome: 'refused', failed[] lists the refused draft with VALIDATION_ERROR and its sibling as aborted, nothing goes live, the registry stays empty. PR fix(metadata-protocol): refuse an org-scoped public form withdrawal a walled posture cannot honour #21473's promotion gate inside publishMetaItem is not in this diff.
  6. **field through the OS_METADATA_WRITABLE hatch: a body name is refused at every door, remedy drop \name`.** Right. A fieldrow isobject.fieldandFieldSchema.nameis dot-free and.optional() (packages/spec/src/data/field.zod.ts), so the body name can never equal the row and dropping it is a real fix; registered, the row answered under the column name, every object's same-named field on one key, which is pin 3's defect. The two hatch fixtures (protocol.code-only-types.test.ts, protocol.destructive-gate-reachable-types.test.ts`) now send a nameless body while their stored rows keep theirs, and what they measure (the hatch's routing, the destructive gate's reach) is unchanged by reading.
  7. The envelope for bodies a schema already refused moves from INVALID_METADATA / 422 to VALIDATION_ERROR / 400 (an empty, null or non-string name on 24 types; any name on seed). Right as a consequence of one predicate before the schema; the accept set does not move (nothing was stored either way); the metadata-protocol changeset states it. Judged as the dev's decision 1 under ③.
  8. Fixture triage outside the two packages (objectql, 4 test files): bodies now named for the row they are saved under; no published source moves. Right; nothing publishes from objectql in this diff, so no changeset is owed there. scripts/engine-double-contract.pinned.json gains exactly the new test's three pinned verbs (the gate's own --write). Right.
  9. Not changed, and said so: boot hydration (loadMetaFromDb then hydrateOverlayIntoRegistry) still registers a pre-change at-rest row under its body name, so no stored row turns unreadable (triage step 3 holds) and a divergent one keeps answering under the body name until rewritten; a non-view body with no name registers nothing, as before. Both are readers, outside this card's region and recorded by the seat, with the hosted tenant NOT MEASURED. Right. One residual I note without a verdict: a non-document body (a string, an array, null) passes this judge by design and is left to the door's guards and the schema, where row 1 refuses it at register(); pre-existing and not this card's defect.

② Semver level

  • Premise (the seat's two re-checks, taken at this read): holds. git ls-tree origin/main prints c8df04b2 for .changeset/21412-metadata-view-container-name-judge.md and c271c397 for .changeset/21412-metadata-protocol-save-door-container-name.md; npm view @objectstack/metadata@latest exports --json is 17.6.0 with ./view-container-name counted 0 (control ./view-container 1). So the rename removes nothing released.
  • PR line Clause-②: yes (narrowing): matches the claim's revision 2 and the seat's correction (5964758196); check-changeset-no-major's level axis read it on this head and passed (narrowing, a BREAKING change shipped minor in the launch window; no package it moves is graded patch).
  • .changeset/21470-metadata-write-door-item-name-judge.md, @objectstack/metadata minor, Clause-②: yes: right. The subpath's export set changes against main (①.1), which is what yes declares, and yes takes at least minor; no BREAKING banner is owed, because the export it replaces never shipped (the premise above).
  • .changeset/21470-metadata-protocol-every-write-door-item-name.md, @objectstack/metadata-protocol minor, BREAKING banner, Clause-②: no (narrowing), ADR-0087 not-required (no-migration-prescription): right. No exported type or signature of metadata-protocol moves (restoredBodyWriter is private), so no; the accept set narrows at four doors, so (narrowing) and the banner; the disposition carries the census (0 rows on four bootable examples, hosted NOT MEASURED) and check-adr-0087-registration passed on it in the job log, the shape PR 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's note took. The "What is refused now" paragraph is true against the diff sentence by sentence, including the translation '' case, the field hatch case, the view stamp exception and the 422 to 400 move.
  • skip-changeset is correctly absent (a PR that corrects pending notes AND releases its own never takes it).
  • The DELIBERATE CORRECTION red, named and judged. Check Changeset (check-run 111126333722) is red at its step Reject an empty-frontmatter changeset added by this PR: scripts/check-empty-changeset.mjs's foreign-changeset refusal, naming exactly the two notes below and nothing else (the empty-frontmatter rule itself passed: 4 declaring changesets added). Both notes exist on the merge base and were added by PR 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, not by this PR; each loses one sentence group to a rewrite this diff makes true:
    • .changeset/21412-metadata-view-container-name-judge.md (seat-ordered in 5964758196). The rewritten first bullet, sentence by sentence: "It returns a VALIDATION_ERROR / 400 refusal for an aggregated view container whose own name is set and differs from that key, and undefined otherwise; a container with no name, and a standalone view record (viewKind), are not judged by it." TRUE: that is viewContainerNameRefusal's gate and return at the head, unchanged from main. "The subpath also exports savedItemNameRefusal(type, item, saveName, door), the runtime write doors' entry, which judges every metadata type against the name the row is written under, and the ViewContainerNameRefusal type both entries return." TRUE: the export, its four-argument signature, its three call-site doors and the shared return type are all in the diff. The removed sentences named savedViewContainerNameRefusal(container, saveName) and "Both return … for an aggregated view container", which this diff makes false. The bullet's unchanged opening (the viewContainerNameRefusal entry and its derived key) stays true.
    • .changeset/21412-metadata-protocol-save-door-container-name.md (the dev's addition, flagged in the PR body). The rewritten last line, one sentence: "A standalone view record (viewKind) and every other metadata type are judged too, by the same release's every-type refusal at the save, restore and publish doors (its own entry)." TRUE: P6 (a standalone record view) and P7 are refused at the save door, the restore and publish doors judge (①.4, ①.5), and "its own entry" is .changeset/21470-metadata-protocol-every-write-door-item-name.md, pending in the same .changeset/ set as this note, so the two ship in one changeset version. The replaced line, "Not judged here: a standalone view record (viewKind) and every other metadata type.", would be false once this diff lands. The correction is right and the dev was right to make it: a release note that contradicts a sibling note in the same release is the sentence the rule exists to catch.
    • This record is the same-head confirmation the landing rule asks for. For the three conditions on carrying this red into the queue: the pushed branch's PR body names the gate and the class and says it is red by design; pr-automation.yml runs on pull_request only, so the step does not run in merge_group; and this comment records the gate and the reason. The gate stays red by design; ⛔ it is not to be greened by restoring either note.

③ Boundary flags

The dev's final report (5965475783) carries open_questions: []; its three review decisions and three named deviations are answered here, each against triage 5962080068, the seat's answer 5964758196 (including its "⛔ Not a second rule") and the claim's revision 2 (5964548518). None is escalated.

  1. Decision 1, one predicate before the schema (422 INVALID_METADATA to 400 VALIDATION_ERROR for bodies a schema already refused): ANSWERED, accepted. The seat's pin was conditional: "If the schema already refuses it, record that and add nothing." The measurement found two types where it does not (translation accepts name: '' and was keyed ''; external_catalog has no schema), so under pin 3 the judge must refuse there, and the only way to "add nothing" for the other 24 would be a carve-out keyed on each type's schema, which is a second judgement at the door, the shape triage and the seat both rule out. Row 1's predicate, unmodified, is the one rule. The cost is a status code on inputs nobody could store, the accept set is unchanged, the ordering (judge first, then the stamp, then the schema) is the one finding(metadata-protocol): the runtime save door accepts a view container whose body name contradicts its row name and registers it under both keys; the two source registrars refuse the same document #21412 landed, and the changeset says so. No product-semantics fork, so nothing to escalate.
  2. Decision 2, field bodies with a name refused at the OS_METADATA_WRITABLE hatch: ANSWERED, accepted. The type is code-only and ruled REMOVE, so the hatch is its only runtime write; registered under the column name, every object's same-named column collided on one key, which is pin 3's defect in its purest form; the remedy is true (FieldSchema.name is optional and dot-free, so dropping it is the fix, and the save name is one the body cannot spell). The two alternatives the dev names are both worse under the rulings: exempting field keeps the collision; judging against the column half of the row name is a type-specific key derivation, a second rule. The fixture edits are read and the tests' subjects are unchanged.
  3. Decision 3, the four-argument savedItemNameRefusal(type, item, saveName, door) with a two-direction remedy: ANSWERED, accepted. The seat ruled the substance (one every-type entry replaces the container entry; the container case byte-identical; the remedy true per type) and left the name and shape to the dev ("The name is yours; … is fine"). door is what makes the remedy true at the two doors whose caller cannot edit a stored body, and what makes the subject honest (version, draft); the second direction ("or save the item under NAME") keeps the remedy true where a type's schema cannot spell the save name. Byte-identity for the container case is verified at ①.2. It adds no gate and no second rule. One residual, not a defect: for a dotted save name on a type that refuses dotted names both arms are printed and only the second is true; the author is still given a direction that works.
  4. Deviation (c), the second pending note corrected without the seat's naming it: ANSWERED, confirmed at ② above. The seat's answer named one note; the rule the dev acted under (every changeset sentence true) is the repository's, and this record is the confirmation the gate asks for.
  5. Deviations (a) and (b), the objectql fixtures and the double-contract ledger: ANSWERED, right (①.8); each is forced by a gate or by the rule's consumer radius and is named in the PR.
  6. The seat's premise ("⛔ if either fails, stop and report the fork"): re-checked at this read and holding (②). The record does not carry forward past a release that ships the subpath; a landing after such a release needs a fresh look at this one line.
  7. Census (triage step 1, pin 4): in the PR body as reported, four bootable examples 0 / 0, embed-objectql no table, hosted tenant NOT MEASURED. Triage step 3 owes no conversion on the measured corpus; the seat recorded the hosted gap and the trigger to file. Right.
  8. Out-of-scope finding (boot hydration residue): recorded by the seat as residue only, carrier finding(metadata-protocol): a stored view row named exactly like a container expansion is shadowed in the object door by the expansion, while the by-name read answers the stored row #21510 / finding(metadata-protocol): on an unscoped kernel a stored container's hydrated expansions carry no tenant marker, so an expanded view reads resettable: true and its layered code is the hydrated expansion #21511 or the seat; not filed, consistent with the seat's own note.

Checks on this head at this read (33 check-runs). Green (27): Build Core; TypeScript Type Check and its four legs (source gates, debt ledger, consumer gates, workspace); Temporal Conformance (live PG + MySQL); Test Core 2/6 to 6/6; Dogfood Regression Gate and its three shards; Dogfood Verify CLI; Governed Surface Queue Guard; Check PR Size; Auto Label; Check Documentation Links; Flag docs affected by code changes; the three single-writer / claim guards; filter. Skipped (3): Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in). Red by design (1): Check Changeset (② above). Pending at this read, named and not judged (2): Lint & Repo Gates; Test Core (1/6). Their conclusions are the gate verdicts; the landing seat reads them green before queueing, as the landing rule's second pre-check requires.

Implemented-by: claude/issue-21470-save-door-every-type
Reviewed-by: e9b4084e-558f-5388-aae5-1c69d5d0d420

The Reviewed-by: value is this isolated review subagent's CLAUDE_CODE_SESSION_ID, read from the environment; it runs under the seat session session_01DDZNkDVwPQnevTFcYE47H3, which is also the session that dispatched the dev on the branch above, so the independence pair is subagent-to-branch, as the brief asks.

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 05:03
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 05:03
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 44defd4 Oct 3, 2026
35 of 36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21470-save-door-every-type branch October 3, 2026 05:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants