Skip to content

Commit 7b07749

Browse files
fix(metadata-protocol): the save door refuses a view container saved under a name another stored container of the same object expands to (#21620) (#21637)
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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
The runtime save door refuses a view container saved under a name another stored container of the same object expands to
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing at one runtime write door over existing keys: no key of `ViewSchema` or of any other metadata schema is removed, renamed or re-shaped, so there is no tombstone and nothing mechanical for `objectstack migrate meta` to rewrite. Whether the refused container was meant as a member of the container that already expands that name, or as a view item of that name, is authoring intent no conversion entry can decide. New saves are refused with the remedy; a row stored before this change keeps its bytes and is served as before, and no stored row is re-saved. The census found no packaged container that can reach this door in this shape (the source registrars file a container under its object and refuse a `name` that disagrees with it; the thirteen `defineView` sites in this repository carry no top-level `name`), no seeded `sys_metadata` view rows in the example apps, and no Studio or in-repo AI writer that saves a container under a name another container expands unless its author types that name (Studio's generic metadata editor saves a body under the name it carries); hosted tenants and the cloud AI author were not measured. 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 a runtime write door accepts, 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 save door, shipped as `minor` under the repo's launch-window convention for breaking changes, the grade the same door's earlier name refusals shipped with.
12+
13+
**What was accepted before.** `saveMetaItem`, which `PUT /api/v1/meta/view/:name` and the dispatcher's metadata save both call, accepted an aggregated view container (`list` / `form` / `listViews` / `formViews`) saved under a name that another stored container of the same object expands to. For example, with `{ name: 'crm_lead', object: 'crm_lead', list: { … }, listViews: { pipeline: { … } } }` stored, a second container `{ object: 'crm_lead', list: { … } }` saved as `crm_lead.pipeline`. The second container became that name's own stored row, and an expansion fills only a name with no row of its own, so the first container's `crm_lead.pipeline` view was no longer served: the object door (`GET /api/v1/meta/view?object=…`), which never lists a container, listed nothing under the name, and the by-name read answered the raw second container. Nothing said why.
14+
15+
**What is refused now.** That save, with `VALIDATION_ERROR` / 400, before anything is stored or registered, in draft and in publish mode. The other containers are the stored rows the read doors select for the same caller (environment-wide rows plus the caller's organization's), each expanded exactly as the read doors expand it, so every member kind (a bare or named `list`, `listViews`, `form`, `formViews`), the expander's de-duplicated names, and the names a container on another package's object expands under its own name are all judged where the readers place them. A container with no `name` is judged under the save name the door stamps on it.
16+
17+
**What still saves.** 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: this door keeps a container saved under a name other than its object, and this change leaves that alone. A view item (a body carrying `viewKind`) under an expanded name, the sanctioned override for that name. The read doors are unchanged. A row stored in this shape before this change keeps its bytes and is served as before; `migrate meta --stored` and package duplication, which re-save stored rows through this door, report such a row as failed with this refusal instead of re-saving it.
18+
19+
**The fix.** Add the view as a member of the stored container that already expands the name (in the example, the container `crm_lead`, whose `listViews.pipeline` is that view), or save a view item (`name`, `object`, `viewKind`, `config`) under the expanded name (`crm_lead.pipeline`).

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ import { isMissingTableError } from '@objectstack/metadata/errors';
107107
// door (`saveMetaItem`), the restore doors (`rollbackMetaItem`, `revertCommit`)
108108
// and the draft promotion (`promoteDraftForPublish`).
109109
import { savedItemNameRefusal } from '@objectstack/metadata/view-container-name';
110+
// [#21620] The one spelling of "which object a view container binds to" — the
111+
// derivation the source registrars file a container under — so the save door's
112+
// sibling-expansion refusal judges "the same object" as every other door does.
113+
import { deriveViewContainerObject } from '@objectstack/metadata/view-container';
110114
import type {
111115
BatchUpdateRequest,
112116
BatchUpdateResponse,
@@ -17955,6 +17959,113 @@ export class ObjectStackProtocolImplementation implements
1795517959
return err;
1795617960
}
1795717961

17962+
/**
17963+
* [#21620] The save door's refusal of a view container saved under a name
17964+
* that ANOTHER stored container of the same object expands to — with
17965+
* `{ name: 'crm_lead', object: 'crm_lead', listViews: { pipeline } }`
17966+
* stored, a second container `{ object: 'crm_lead', list }` saved as
17967+
* `crm_lead.pipeline`.
17968+
*
17969+
* The harm is #21558's, reached through a sibling: the second container
17970+
* becomes the stored row of `crm_lead.pipeline`, and both read doors give
17971+
* a name with a row of its own that row (#21510's one predicate,
17972+
* {@link namesWithOwnStoredRow}). So the first container's expansion no
17973+
* longer fills the name, the object door — which never enumerates a
17974+
* container — lists nothing under it, and the by-name read answers the raw
17975+
* second container: the sibling's view is gone from both doors and no door
17976+
* answers a view item for the name. #21558's check cannot see this: the
17977+
* second container's OWN expansion is `crm_lead.default`, never its save
17978+
* name.
17979+
*
17980+
* Triage's ruling on the card named a broader check — a container's name
17981+
* must be its object's name — and made it conditional on a census, with
17982+
* THIS narrower check as the fallback. The census hit: this door keeps a
17983+
* container saved under a name other than its object (#13407's live
17984+
* authoring path, which the platform checklist's live view-authoring item
17985+
* drives; #21412's P2 and P2b, ruled; #21334's arm expands one under its
17986+
* own name, ruled), and Studio's metadata editor re-saves such a container
17987+
* under its stored name. So the name is judged only against what the
17988+
* other stored containers of the same object expand to.
17989+
*
17990+
* The judgment is the readers' own, never a copy of it:
17991+
* - the rows are the ones {@link readActiveOverlayRows} selects for this
17992+
* caller, through the read gate the readers apply
17993+
* ({@link organizationIdForMetaRead}) and with no package filter, so
17994+
* every reader whose selection holds this container and a sibling is
17995+
* covered for this caller's scope;
17996+
* - each row is parsed by {@link storedOverlayEntries} and expanded by
17997+
* {@link expandStoredViewContainers}, with the row's own package
17998+
* binding, so every member kind, the expander's de-duplication and
17999+
* #21334's arm are judged where the readers place them;
18000+
* - the row stored under the save name itself is left out: it is the row
18001+
* this save replaces, not a sibling;
18002+
* - "the same object" is the expanded view's `object` against
18003+
* {@link deriveViewContainerObject} of the body, the one derivation
18004+
* every door files a container under.
18005+
*
18006+
* The body judged is the one the author sent, with the door's own `name`
18007+
* stamp applied first (a body with no `name` is judged under the save
18008+
* name), BEFORE {@link normalizeViewMetadata}'s identity patch — on an
18009+
* unscoped kernel the sibling's expansion is registered under the name,
18010+
* and a `form`-only container would take its `viewKind` there and reach
18011+
* the schema as a malformed view item instead of this refusal.
18012+
*
18013+
* A view item (`viewKind` set) is not a container and is untouched: under
18014+
* an expanded name it is that name's sanctioned override. Rows already
18015+
* stored in this shape keep their bytes and are served as before; only a
18016+
* new save of one is refused, and the re-savers that write through this
18017+
* door (`migrateStoredMetadata`, `duplicatePackage`) record that refusal
18018+
* as the row's failure instead of re-saving it.
18019+
*
18020+
* `VALIDATION_ERROR` / 400, the envelope of the two name checks it sits
18021+
* beside. The prescription names the stored container that expands the
18022+
* name, and gives two arms: add the view as a member of THAT container, or
18023+
* save a view item under the expanded name. ⛔ It never prescribes a save
18024+
* under a name another stored container holds — not even the object's own
18025+
* name, which in the card's pair IS the sibling: an author (or an AI)
18026+
* following such an arm literally would replace the sibling's row and drop
18027+
* the very view this refusal keeps serving. Runtime words carry no tracker
18028+
* number.
18029+
*/
18030+
private async containerSiblingExpansionNameRefusal(
18031+
type: string,
18032+
item: unknown,
18033+
saveName: string,
18034+
organizationId: string | undefined,
18035+
): Promise<(Error & { code: 'VALIDATION_ERROR'; status: 400 }) | undefined> {
18036+
if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'view') return undefined;
18037+
if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
18038+
const body = item as Record<string, unknown>;
18039+
const stamped = body.name ? body : { ...body, name: saveName };
18040+
if (!isAggregatedViewContainer(stamped)) return undefined;
18041+
const object = deriveViewContainerObject(stamped);
18042+
if (!object) return undefined;
18043+
let records: any[] = [];
18044+
try {
18045+
records = await this.readActiveOverlayRows({ type }, organizationIdForMetaRead(type, organizationId));
18046+
} catch (error) {
18047+
// [#5532] The readers' rule: only an unprovisioned store means "no
18048+
// rows". Any other failure is not answered as "no sibling".
18049+
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
18050+
}
18051+
const siblings = this.storedOverlayEntries({ type }, records)
18052+
.filter((entry) => entry.name !== saveName);
18053+
const hit = this.expandStoredViewContainers(type, siblings)
18054+
.find(({ item: expanded }) => expanded.name === saveName && expanded.object === object);
18055+
if (!hit) return undefined;
18056+
const err = new Error(
18057+
`Invalid view container: it is saved under '${saveName}', which is a name the stored container `
18058+
+ `'${hit.container.name}' expands (its ${String(hit.item.viewKind)} view on '${object}'). An expanded `
18059+
+ `view fills only a name that has no stored row of its own, and this container would be that row, so `
18060+
+ `that view would no longer be served and no read would answer a view under '${saveName}'. Add the `
18061+
+ `view as a member of the container '${hit.container.name}' (its list, listViews, form or formViews), `
18062+
+ `or save a view item (name, object, viewKind and config) under '${saveName}'.`,
18063+
) as Error & { code: 'VALIDATION_ERROR'; status: 400 };
18064+
err.code = 'VALIDATION_ERROR';
18065+
err.status = 400;
18066+
return err;
18067+
}
18068+
1795818069
// [#21207] `parentVersion` is a CALLER's version token — the keyed form a
1795918070
// receipt served — and is compared in that form (`storedParentForToken`).
1796018071
// `storedParentVersion` is the in-process twin for a caller that read the
@@ -18446,6 +18557,16 @@ export class ObjectStackProtocolImplementation implements
1844618557
);
1844718558
if (ownExpansionRefusal) throw ownExpansionRefusal;
1844818559
}
18560+
// [#21620] …and a view container saved under a name ANOTHER stored
18561+
// container of the same object expands to, with the same envelope,
18562+
// judged by the readers' own row selection and expansion. Also
18563+
// before the stamp. See {@link containerSiblingExpansionNameRefusal}.
18564+
{
18565+
const siblingExpansionRefusal = await this.containerSiblingExpansionNameRefusal(
18566+
singularType, request.item, request.name, request.organizationId,
18567+
);
18568+
if (siblingExpansionRefusal) throw siblingExpansionRefusal;
18569+
}
1844918570
let baseline: unknown;
1845018571
if ((PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view'
1845118572
&& typeof this.engine.registry?.getItem === 'function') {

0 commit comments

Comments
 (0)