Repository navigation
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
Conversation
…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>
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>
…d under Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
…ve-door-every-type
…engine double Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
…ve-door-every-type
📓 Docs Drift CheckThis PR changes 2 package(s): 22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
Contract reviewServed-tier: 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 ① Derived judgmentsEach accept-set or public-surface change the diff implies, named and judged.
② Semver level
③ Boundary flagsThe dev's final report (5965475783) carries
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; Implemented-by: The VERDICT: PASS Generated by Claude Code |
Fixes #21470
Clause-②: yes (narrowing)
Every runtime door that writes a
sys_metadatarow now refuses a body whose ownnamedisagrees with the name it writes the row under, for every metadata type, withVALIDATION_ERROR/ 400, before anything is stored or registered. The refusal goes through the one judge #21412 landed (@objectstack/metadata/view-container-name). The doors aresaveMetaItem,rollbackMetaItem, the restore limb ofrevertCommit, and the draft promotion thatpublishMetaItemandpublishPackageDraftsshare.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)replacessavedViewContainerNameRefusal(container, saveName)on the subpath.dooris'save','restore'or'publish'.assertMetadataRegisterContract): anamethe 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 viewnameafter the judge runs. So for aviewat'save', anamecounts 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 bodynamecontradicts its row name and registers it under both keys; the two source registrars refuse the same document #21412, unchanged.viewContainerNameRefusal(the source registrars' entry), its words,objectql's re-export, the boot loop andos validate.npm view @objectstack/metadata@latest exports --json | grep -c view-container-nameprints0. The control:"./view-container"counts1, and latest is17.6.0.git ls-tree origin/main .changeset/21412-metadata-view-container-name-judge.mdprints the blob linec8df04b2….origin/mainc98a72d69e.Clause-②: yes (narrowing)because the subpath's export set changes againstmain.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 builtdist. Both renders give the same sha256,0f65c121514e148caae28f82f03b64db2fcd21c04b561ccb75f73af4bb629352, andcmpreports them identical. The control: after the build, thedisthas 0 hits for the old name and 2 for the new one.The words for every other body and door (rendered from
dist)Each remedy is true for its type and its door:
drop nameis offered only where dropping works: a view (the save door stamps a missing name), and afield.FieldSchemadoes not requirename, and itsnameis dot-free, so it can never equal anobject.fieldrow name.set nameis offered for every other type, together with the opposite direction: save the item under its ownname. 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 bodyname, and the save door's grammar admits dotted row names.Callers in
protocol.tssaveMetaItem: the existing call site, now for every type, still beforenormalizeViewMetadata. ⛔ PR fix(metadata-protocol): refuse an org-scoped public form withdrawal a walled posture cannot honour #21473's public-form lines are not touched; its nearest hunk sits about 230 lines above.rollbackMetaItemandrevertCommit's restore limb: through a new privaterestoredBodyWriter(type, name). It is thederiveRestoredBodythatrepo.restoreVersioncalls on the very history body it read, before it reads the active row and beforeput. It runs the judge first and then the existing credential-channel strip (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). A refused version writes nothing:rollbackMetaItemrethrows the refusal;revertCommitreportsfailed[]withcode: 'VALIDATION_ERROR'.promoteDraftForPublish: judges thedraftForGatebody beforerepo.promoteDraftwrites, beside the existing authoring gate and in the same way. ⛔ PR fix(metadata-protocol): refuse an org-scoped public form withdrawal a walled posture cannot honour #21473's promotion gate insidepublishMetaItem(about:19410) is not touched.Census of at-rest rows (triage step 1)
Taken on
objectstack-ai/objectstackate9dec3dab. Each bootable example booted with--freshand its seeds. Everysys_metadataandsys_metadata_historyrow was read straight from the fresh SQLite file (read-only), and each body'snamewas compared with its row'sname.sys_metadatarowssys_metadata_historyrowspnpm dev:crm -- --freshpnpm dev:todo -- --freshpnpm dev -- --freshpnpm --filter @objectstack/example-multi-package dev -- --fresh(no root script)sys_metadatatable: no metadata protocol in its closuresys_userreads 1 / 1 / 3 / 1 andsys_permission_setreads 10 / 8 / 17 / 8 in the same four files.migrateStoredMetadatapass reports itfailed, and a rollback, revert or publish of it is refused with the remedy.Measured before the change (
origin/maine9dec3dab, a probe battery since deleted)crm_lead.mine, bodynamecrm_lead.other): accepted. The registry key wascrm_lead.otheronly.dash_a, bodynamedash_b): accepted. The registry key wasdash_bonly.rollbackMetaItemto a stored version whose bodynameisdash_b): it restored that version with 0saveMetaItemcalls. The key wasdash_b.revertCommit,prevVersionthat version):revertedCount: 1with 0saveMetaItemcalls. The key wasdash_b.publishMetaItemof a draft rowdash_dwhose bodynameisdash_e): promoted with 0saveMetaItemcalls. The key wasdash_e.nameon a non-container type (the seat's added pin), measured per type againstgetMetadataTypeSchema:'',7andnull(422).seeddeclares nonameat all, so it refuses anyname.viewstamps a falsyname(and the schema refuses7).translationacceptsname: ''.external_cataloghas no schema, so it accepts anything.''reaches persistence fortranslation, and it is keyed''in the registry; any value reaches persistence forexternal_catalog. The judge therefore refuses a set non-viewnamewhatever 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 itsname, and whose transaction rolls back on a throw (ADR-0067 D2). It runs on the topology where non-objecttypes write through to the shared registry.translationwithname: '': refused withVALIDATION_ERROR/ 400. Nothing is stored and nothing is registered.namepasses: a dashboard stored and keyeddash_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.saveMetaItemcalls; the draft is kept. Each has a clean control through the same door.publishPackageDraftsbatch case: one refused draft aborts the batch, as the authoring gate's refusal does. The outcome isrefusedandfailed[]lists the refused draft withVALIDATION_ERRORand its sibling as aborted. Nothing goes live, and the registry is empty. A clean control publishes both.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; andfield.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:
saveMetaItemcall (src)translation);view-container-runtime-expansion.test.ts5 red (#21412's P1 ×3, P3, P4)distpackages/metadata/src, then rebuiltablation-dist-preflightfound the marker in 2 built files; the every-door file 7 red / 5 green (every refusal red, every control green); the container file stays greenHEADand an emptygit diff HEAD.--absentpreflight, and 12 / 12 green.Tests (at
181408e1e5; core pins again at056df2c896after the last merge ofmain)@objectstack/metadata:src/view-container-name.test.tspasses 19 / 19, and the full suite earlier passed 858 / 858.@objectstack/metadata-protocol: the full suite passes 3163, with 19 skipped. At056df2c896the every-door and container files pass 131 / 131.metadata-protocoldist: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, andcli(unit tier): 2 + 1 + 2 + 2 files.typecheck:metadata,metadata-protocolandobjectqlall green (the last includingcheck:test-typecheck, 65 pinned signatures held).--listFilesconfirms the new and edited test files are compiled.eslint --no-inline-config --format jsonover the 13 touched.tsfiles gave 13 results, 0 errors, 0 warnings, and none ignored. The touched population is all of them:eslint.config.mjslints**/*.{ts,…}minus itsNEVER_LINTEDset. Untouched files cannot move, because the config enables no type-aware linting (noparserOptions.project, as its own header states).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 thecliintegration tier.Gates
dispatch-gates --commandson 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.--ranwith each exit code recorded answered74 derived famil(ies) accounted for — 74 run, 0 NOT-MEASURED. 73 exited 0. At056df2c896the 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-registrationandchangeset-no-major. All exited 0.check:engine-double-contractasked for the new test's pinned double to be recorded (--write). That is the 15-line addition toscripts/engine-double-contract.pinned.json, and nothing else moved.check-empty-changesetexits 1, deliberately: it is a DELIBERATE CORRECTION. It needs confirmation on this PR (see below).check-changeset-no-major's clause-② axis readsNOT APPLICABLElocally (there is nopull_requestpayload); 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 andClause-②: no (narrowing). Its ADR-0087 disposition isnot-required (no-migration-prescription), with the census (0 rows) in the marker, as 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 was.namecontradicts its row name and registers it under both keys; the two source registrars refuse the same document #21412 release notes are corrected, because this PR makes a sentence in each false. They are named here for confirmation, ascheck-empty-changesetasks:.changeset/21412-metadata-view-container-name-judge.md: the sentence namingsavedViewContainerNameRefusal(container, saveName)now namessavedItemNameRefusal(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
nameon a non-view type is refused by the judge, which runs before the schema. Where the type's schema already refused such a body ('',7ornullon 24 types; anynameonseed), the answer moves from the schema'sINVALID_METADATA/ 422 toVALIDATION_ERROR/ 400. Nothing is stored either way.translation's''andexternal_catalog's anything.field. Afieldrow is namedobject.field, and its canonical body carries the dot-free columnname. Registered, the row answered under the column name, and every object'stitlefield collided on one key. That is pin 3's defect, so the judge refuses it, and the remedy is "dropname".fieldPUT never reaches the object — a runtime-created field is storedvalid=trueand is absent fromfieldsforever #7893), so this is reachable only through theOS_METADATA_WRITABLEoperator hatch.protocol.code-only-types.test.tsandprotocol.destructive-gate-reachable-types.test.ts. What those tests measure (the hatch's routing, the destructive gate's reach) is unchanged.field(which keeps the collision) or to judge it against the column half of its row name (a type-specific key derivation).doorparameter and the two-direction remedy go beyond the seat's suggestedsavedItemNameRefusal(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_BODYis now nameless; the door stamps the request name) andprotocol.runtime-gate-stored-universe.test.ts(oneWidgetBoardtakes the row name).objectql:protocol-recorded-by-null.test.ts(viewBodytakes the row name);protocol-save-meta-repo-path.test.ts(view_onebecomesv);*-meta-response-conformance.test.tsfiles (cleanFlowis namedbounded_purge, the row it is saved under).field: see the decisions above.Not in this PR
loadMetaFromDb, thenhydrateOverlayIntoRegistry) keeps registering a row stored before this change under its bodyname.getMetaItem,readFlattenedMetaItemsorhydrateExpandedViewItems; 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 and finding(metadata-protocol): on an unscoped kernel a stored container's hydrated expansions carry no tenant marker, so an expanded view readsresettable: trueand its layeredcodeis the hydrated expansion #21511 are queued behind this card).namestill registers nothing at all (hydrateOverlayIntoRegistryskips a nameless body). This is pre-existing, and triage's pin 2 says an absentnamepasses.Generated by Claude Code