Commit 7b07749
Fixes #21620
Clause-②: no (narrowing)
The runtime save door's accept set narrows: a view container saved under
a name that another stored container of the same object expands to is
refused with `VALIDATION_ERROR` / 400. Nothing widens. Dispatched by
`domain:engine` seat 1 under claim 5973165628 (branch
`claude/issue-21620-container-named-after-object`).
## What changed
`saveMetaItem` (`packages/metadata-protocol/src/protocol.ts`), the
method behind `PUT /api/v1/meta/view/NAME` and the dispatcher's metadata
save, gains one check: `containerSiblingExpansionNameRefusal`. It runs
right after #21558's `containerOwnExpansionNameRefusal` and before the
view identity stamp (`normalizeViewMetadata`). This is **triage's
pre-named fallback**, the narrower check, not the broad one. The census
below hit, so the broad check ("a container's name must equal its
object's") was **not** written.
- **The predicate:** "another stored container of the same object
expands to the save name". It is answered by the readers' own pieces,
never a copy of them:
- **Rows:** the active `view` rows that `readActiveOverlayRows` selects
for this caller, through the readers' gate `organizationIdForMetaRead`
and with no package filter. That is environment-wide rows plus the
caller's organization's.
- **Parse and expansion:** each row is parsed by `storedOverlayEntries`
and expanded by `expandStoredViewContainers` with its own package
binding. So every member kind (a bare or named `list`, `listViews`,
`form`, `formViews`), the expander's de-duplication, and #21334's
own-name arm on another package's object are judged where the readers
place them.
- **"Another":** the row stored under the save name itself is left out,
because it is the row this save replaces.
- **"Of the same object":** the expanded view's `object` against
`deriveViewContainerObject` of the body. That is the one derivation the
source registrars file a container under, newly imported from
`@objectstack/metadata/view-container`.
- **The body judged** is the authored one, with the door's own `name`
stamp applied first (H3), before the identity patch. On an unscoped
kernel the sibling's expansion is registered under the name, and a
`form`-only container would otherwise take its `viewKind` and reach the
schema as a malformed view item.
- **The envelope:** `VALIDATION_ERROR` / 400, the same as the two name
checks beside it. No new code, and the error-code ledger is untouched.
- **The words:** "Invalid view container: it is saved under
'crm_lead.pipeline', which is a name the stored container 'crm_lead'
expands (its list view on 'crm_lead'). An expanded view fills only a
name that has no stored row of its own, and this container would be that
row, so that view would no longer be served and no read would answer a
view under 'crm_lead.pipeline'. Add the view as a member of the
container 'crm_lead' (its list, listViews, form or formViews), or save a
view item (name, object, viewKind and config) under
'crm_lead.pipeline'." The prescription names the stored container that
expands the name: add the view as a member of it, or save a view item
under the name. It never prescribes a save under a name another stored
container holds (patch round 1, REWORK 5973617844). The text carries no
tracker number.
- **The read doors are not changed, and no stored row is re-saved.**
File surface as claimed: `saveMetaItem`'s view save door only.
## The census, taken first (the ruling's stop condition)
The ruling, as the seat reads it: if any writer or packaged container
names a container other than after its object, do not write the broad
check; record the hit; implement the narrower check. **The census hit,**
decidably.
Readings at BASE `045b946256`, with objectui at its pin `89cad75d55`:
| Census input | Evidence | Names a container other than after its
object? |
|---|---|---|
| The platform checklist's live view-authoring item,
`studio-authoring.view-authoring-live` (P1, active, revision 2 of
2026-10-02) | Step 1 is `PUT
/api/v1/meta/view/qa_repair_asset_views?mode=draft` with `{ object:
'repair_asset', list, form }`, on a runtime-authored object. | **Yes.**
It is the documented live authoring path. |
| #13407, found on a live EE deployment (QA-source #13404, the same
item) | `PUT /api/v1/meta/view/NAME` of a container bound to `note`
under another name. #13407's repair taught the readers the container's
own `object` so that this shape serves. | **Yes**, a live writer. |
| #21334, steps 3 and 8 (the 17.6.0 run in #21330, the same item) |
`os_qa_shadow_probe` saved on `showcase_task`. Ruling 5946423948 allowed
"expands under the container's own name". Seat answer 5955628428
rejected refusal at save because it "blocks a legitimate 'add views to a
shipped object' path". PR #21430 pins it (46 cases plus a REST dogfood
pin). | **Yes**, and a ruled arm. |
| #21412's seat answer 5961930912 | It rejected judging the save door
against the container's binding because that "refuses P2b, the body the
door itself stores for the #13407 shape". The save door's own comment at
BASE: "this door keeps a container saved under a name other than its
object (#13407, #21334)". Pinned as P2 and P2b here and in
`packages/metadata/src/view-container-name.test.ts`. | **Yes**, a ruled
arm. |
| Studio (objectui at its pin) | **Creators** write view items:
ObjectView through `buildViewConfigSaveBody` and `viewEnvelope`,
ObjectDataPage through `createRuntimeMetadata`, the metadata-admin
`createBuildBody`, and the flat configs of `data-objectstack`
`setViewConfig` and `createView`. **Re-savers:** the metadata-admin
`ResourceEditPage` saves a body under the name it carries, and
`data-objectstack` `updateView`'s draft path merges onto the stored
draft without reducing a container to its `list`. Both re-save a
container stored under a non-object name, under that name. | No creator.
Both re-savers would be refused by the broad check. |
| The in-repo AI author | The MCP tools in
`packages/mcp/src/mcp-http-tools.ts` are object, record and action
tools: no metadata write. The published `skills/objectstack-ui` teaches
`defineView` containers in source, with no top-level `name`. | No. The
cloud AI author is outside this repository: **NOT MEASURED**. The census
is decided by the rows above either way. |
| Packaged containers (H4) | An AST scan finds 13 `defineView(` call
sites with an object-literal argument, outside `packages/spec/src` and
tests, and 0 with a top-level `name`. The source registrars refuse a set
`name` that disagrees with the derived object: the boot loop
(`engine.ts:7024`), the artifact/HMR loader (`plugin.ts:1192`) and `os
validate` (`view-container-names.ts:101`). | No. **H4 holds.** |
| Stored rows | The example apps seed no `sys_metadata` view rows; the
one `sys_metadata` mention is a comment in the showcase connectors.
Hosted tenants: **NOT MEASURED**. | No seeded rows. Rows of the
legitimate "named other than its object" shape exist wherever the item
above ran. |
**H2, measured: what the broad check would have broken.** A throwaway,
trap-guarded probe planted the broad predicate at the save door at BASE,
ran the full `metadata-protocol` suite, and was restored (blob
`8a8053c40c94` equal to HEAD, `git diff HEAD` empty). It gave **127 of
3342 tests red, every one carrying the probe's own message**:
- #21334's block (PR #21430's pins): 38 of its 46 cases. That is 36
member-kind and card-probe cells over 3 containers and 2 kernels, plus
the de-duplication and shipped-key cases.
- The #21442 block (45) and the #21511 block (14), which reuse that
harness's container.
- The #13407 block (1), and #21412's P2 and P2b (2).
- Two incidental fixtures: `protocol.graft-folded-form-sections.test.ts`
(2, `lead_views` on `lead`) and
`sys-metadata-repository.package-writability.test.ts` (1, `case_grid`).
- The 24 #21558 refusal cells. These are refused either way and fail
only on the probe's wording.
PR #21430's REST dogfood pin was not run under the probe (NOT MEASURED).
With this PR it passes, 4 of 4. The pins that encode a ruled arm
(#13407, #21334, #21412) are evidence for the stop condition, alongside
the writers above. The probe's first attempt was refused by
`ablation-replace` because its replacement re-contained the anchor.
Nothing ran, the tool restored, and the probe was re-spelled.
## Every accept-set change at `saveMetaItem`, type `view`
| Input | Before | After |
|---|---|---|
| A container saved under a name that another active stored container of
the same object, in the caller's selection, expands to. Publish and
draft mode, both scopes, both kernels. | **Accepted:** stored, and
registered on an unscoped kernel. The sibling's view under that name was
then served by neither door. | **Refused** `VALIDATION_ERROR` / 400.
Nothing is stored or registered. |
| The same container with no body `name` (the door stamps the save
name). | Accepted. | Refused, with the same envelope. |
| A `form`-only container under a sibling's form-expanded name, on an
unscoped kernel with an environment-wide sibling (the registry holds the
sibling's expanded item under the name). | Refused `INVALID_METADATA` /
422: the identity stamp copied that item's `viewKind` onto the body. |
Refused `VALIDATION_ERROR` / 400 by this check, which now runs first. |
| Everything else. | Unchanged. | Unchanged. |
**What still saves** (pinned on both kernels and in both scopes):
- a container under its object's name, which expands as before, and its
own re-save;
- a container under any other name of its own that no other stored
container of its object expands to. That is the census's writers' shape,
which this door keeps;
- a view item under an expanded name, the sanctioned override (#21510);
- a container whose would-be sibling is in another organization. The
caller's own selection decides, as it does for the readers.
**Rows already stored in this shape** keep their bytes and read as they
do today. Measured with the check ablated, on both kernels and in both
scopes:
- the object door lists nothing under the name;
- the by-name read answers the raw second container;
- the second container's own expansion takes `crm_lead.default` on both
doors.
A new save of such a row is refused, and delete stays open.
`migrateStoredMetadata` and `duplicatePackage` re-save stored rows
through this door inside a `try` whose `catch` records the row:
`outcome: 'failed'` with the refusal's text, or a `failed[]` entry. So
they report such a row and never re-save it (H5, by construction: the
check is not gated on `source` or `writeFace`).
## The PM's mechanism hypotheses
- **H1, confirmed** at `045b946256`: `savedItemNameRefusal`, then
`containerOwnExpansionNameRefusal`, then `normalizeViewMetadata`, all
`VALIDATION_ERROR` / 400. #21558's check is kept as it is, and the new
check sits after it. Every #21558 pin holds its envelope and intent (the
file is green).
- **H2, confirmed and measured** (above). `expandUnderOwnName`'s arm is
a ruled writer path, so the broad check would have refused it.
- **H3, implemented and pinned:** an unnamed body is judged under the
stamped save name.
- **H4, confirmed** (above).
- **H5, kept,** by construction (above).
## The foreseen follow-up: a container saved under the name of a view
item a package ships
This was measured in-process on both kernels with a throwaway test,
deleted afterwards. **It is a different mechanism, so it is reported to
the seat as a finding and not changed here.** The save was `{ name:
'showcase_task.in_progress', object: 'showcase_task', list }` as
`showcase_task.in_progress`: package-less and environment-wide,
package-less and organization-scoped, and in a writable package.
- **Accepted before and after this change.** Afterwards the object door
lists nothing under `showcase_task.in_progress`, and the by-name read
answers the raw container. `showcase_task.form` reads the same.
`showcase_task.default` reads the same from a writable package;
package-less, #21558's check refuses it.
- **Package-less, it also replaces the packaged default.** The row's
package is read from the artifact of the same name, here a shipped view
item, so the container is not judged cross-package. Its bare `list` then
expands to `showcase_task.default` and replaces the packaged default on
both doors, stamped `_packageId: com.example.showcase`.
- **Why it is a different mechanism:** the displaced view is a packaged
artifact, not a stored container's expansion, so this check, which reads
stored rows, cannot see it. The paths are the readers' name-keyed
overlay of a shipped item by a stored container row, and the package
attribution in `runtimeViewContainerPackage`.
## Tests
- **Premise, measured at the fix's own pins with the new throw ablated**
(the measurement half of the reverse verification below), and confirmed
by the throwaway door probe on both kernels and both scopes:
- the card's pair is accepted;
- the object door answers nothing for `crm_lead.pipeline`;
- the by-name read answers the raw container;
- `crm_lead.default` answers the second container's list on both doors.
With the fix: refused `VALIDATION_ERROR` / 400, and both doors answer
the first container's views.
- **New pins:** 50, in a `#21620` block in
`view-container-runtime-expansion.test.ts`, inside #21334's
faithful-registry harness.
- Per kernel (`env_local` and unscoped) and per scope (environment-wide
and organization-scoped):
- five member-kind refusals (a bare and a named `list`, `listViews`,
`form`, `formViews`);
- the card's pair, refused in publish mode, with no body `name`, and in
draft mode;
- a `form`-only body;
- a sibling on another package's object (#21334's own-name expansion);
- three controls: a container under its object's name beside a sibling,
with its re-save; a view item under an expanded name; a container under
a name of its own.
- Per kernel: an environment-wide sibling refuses an organization
caller's save, and a control where another organization's container is
not this caller's sibling.
- Per kernel (patch round 1): the prescription names the sibling
container, and never a save under its name.
- Each refusal asserts the ADR-0112 envelope (`code` and `status`) and
the two subjects it names. It also asserts that no row or draft is
stored, that no container is registered under the name, and that the
sibling's view still answers on both doors as the same item.
- **The file:** 231 passed (181 pre-existing plus 50), at `13736a50f6`.
- **The package**, at `13736a50f6` (patch round 1; round 0 read 3371
passed at `5573152989`):
- `pnpm --filter @objectstack/metadata-protocol exec vitest run
--maxWorkers=2`: Test Files 209 passed, 3 skipped (212); Tests 3373
passed, 19 skipped (3392); exit 0.
- `typecheck`: exit 0. `tsc --noEmit --listFiles` includes the test
file.
- **Downstream sample.** Direction: consumers of
`@objectstack/metadata-protocol`, against `dist/` rebuilt by `turbo run
build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2`
(71 of 71 tasks). All pass; the rest of the downstream run is CI's.
- `objectql`: `protocol-meta`, `protocol-view-identity-overlay`,
`protocol-org-overlay-registry-gate`, `protocol-commit-history`,
`view-container-divergent-name-registrars`,
`engine-nested-plugin-view-expansion` and `metadata-validation-sweep`: 7
files, 189 tests.
- `rest`: `public-form-routes.stored-row`, 7 tests.
- `dogfood`: `view-container-cross-package-default` (PR #21430's pin
over REST on the real showcase), 4 tests.
- **The merge of `origin/main`** (`719644794c`) moves no byte under
`packages/metadata-protocol` or its dependency closure. So the suite,
typecheck and ablation readings taken at `5573152989` read the same
bytes.
## Reverse verification
The fix was committed first (`5573152989`). A trap-guarded script then
ran `scripts/ablation-replace.mjs --delete` on the anchor `if
(siblingExpansionRefusal) throw siblingExpansionRefusal;`.
- **Mutation.** The anchor went from 1 occurrence to 0, and the blob
from `771b82e97372` to `1cb3741ebb3a`.
- **Predicted direction:** red.
- **Result.** The file gave **34 failed and 195 passed**.
- All 34 are this block's refusal pins. 33 failed with `expected null to
be an instance of Error` (the save accepted). 1 answered
`INVALID_METADATA` instead of `VALIDATION_ERROR` (the `form`-only cell
on the unscoped environment-wide kernel, the identity-stamp row in the
table).
- All 14 of this block's controls stayed green, and no other test moved.
- **Restore.** `git checkout HEAD -- ABS_PATH` brought the blob back to
`771b82e97372`, equal to HEAD, and `git diff HEAD` was empty. Both the
tool and the script's own trap proved this.
- **Patch round 1, from committed `13736a50f6`, two legs:**
- (a) The prescription reverted to the old object-name arm: **2 failed
and 229 passed**, exactly the two new pins.
- (b) The throw deleted: **36 failed and 195 passed**. All 36 are the
block's refusal pins, and the 14 controls stayed green.
- Restore: blob `56bc12dce760`, equal to HEAD, with `git diff HEAD`
empty.
- **No dist leg.** The subject is imported through `./index.js`, the
source.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, with no paths, derived **64** families at
`719644794c`. That is the dispatch lead's 56 plus the 8 the changeset
adds: `check-adr-0087-registration` ×2, `check-empty-changeset` ×2,
`release-rehearsal-clone --self-test`, `release-pending-publish
--self-test`, `check:objectui-changeset` and
`check:pm-changeset-deadline-census`.
- **All 64 exited 0.** `pnpm check:lean-entry-closure` first exited 3
(PREREQUISITE NOT MET: `objectql`'s `dist/` was absent). It exited 0
after the workspace build. `check:dual-build-cjs-loads` and
`check:type-check-debt` ran through the verify lock, after the build.
- **`--ran`:** 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN.
- **Patch round 1, at `13736a50f6`:** the same 64 families, all exit 0;
`--ran` 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN.
- `check-adr-0087-registration` accepts the changeset's `not-required
(no-migration-prescription)` disposition.
- **Lint, narrowed.** `eslint --no-inline-config --format json` over the
2 changed `.ts` files reports 2 files, 0 errors and 0 warnings (no "file
ignored" message). Type-aware linting is never enabled
(`eslint.config.mjs:327-328`; `--print-config` shows
`parserOptions.project` and `projectService` both null), so files this
diff does not touch cannot change verdict. Repo-wide `pnpm lint` is
CI's.
- **Over REST**, the refusal (`PUT /api/v1/meta/view/NAME` on a booted
stack) is **NOT MEASURED**. The pins are in-process at the method that
door calls, on both kernels, and the envelope is the one the same door's
name refusals already carry through REST.
## Acceptance notes
- **The ruled predicate's "same object", read literally, leaves two
measured shapes open.** Both are accepted on both kernels and in both
scopes, and the sibling's view is gone from both doors:
- a container bound to another object under a sibling's expanded name
(`{ object: 'crm_account', list }` saved as `crm_lead.pipeline`);
- an unbound container under it (`{ list }`, whose derived object is its
own name).
Both are refused if the check drops "of the same object" and keys on the
name alone. No writer in the census saves either shape, so that change
would refuse nothing legitimate. It is raised to the seat as an open
question, not taken here, because the ruling's words name the same
object.
- **A second container under a free name still takes a sibling's
expanded name.** Two containers of one object whose expansions share a
name (both bare `list`s give `crm_lead.default`) are both accepted, and
the later one's view answers that name on both doors. This is the card's
step-3 symptom without this save, measured on both kernels and in both
scopes. It is reported to the seat as a finding.
- **Scope and order:**
- An environment-wide save under the expanded name of an
organization-scoped sibling is accepted, and for that organization the
sibling's view is gone. Reading every organization's rows at an
environment-wide save would be a cross-tenant read at a write door.
- A second container stored first, before its sibling gains the member,
keeps the name: the sibling's save is judged by its own name, which is
its object's.
Both are measured, and both are noted rather than filed.
- **Restore and publish doors.** `rollbackMetaItem`, `revertCommit` and
the draft promotion do not run this check, as for #21558. A draft or
version stored before this change, or a draft stored before its sibling
existed, can still be written back in this shape. Kept to the claimed
surface.
- **Cost.** A save of a view container (only a container) reads the
caller's active view rows once more, through the same overlay row cache
the readers use. A view item pays nothing.
- **Declaration bytes.** `dist/index.d.ts` gains one private member
line. No public member or exported type changes.
- `.changeset/21620-container-sibling-expansion-name.md`:
`'@objectstack/metadata-protocol': minor`, `Clause-②: no (narrowing)`,
the **BREAKING** banner, and the ADR-0087 disposition `not-required
(no-migration-prescription)`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 36e4647 commit 7b07749
3 files changed
Lines changed: 364 additions & 0 deletions
File tree
- .changeset
- packages/metadata-protocol/src
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
107 | 107 | | |
108 | 108 | | |
109 | 109 | | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
110 | 114 | | |
111 | 115 | | |
112 | 116 | | |
| |||
17955 | 17959 | | |
17956 | 17960 | | |
17957 | 17961 | | |
| 17962 | + | |
| 17963 | + | |
| 17964 | + | |
| 17965 | + | |
| 17966 | + | |
| 17967 | + | |
| 17968 | + | |
| 17969 | + | |
| 17970 | + | |
| 17971 | + | |
| 17972 | + | |
| 17973 | + | |
| 17974 | + | |
| 17975 | + | |
| 17976 | + | |
| 17977 | + | |
| 17978 | + | |
| 17979 | + | |
| 17980 | + | |
| 17981 | + | |
| 17982 | + | |
| 17983 | + | |
| 17984 | + | |
| 17985 | + | |
| 17986 | + | |
| 17987 | + | |
| 17988 | + | |
| 17989 | + | |
| 17990 | + | |
| 17991 | + | |
| 17992 | + | |
| 17993 | + | |
| 17994 | + | |
| 17995 | + | |
| 17996 | + | |
| 17997 | + | |
| 17998 | + | |
| 17999 | + | |
| 18000 | + | |
| 18001 | + | |
| 18002 | + | |
| 18003 | + | |
| 18004 | + | |
| 18005 | + | |
| 18006 | + | |
| 18007 | + | |
| 18008 | + | |
| 18009 | + | |
| 18010 | + | |
| 18011 | + | |
| 18012 | + | |
| 18013 | + | |
| 18014 | + | |
| 18015 | + | |
| 18016 | + | |
| 18017 | + | |
| 18018 | + | |
| 18019 | + | |
| 18020 | + | |
| 18021 | + | |
| 18022 | + | |
| 18023 | + | |
| 18024 | + | |
| 18025 | + | |
| 18026 | + | |
| 18027 | + | |
| 18028 | + | |
| 18029 | + | |
| 18030 | + | |
| 18031 | + | |
| 18032 | + | |
| 18033 | + | |
| 18034 | + | |
| 18035 | + | |
| 18036 | + | |
| 18037 | + | |
| 18038 | + | |
| 18039 | + | |
| 18040 | + | |
| 18041 | + | |
| 18042 | + | |
| 18043 | + | |
| 18044 | + | |
| 18045 | + | |
| 18046 | + | |
| 18047 | + | |
| 18048 | + | |
| 18049 | + | |
| 18050 | + | |
| 18051 | + | |
| 18052 | + | |
| 18053 | + | |
| 18054 | + | |
| 18055 | + | |
| 18056 | + | |
| 18057 | + | |
| 18058 | + | |
| 18059 | + | |
| 18060 | + | |
| 18061 | + | |
| 18062 | + | |
| 18063 | + | |
| 18064 | + | |
| 18065 | + | |
| 18066 | + | |
| 18067 | + | |
| 18068 | + | |
17958 | 18069 | | |
17959 | 18070 | | |
17960 | 18071 | | |
| |||
18446 | 18557 | | |
18447 | 18558 | | |
18448 | 18559 | | |
| 18560 | + | |
| 18561 | + | |
| 18562 | + | |
| 18563 | + | |
| 18564 | + | |
| 18565 | + | |
| 18566 | + | |
| 18567 | + | |
| 18568 | + | |
| 18569 | + | |
18449 | 18570 | | |
18450 | 18571 | | |
18451 | 18572 | | |
| |||
0 commit comments