Repository navigation
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
Conversation
…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>
…fact door Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
…er-name entry 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>
📓 Docs Drift CheckThis PR changes 4 package(s): 24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 142 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 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
|
…use-2 arm Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 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 ① Derived judgmentsAccept-set changes the diff implies:
Public-surface changes the diff implies:
Nothing judged wrong. ② Semver levelFour changesets, each judged against what its package publishes:
The PR body's declaration reads Gate verdicts on the head: ③ Boundary flagsDev flags from the PR body and the reports 5962619241 and 5962685002:
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: VERDICT: PASS |
…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>
Fixes #21412
Clause-②: yes (narrowing)
The runtime save door (
saveMetaItem, which RESTPUT /api/v1/meta/view/:nameand the dispatcher's metadata save both call) now refuses an aggregated view container whose bodynamedisagrees with the name it is saved under. It answersVALIDATION_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 (rowcrm_lead, bodynamelead_views) was accepted, stored undercrm_lead, and registered underlead_viewspluscrm_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_decisionreport (5961864645): Q1 A, Q2 A, Q3 A and Q4 A.What changed
@objectstack/metadata. New subpath@objectstack/metadata/view-container-name(packages/metadata/src/view-container-name.ts). The judgement: a container's ownname, 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 inpackages/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.namedisagrees with the row (P3, P4).@objectstack/metadata.@objectstack/corecannot host the judge: the judge needsderiveViewContainerObject, which lives in@objectstack/metadata, and@objectstack/metadatalists core.@objectstack/metadatais the one layer all three doors already depend on. The judge is a subpath of its own, not the./view-containerleaf, because that leaf imports nothing and the judge needsisAggregatedViewContainerfrom@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-closureholds:@objectstack/objectql/coreis 15 packages, the admitted set held exactly.packages/metadata-protocol/src/protocol.ts,saveMetaItem) callssavedViewContainerNameRefusal(request.item, request.name)forviewfirst, beforenormalizeViewMetadatacan keep an authoredname. Containers only.normalizeViewMetadata's docblock says so.packages/metadata/src/plugin.ts) callsviewContainerNameRefusal(item, 'artifact', packageId)after it derives the key and beforememLoader.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/objectqlkeepsviewContainerNameRefusaland theViewContainerNameRefusaltype 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.tsandpackages/cliare untouched.ViewSchema'sguidance:inpackages/spec/src/ui/view.zod.tsno longer sayssaveMetaItemsends 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.
code,statusandhttpStatuswere all equal,Tests 1 passed. The test was deleted afterwards.view containerand not`views:` container from SOURCE 'OWNER': the save door has noviews:collection and no owning manifest.the name it is saved under.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 whosenameequals its binding.Pins (triage pins, restated to Q1 A)
packages/metadata-protocol/src/view-container-runtime-expansion.test.ts, the filing's own stub engine:VALIDATION_ERROR/ 400. No row is stored and nothing is registered.savedViewContainerNameRefusal's, so the refusal goes through the judge.code/statusequal the artifact/HMR registrar's for the same document.crm_lead.default.packages/metadata/src/view-container-name.test.tscovers:namediffers from its row name, and registers it under the body name (the every-type half of #21412) #21470 remains open for the every-type half);namestill registers.os validate's refusal words are unchanged, and their pins are unedited:packages/objectql/src/view-container-name-refusal.test.ts(green) andpackages/cli/test/validate-view-container-name.test.ts(CI; see Tests).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)if (nameRefusal) throw nameRefusal;inprotocol.tswas disabled (anchor 1 to 0, blob478daa416f90to6f9ae10ffb7b), andview-container-runtime-expansion.test.tswent5 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 HEADempty). Both test and subject resolve the protocol fromsrc, so no build was involved.plugin.tswas disabled (blob6924156b5fdatocef5ec76af37), andview-container-name.test.tswent1 failed | 10 passed (11). The refusal came from row 1 instead (nohttpStatus), so the assertion failed. The file was restored to HEAD. The test imports./plugin.jsfromsrc.Tests
Code at
a70d0d61a4is identical to7d4ee0ac46outside.changeset/. All runs went throughscripts/pm/os-verify-lock.sh, and each gaveVERDICT command-exit 0:@objectstack/metadata:pnpm testgaveTest Files 57 passed (57),Tests 847 passed (847), andpnpm typecheckexited 0. Its tsconfig includessrc/**/*, so the tests are type-checked.@objectstack/metadata-protocol:pnpm testgaveTest Files 205 passed | 3 skipped (208),Tests 3092 passed | 19 skipped (3111), andpnpm typecheckexited 0 (tests included).@objectstack/objectql: thelocalproject gaveTest Files 366 passed (366),Tests 7383 passed (7383),test:repogave1 passed, andtypecheckexited 0. That coverstsc, the scripts project, andcheck:test-typecheckOK, held in the debt ledger.turbo run build --filter=@objectstack/objectql^... --filter=@objectstack/objectqlgave14 successful. The new subpath loads under both conditions (requireandimporteach return both entries,VALIDATION_ERROR400), anddist/view-container-name.d.ts/.d.ctsare emitted.packages/cli, theos validatepins.@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.packages/metadata/src/serializers/typescript-serializer-annotation.test.tspins how manyexportsentries it visits (5 to 6), so the new entry is checked too.Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandswas derived ata70d0d61a4and gave 99 commands. All 99 were run ata70d0d61a4:pnpm check:dual-build-cjs-loadsexited 3 withPREREQUISITE NOT MET, because it needs every package'sdist/(the direct load check above stands in, but is not the gate).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 jsonover the 10 touched.tsfiles reports 10 files, 0 errors, 0 warnings, and none ignored. Every one is in eslint's population (--print-configresolves each).eslint.config.mjsnever enables type-aware linting (its own note near line 327: noparserOptions.project, no typed rules), so this diff cannot move the verdict on any untouched file. The repo-widepnpm lintis CI's.Changesets
@objectstack/metadataminor: the new subpath, and the artifact door's refusal speaking through the judge.@objectstack/metadata-protocolminorwith 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 isnot-required (no-migration-prescription).@objectstack/objectqlpatch: the re-export.@objectstack/specpatch: comment only.src/**/*.zod.tsships as source, and the comment ships in theuiJavaScript output (measured: the new sentence is in 20distfiles; the old one is in none).The
Clause-②line above is the claim's, copied as dispatched. Byscripts/pm/clause2-line.mjs's own definitions, this diff both widens a public surface (the new@objectstack/metadatasubpath, with two functions and a type) and narrows an accept set (the save door). That reads asyes (narrowing). This is raised to the seat in the dev report; the line here is not changed by the dev.Acceptance notes
carrier: #21470(finding(metadata-protocol): the runtime save door accepts any metadata body whosenamediffers from its row name, and registers it under the body name (the every-type half of #21412) #21470 remains open). Container bodies stored before this change that already carry a divergentnamekeep registering under thatnameat boot (loadMetaFromDb, thenhydrateOverlayIntoRegistry).revertCommitandrollbackMetaItemre-persist stored versions throughrepo.restoreVersionwithout passing the save seam. The count of such rows is not measured.namethat differs from the row) is finding(metadata-protocol): the runtime save door accepts any metadata body whosenamediffers from its row name, and registers it under the body name (the every-type half of #21412) #21470's, and is not judged here.Generated by Claude Code