Skip to content

Commit 44defd4

Browse files
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)
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 #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 #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 (#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 #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 (#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 #21483's was. - **Two pending #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 (#5086) and was ruled REMOVE (#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`; #21510 and #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>
1 parent f1e4ae5 commit 44defd4

18 files changed

Lines changed: 973 additions & 134 deletions

‎.changeset/21412-metadata-protocol-save-door-container-name.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,4 @@ Clause-②: no (narrowing)
1616

1717
**The fix.** Drop the body's `name` (the door stamps the save name), or set it to the name the container is saved under.
1818

19-
Not judged here: a standalone view record (`viewKind`) and every other metadata type.
19+
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).

‎.changeset/21412-metadata-view-container-name-judge.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ One judge for a view container's own `name` at every door that files a container
66

77
Clause-②: yes
88

9-
- New subpath `@objectstack/metadata/view-container-name`. It exports `viewContainerNameRefusal(container, sourceLabel, ownerId)`, the source registrars' entry, whose key is the object the container binds to (its own `object`, else `list.data.object` / `form.data.object`). It also exports `savedViewContainerNameRefusal(container, saveName)`, the runtime save door's entry, whose key is the name the row is saved under, and the `ViewContainerNameRefusal` type. Both return 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.
9+
- New subpath `@objectstack/metadata/view-container-name`. It exports `viewContainerNameRefusal(container, sourceLabel, ownerId)`, the source registrars' entry, whose key is the object the container binds to (its own `object`, else `list.data.object` / `form.data.object`). 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. 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.
1010
- The artifact/HMR loader's container branch now refuses such a container through the judge, before it files anything. What it refuses and the envelope are unchanged (`VALIDATION_ERROR` / 400). The message is now the judge's, the words the ObjectQL boot loop and `os validate` print, where it was the generic `IMetadataService.register` contract's.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
Every runtime door that writes a metadata row refuses a body whose own `name` disagrees with the row's name, for every metadata type
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing at the runtime write doors over an existing key: no `name` key of any metadata schema is removed, renamed or re-shaped, so there is no tombstone and nothing mechanical for `objectstack migrate meta` to rewrite. Which of the two names a divergent body meant (its own, or the one it was saved under) is authoring intent no conversion entry can decide. The at-rest census found no such row: 0 `sys_metadata` and 0 `sys_metadata_history` rows in the four bootable example apps, booted with their seeds; the hosted-tenant shape was not measured. New writes are refused with the remedy; a row stored before this change keeps its bytes and stays readable. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this rule and this diff adds none (not registered / already-registered); and the change narrows what runtime write doors accept, not a runtime interface or a type surface alone (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING** accept-set narrowing at the runtime write doors, shipped as `minor` under the repo's launch-window convention for breaking changes, the grade the view-container half of this refusal takes in the same release.
12+
13+
**What was accepted before.** The runtime stores a metadata row under the name the request names and registers its body under the body's own `name`. These doors accepted a body whose `name` was not the row's, so the row answered under a name nobody saved it under, and under none by its own:
14+
15+
- `saveMetaItem`, which `PUT /api/v1/meta/:type/:name` and the dispatcher's metadata save both call, for every type but a view container (a dashboard saved as `dash_a` with `name: 'dash_b'` registered as `dash_b`; a record view saved as `crm_lead.mine` with `name: 'crm_lead.other'` registered as `crm_lead.other`);
16+
- `rollbackMetaItem` and `revertCommit`, which wrote such a stored history version back as the active row without passing `saveMetaItem`;
17+
- `publishMetaItem` and `publishPackageDrafts`, which promoted such a stored draft the same way.
18+
19+
**What is refused now.** Each of those bodies, with `VALIDATION_ERROR` / 400, before anything is stored or registered, through the judge the view-container refusal already used (`savedItemNameRefusal`, `@objectstack/metadata/view-container-name`). `rollbackMetaItem` and `publishMetaItem` throw it. `revertCommit` reports the item in `failed[]` with `code: 'VALIDATION_ERROR'`. `publishPackageDrafts` aborts the batch on it, as it does on any refused draft: nothing in the batch is published. A body with no `name` is accepted as before. A `name` the body carries is judged whatever its value; a `translation` saved with `name: ''`, which its schema accepts, is now refused instead of being registered under the empty string. A view at the save door is the exception: a missing or empty view `name` is still stamped with the save name. A `field` written through the `OS_METADATA_WRITABLE` operator hatch is accepted only without a body `name`: its row is named `object.field`, which the column `name` cannot spell, and registered it answered under the column name alone. Where the type's schema already refused such a body (an empty or non-string `name` on most types, any `name` on a `seed`, whose schema declares none), the answer is now this refusal (`VALIDATION_ERROR` / 400) instead of the schema's `INVALID_METADATA` / 422; nothing is stored either way.
20+
21+
**The fix.** Set the body's `name` to the name you save it under, or save the item under the body's own `name`; for a view or a `field`, dropping `name` works too. To bring back a version or a draft that carries another `name`, save the item again with that fix, and publish that save if it is a draft.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/metadata': minor
3+
---
4+
5+
The runtime write doors' `name` judge covers every metadata type: `savedItemNameRefusal` replaces `savedViewContainerNameRefusal` on `@objectstack/metadata/view-container-name`
6+
7+
Clause-②: yes
8+
9+
- `savedItemNameRefusal(type, item, saveName, door)` is the one entry the runtime write doors of `@objectstack/metadata-protocol` call. It returns a `VALIDATION_ERROR` / 400 refusal when a body of any type carries its own `name` and that `name` differs from the name the row is written under, and `undefined` otherwise. `door` is `'save'`, `'restore'` or `'publish'`. A body with no `name` passes. A `name` the body does carry is judged whatever its value (`''`, `null` and non-strings included), with one exception: a `view` at the `'save'` door, which stamps a missing name there, is judged only on a non-empty string `name`.
10+
- It replaces `savedViewContainerNameRefusal(container, saveName)`, which judged view containers only. That export was added to this subpath in this same release cycle and never shipped in a published version, so no published export is removed.
11+
- The words name the type and give a remedy that works for it: "drop `name`, or set it to KEY" for a view, whose missing name the save door stamps; "drop `name`" for a `field`, whose row is named `object.field`, which its dot-free column `name` cannot spell; "set `name` to KEY, or save the item under NAME" for every other type; and at the restore and publish doors, whose caller cannot edit the stored body in place, the save that fixes it. For a view container at the save door the message is byte for byte the one `savedViewContainerNameRefusal` returned.
12+
- `viewContainerNameRefusal` (the source registrars' entry), its words and the `ViewContainerNameRefusal` type are unchanged.

‎packages/metadata-protocol/src/protocol.code-only-types.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,15 @@ const PROBES: Record<string, { name: string; item: Record<string, unknown> }> =
145145
// Schema-valid on purpose, like the four above: `field` resolves
146146
// `FieldSchema`, so a malformed body would 422 before the registry consult
147147
// and the probe would prove nothing about the code-only gate.
148+
//
149+
// [#21470] And NAMELESS on purpose: the row is named `<object>.<field>`,
150+
// which a dot-free `FieldSchema` `name` can never spell, so a body `name`
151+
// would now be refused by the write doors' name judge (`VALIDATION_ERROR`
152+
// / 400) once the hatch below opens the door — a different refusal from
153+
// the one this probe measures. `FieldSchema` does not require `name`.
148154
field: {
149155
name: 'rc3_field_probe.zz_probe',
150156
item: {
151-
name: 'zz_probe',
152157
label: 'Probe',
153158
type: 'text',
154159
},

‎packages/metadata-protocol/src/protocol.destructive-gate-reachable-types.test.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,19 @@ const fieldRow = (type: string): Row => row({
133133
metadata: JSON.stringify({ name: FIELD, label: 'Probe', type }),
134134
});
135135

136+
/**
137+
* [#21470] The body a hatch-path save SENDS: a field definition with no
138+
* `name`. A `field` row is named `<object>.<field>`, which a `FieldSchema`
139+
* `name` (dot-free) can never spell, so every runtime write door now refuses a
140+
* `field` body whose `name` is set (`VALIDATION_ERROR` / 400, before this
141+
* file's gates) — the write would register the row under the column name.
142+
* `FieldSchema` does not require `name`, so the nameless body is the one the
143+
* hatch can carry, and what these cases measure is unchanged: the destructive
144+
* gate's reach, not the body's name. The STORED rows above keep theirs, as a
145+
* row written before that refusal sits at rest.
146+
*/
147+
const HATCH_BODY = (type: 'text' | 'number') => ({ label: 'Probe', type });
148+
136149
/** An `object` row carrying a real `fields` map — the only diffable shape. */
137150
const objectRow = (name: string, fields: readonly string[]): Row => row({
138151
type: 'object',
@@ -364,7 +377,7 @@ describe('[#11014 §2 reason 1] a `field` body has no `fields` map to diff', ()
364377
const r = await save(protocol, {
365378
type: 'field',
366379
name: DOTTED,
367-
item: { name: FIELD, label: 'Probe', type: 'number' },
380+
item: HATCH_BODY('number'),
368381
});
369382

370383
// It persisted — so the request reached, and passed, the gate.
@@ -379,7 +392,7 @@ describe('[#11014 §2 reason 1] a `field` body has no `fields` map to diff', ()
379392
const r = await save(protocol, {
380393
type: 'field',
381394
name: DOTTED,
382-
item: { name: FIELD, label: 'Probe', type: 'number' },
395+
item: HATCH_BODY('number'),
383396
});
384397

385398
expect(r.outcome).toBe('resolved');
@@ -469,7 +482,7 @@ describe('[#11014 §3] the double fault the trimmed limb used to catch', () => {
469482
const r = await save(protocol, {
470483
type: 'field',
471484
name: DOTTED,
472-
item: { name: FIELD, label: 'Probe', type: 'text' },
485+
item: HATCH_BODY('text'),
473486
});
474487

475488
expect(r.outcome).toBe('resolved');

0 commit comments

Comments
 (0)