Commit 44defd4
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
File tree
- .changeset
- packages
- metadata-protocol/src
- metadata/src
- objectql/src
- scripts
Lines changed: 1 addition & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
19 | | - | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
9 | | - | |
| 9 | + | |
10 | 10 | | |
Lines changed: 21 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
Lines changed: 6 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
145 | 145 | | |
146 | 146 | | |
147 | 147 | | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
148 | 154 | | |
149 | 155 | | |
150 | 156 | | |
151 | | - | |
152 | 157 | | |
153 | 158 | | |
154 | 159 | | |
| |||
Lines changed: 16 additions & 3 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
133 | 133 | | |
134 | 134 | | |
135 | 135 | | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
136 | 149 | | |
137 | 150 | | |
138 | 151 | | |
| |||
364 | 377 | | |
365 | 378 | | |
366 | 379 | | |
367 | | - | |
| 380 | + | |
368 | 381 | | |
369 | 382 | | |
370 | 383 | | |
| |||
379 | 392 | | |
380 | 393 | | |
381 | 394 | | |
382 | | - | |
| 395 | + | |
383 | 396 | | |
384 | 397 | | |
385 | 398 | | |
| |||
469 | 482 | | |
470 | 483 | | |
471 | 484 | | |
472 | | - | |
| 485 | + | |
473 | 486 | | |
474 | 487 | | |
475 | 488 | | |
| |||
0 commit comments