Skip to content

Commit eea82af

Browse files
fix(metadata-protocol): one collision predicate at the save door — a stored view container never takes a name already served from elsewhere (#21639, #21638) (#21648)
Fixes #21639 Fixes #21638 Clause-②: no (narrowing) #21638 carries its own claim on this branch (5975022936), as the Closing-Target Claim Guard requires for every card a PR closes. Triage folded it into #21639's claim. The runtime save door's accept set narrows: one predicate now refuses a stored view container whose save name, or any name its expansion produces, is a name already served from elsewhere. Nothing widens. A package-less container row stored under the name of a view item a package ships now belongs to no package, as triage's fold of #21638 rules. Dispatched by `domain:engine` seat 1 under claim 5974259402 (file surface corrected by 5974270195), branch `claude/issue-21639-view-container-collision`. Triage's ruling 5973827435 and its fold 5973838571 are implemented as written. ## What changed All of it is in `packages/metadata-protocol/src/protocol.ts`, the claimed surface. The producer is the save door itself, so nothing moved to another package. - **One predicate replaces the two one-shape checks.** `viewContainerNameCollisionRefusal` replaces #21558's `containerOwnExpansionNameRefusal` and #21620's `containerSiblingExpansionNameRefusal` at the same place in `saveMetaItem`: after `savedItemNameRefusal`, before `normalizeViewMetadata`. - **What the container would serve** is its expansion as the readers place it: `expandRuntimeViewContainer` with the request's package binding, the binding the row is stored under. The body judged is the authored one with the door's own `name` stamp applied first. - **Elsewhere (1), another stored container's expansion in the caller's selection, whatever its object.** These are the rows `readActiveOverlayRows` selects through the readers' gate `organizationIdForMetaRead`, with no package filter. Each is parsed by `storedOverlayEntries` and expanded by `expandStoredViewContainers` with its own binding. The row stored under the save name is left out, because it is the row this save replaces. The `same object` qualifier is gone, as ruling (1) B says, and so is the now-unused `deriveViewContainerObject` import. - **Elsewhere (2), a view item a package ships.** `lookupArtifactItem` answers, and the artifact must carry `viewKind` (`isShippedViewItem`, a new module-level predicate). One exception: the views of the shipped container this row overlays by its own name (`overlaidShippedContainerViewNames`). ADR-0005 keys an overlay by its own name, so that container's views are the row's own to replace. - **The cells, in order:** the save name against a sibling's expansion, then against a shipped view item, then against its own expansion (#21558's shape); then each expansion name against a sibling's expansion, then against a shipped view item. The first collision answers. - **The envelope** is unchanged: `VALIDATION_ERROR` / 400, with no new code. Every message names the other owner (the stored container, the shipping package, or "its own expansion") and gives the family's prescription. That is: add the view as a member of the container that owns the name, or save a view item under the name. For a shipped name: save a view item under it, or a name or key of its own. ⛔ No arm prescribes a save under a name another stored row holds. #21558's own-expansion arm used to say "Save the container under its object's name" even when a stored container held that name. It now names that container to add the view to. - **Prescription first, explanation last.** A 4xx message crosses the REST boundary bounded at 500 characters with its tail cut (`CLIENT_MESSAGE_MAX`). The first REST probe of this branch received the shipped-arm prescription cut mid-word, so every message now front-loads the owner and the prescription. The pin asserts both inside the first 500 characters (#17584's ordering rule). - **The attribution half (#21638), in `runtimeViewContainerPackage`.** A package-less row whose same-named shipped artifact is a view **item** (`viewKind` set) now answers no package: "A container row is not an overlay of the item, so it is attributed to no package." A package-less row under a shipped **container**'s name keeps that container's package, as before. A bound row is unchanged. ## Census, taken first (the ruling's stop conditions) **(2): does a live writer save a second container of one object on purpose?** Readings are at BASE `7b07749f05`, re-read after merging `origin/main` (head `d5101d1831`). objectui was read at the old pin `89cad75d55` and at the new pin `ab1879721595`, which `origin/main` moved to while this ran. | Census input | Evidence | A second container of one object, a container under another container's expanded name, or a container under a shipped view item's name, on purpose? | |---|---|---| | #13407's authoring path: the platform checklist's live view-authoring item `studio-authoring.view-authoring-live` (P1, revision 2) | Step 1 saves ONE container, `qa_repair_asset_views` = `{ object: 'repair_asset', list, form }`, on a runtime-authored object. No other checklist item stores a container on `repair_asset`. #13407's own repro (a container bound to `note` under another name, per PR #21637's census) is one container too. | No. | | #21412's P2 / P2b (seat answer 5961930912) | One container, `lead_views`, bound to `crm_lead`. Pinned in the `#21412` block of the same test file, still green. | No. | | #21334's arm (rulings 5946423948, 5955628428) | It expands under the container's own name, `OBJECT.CONTAINER_NAME` and `OBJECT.CONTAINER_NAME.KEY`, never a name the owning package ships. Pinned as two allowed rows below. | No. | | Studio's metadata editor re-save (objectui at both pins) | **Creators** write view items: the metadata-admin `createBuildBody` (`anchors.ts:291`, "Emit a canonical ViewItem"), the spec create seed for `view` (`metadata-create-seeds.ts:62`, `viewKind: 'list'`), and the flat configs of `data-objectstack` `createView` and `setViewConfig`. **Re-savers** save the loaded body under the name it carries: `ResourceEditPage` (`:1477`), the Interfaces pillar's `StudioDesignSurface` (`:2475`, new at `ab1879721595`), and `updateView`. | No creator writes a container. A re-saver writes a colliding container only when the store already holds the collision, and that re-save is now refused until its body stops colliding. | | Package duplication (`duplicatePackage`, a writer through this door, outside the ruled set) | Throwaway probe, deleted afterwards. A container `pone_extra_views` in `com.example.pone`, bound to `crm_lead` (outside the package, shipped by no code package), duplicated into `com.example.ptwo`. **At BASE:** copied, and the object door's `crm_lead.default` became the copy's, wearing `_packageId: com.example.ptwo`. The source package's view was silently replaced. **At HEAD:** `failed[]` carries this refusal, naming `pone_extra_views`, and the source's view stays. | Yes, as a byproduct; not on purpose, as this dev reads it. The copy is meant to be an independent base, and the collision is the defect itself. Raised as an open question in the report. | | `migrateStoredMetadata` | It re-saves rows already stored, inside a `try` that records `outcome: 'failed'`. | Not a creator. | | The in-repo AI author | The MCP tools in `packages/mcp/src/mcp-http-tools.ts` have no metadata write. `skills/objectstack-ui` teaches `defineView` in source. | No. The cloud AI author is outside this repository and the ruled set: **NOT MEASURED**. | | Packaged containers and stored rows | Source registrars never reach this door. The example apps seed no `sys_metadata` view rows. Hosted tenants: **NOT MEASURED**. | n/a | **The attribution half: does a live overlay path depend on a package-less container row taking a shipped item's package?** - **Callers.** `runtimeViewContainerPackage` is called by `expandRuntimeViewContainer` (and by the new `overlaidShippedContainerViewNames`). `expandRuntimeViewContainer` is called by `expandStoredViewContainers` (the list read, the by-name read's `resolveRowlessExpandedView`, and this predicate), by `hydrateExpandedViewItems` (the registry), and by the predicate itself. - **The overlay that does take a package is unchanged and pinned (CONTROL):** a package-less row under the package's own container name. That is the path behind the checklist's `packaged-display-class-direct-edit` designer overlay. - **No writer stores a package-less container under a view item's name.** Studio's creators write view items, the save door now refuses the shape, and the restore doors and draft promotion re-write only bodies already stored. - **Verdict:** no live overlay path depends on it, so the ruling's attribution lands. ## Every accept-set change at `saveMetaItem`, type `view` Each row covers publish and draft mode, both scopes and both kernels. "Stored container" means one in the caller's selection. | Input | Before (BASE `7b07749f05`) | After | |---|---|---| | A container bound to **another object**, under a name a stored container expands | Accepted: stored, and registered on an unscoped kernel. The sibling's view under that name was served by neither door, and the by-name read answered the raw container. | **Refused** `VALIDATION_ERROR` / 400, naming the stored container. Nothing is stored or registered. | | An **unbound** container under such a name | Same as above. | Same as above. | | A container whose expansion takes a name a stored container already expands: a second container of one object whose bare `list` takes `OBJECT.default`, under a free name or under the object's name | Accepted. The one read last replaced the other's view on both doors. | **Refused**, naming the stored container. | | A container under the name of a **view item a package ships**: package-less, organization-scoped, or bound to a writable package | Accepted. The packaged view was no longer listed on the object door, and by-name answered the raw container. Package-less, the row also took the shipping package, so its bare `list` replaced `OBJECT.default` on both doors with that package's `_packageId`. | **Refused**, naming the package. | | An overlay of a package's own container whose member takes the name of a view item **the package ships on its own** | Accepted. The member replaced that packaged view on both doors. | **Refused**, naming the package. | | A container under its own expanded name (#21558), or under a name another stored container of the same object expands (#21620) | Refused `VALIDATION_ERROR` / 400. | Refused, with the same envelope. The prescription now comes first, and the own-expansion arm no longer prescribes a save under a name a stored container holds. | | A `form`-only container under a registered view item's name | Refused `VALIDATION_ERROR` / 400 under #21558 or #21620. Under any other registered name: 422 from the identity stamp. | Every shape this predicate covers is refused `VALIDATION_ERROR` / 400 first, before the stamp. | | Everything else | Unchanged. | Unchanged. | **Rows already stored.** They keep their bytes, and no row is re-saved. A package-less container row stored under a shipped view item's name now reads as belonging to no package: - On that package's object it expands under its own name (`showcase_task.showcase_task.in_progress`), with no `_packageId` and no default. - The packaged views it used to replace (`showcase_task.default`, or `showcase_task.edit` for a `listViews.edit` member) are served again on both doors (pinned). - The row still takes its own name's slot on both doors. That is the read doors' name-keyed overlay, out of surface and unchanged. A new save of a row in a refused shape, a re-save included, is refused until its body stops colliding. Delete stays open. ## The enumeration pin (the card's acceptance) `view-container-runtime-expansion.test.ts`, block `#21639`. Each row runs on both kernels (`env_local` and unscoped) and both scopes, unless the row names one scope. - **A refused row asserts:** - `code` and `status`; - the save name, the colliding name and the owner; - the owner and the view-item prescription inside the first 500 characters; - that no stored row's name is ever prescribed as a name to save under; - in publish and in draft mode, that nothing is stored or registered; - that the owner's view still answers on both doors. - **An allowed row asserts** that it saves, and that each named view answers on both doors. - **The structural tests fail when:** - a row is neither refused nor allowed, or is both; - a ruled shape has no row; - a cell of the predicate has no refused row. | # | Shape | Verdict | Owner named / reason | |---|---|---|---| | 1 | A container under a name its own expansion produces (#21558's own-expansion name) | REFUSED | its own expansion | | 2 | The same, while a stored container holds its object's name | REFUSED | its own expansion; the prescription names that container | | 3 | A container of the same object under a name a stored container expands (#21620's sibling) | REFUSED | the stored container `crm_lead` | | 4 | A container bound to **another object** under that name ((1)'s other-object container) | REFUSED | the stored container `crm_lead` | | 5 | An **unbound** container under that name ((1)'s unbound container) | REFUSED | the stored container `crm_lead` | | 6 | A second container of one object, free name, bare `list` on `OBJECT.default` ((2)'s second container default) | REFUSED | the stored container `crm_lead` | | 7 | A container under its object's name, after a free-named container took `OBJECT.default` | REFUSED | the stored container `lead_other_views` | | 8 | A package-less container under a shipped view item's name (#21638's shipped item name) | REFUSED | the package `com.example.showcase` | | 9 | A writable-package container under a shipped view item's name | REFUSED | the package `com.example.showcase` | | 10 | An overlay of the package's container whose new member takes a view item the package ships on its own | REFUSED | the package `com.example.showcase` | | 11 | A view item under a stored container's expanded name (allowed: #21510's sanctioned override) | ALLOWED | a view item is not a container: it is that name's sanctioned override | | 12 | A view item under a shipped view item's name | ALLOWED | the sanctioned override of the packaged view by name | | 13 | A container under its object's name (allowed) | ALLOWED | the container contract's own name (ADR-0017 §3.2), and nothing it expands is served elsewhere | | 14 | A container's own re-save | ALLOWED | the row under the save name is the row this save replaces | | 15 | A container under a name of its own beside a sibling, with names the sibling does not expand | ALLOWED | the census writers' shape, colliding with nothing | | 16 | An overlay of the package's own container, by its own name | ALLOWED | ADR-0005: the row stands in for the shipped container and its views | | 17 | A package-less container on another package's object | ALLOWED | #21334's arm: its names derive from its own name | | 18 | The same, in a writable package | ALLOWED | #21334's arm, bound to its own package | | 19 | Under another organization's container's expanded name (organization scope) | ALLOWED | the caller's selection decides, as it does for the readers | | 20 | An environment-wide container under an organization's container's expanded name (environment scope) | ALLOWED | the caller's selection decides; reading every organization's rows at an environment-wide save would be a cross-tenant read | ## The PM's mechanism hypotheses - **H1, confirmed** at `7b07749f05`. `saveMetaItem` called `containerOwnExpansionNameRefusal` and then `containerSiblingExpansionNameRefusal`, both before `normalizeViewMetadata` and both `VALIDATION_ERROR` / 400. Both are replaced by the one predicate. - Every #21558 and #21620 pin keeps its envelope and its intent: the file is green, and leg 1 below turns all of them red. - #21558's showcase pins now also assert the owner the predicate names. Every name they save under is one the showcase ships, so the shipped-item arm answers there. The #21620 block gains a one-paragraph note. - **H2, measured.** - The allowed shapes collide as follows. A container's own re-save collides with nothing once its row is left out. An overlay of its own package's container collides with every packaged name it re-expands; the overlaid container's views are excluded, as the ruling's ADR-0005 reading requires. #21334's own-name arm collides with nothing. - **The line drawn:** a container under its object's name is allowed when it expands no name served elsewhere. Saved after a free-named sibling of that object took `OBJECT.default`, it is (2)'s shape and refused (row 7). - **H3, confirmed.** "A view item a package ships" is `lookupArtifactItem`, the registry's artifact read, which never answers a tenant-authored row, holding an artifact with `viewKind` set. - The first spelling ("not a container") refused `sys-metadata-repository.package-writability.test.ts`'s ADR-0005 overlay preservation pin. That fixture's artifact stub is neither a container nor a view item. The predicate is now positive, and that pin is green. - #21510's sanctioned override stays allowed (rows 11 and 12). - **H4, confirmed.** The callers and the live overlay path are listed above. Rows stored before this change get the reading named above. - **H5, kept by construction.** The predicate is not gated on `source` or `writeFace`, so `migrateStoredMetadata` and `duplicatePackage` record the refusal as the row's failure. Duplication was measured (the census row above). ## Tests All readings are at HEAD `d5101d1831` unless named otherwise. - **Premise.** - **Method:** the new pins, run against BASE's `protocol.ts` (blob `56bc12dce760`), restored by a trap-guarded script. Restore proof: blob `e5765e5ba0f9` equal to HEAD at `0af0f28e49`, and `git diff HEAD` empty. The command: `vitest run src/view-container-runtime-expansion.test.ts -t '#21639|#21638'`. - **Result: 40 failed, 50 passed** (231 skipped). - **Red:** - the 7 newly refused shapes × 2 scopes × 2 kernels (28), each failing on `the save is refused`; - row 2 × 4, where BASE's arm reads "Save the container under its object's name, 'crm_lead'" while a stored container holds `crm_lead`; - the 8 attribution pins, where the packaged label and config were replaced. - **Green:** rows 1 and 3, every allowed row, the 2 structural tests, and the 4 attribution controls. - **New pins: 90.** - The enumeration block: 2 structural tests + 76 row cells. - The `#21638` attribution block: 2 at-rest cases + 1 control, × 2 scopes × 2 kernels. - **The file:** 321 passed (231 pre-existing + 90). - **The package:** `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2` gives Test Files 209 passed, 3 skipped (212); Tests 3463 passed, 19 skipped (3482); `VERDICT command-exit 0`. - **Typecheck:** `pnpm --filter @objectstack/metadata-protocol typecheck` (`tsc --noEmit`) exits 0, and `tsc --noEmit --listFiles` includes the test file. - **Downstream sample.** - **Direction:** consumers (downstream) of `@objectstack/metadata-protocol`. - **Build:** against `dist/` rebuilt at `d5101d1831` by `turbo run build --filter='@objectstack/dogfood^...' --filter='@objectstack/metadata-protocol...' --concurrency=2`: 63 of 63 tasks. The built `dist/index.js` carries the new predicate, and the old method names are gone from it. - **`objectql`, 11 files, 229 tests:** `protocol-meta`, `protocol-view-identity-overlay`, `protocol-org-overlay-registry-gate`, `protocol-commit-history`, `protocol-packaged-view-base`, `protocol-save-meta-repo-path`, `protocol-save-meta-repo-path-real-engine`, `view-container-divergent-name-registrars`, `view-container-name-refusal`, `engine-nested-plugin-view-expansion`, `metadata-validation-sweep`. - **`rest`, 1 file, 7 tests:** `public-form-routes.stored-row`. - **`dogfood`, 2 files, 7 tests:** `view-container-cross-package-default` (PR #21430's pin over REST) and `view-container-default-form`. - All pass. The rest of the downstream run is CI's. - **Over REST**, measured with a throwaway dogfood probe on the booted showcase, deleted afterwards: - `PUT /api/v1/meta/view/showcase_task.in_progress` with a container answers 400 `VALIDATION_ERROR`, and the object door still serves `showcase_task.in_progress` as "In Progress" from `com.example.showcase`; - a second container on one runtime object answers 400; - a view item under the shipped name answers 200. ## Reverse verification The change was committed first. Both legs ran from committed `e5ac5cf14a`, the same `protocol.ts` blob as HEAD, since the later merge touched nothing under `metadata-protocol`. Each leg was a trap-guarded script around `scripts/ablation-replace.mjs --delete`. - **Leg 1, the predicate's throw**, anchor `if (containerCollision) throw containerCollision;`. - **Mutation:** the anchor went from 1 occurrence to 0, and the blob from `f853b383e1b2` to `be22198e5e1f`. Predicted direction: red. - **Result: 104 failed, 217 passed.** That is every refusal pin in the file: the #21639 refused rows (40), #21558's showcase block (24), #21620's block (36) and #21558's runtime-object block (4). - **Green:** every allowed row, every control and the attribution block. - **Restore:** blob `f853b383e1b2`, equal to HEAD, and `git diff HEAD` empty. Both the tool and the script's trap proved it. - **Leg 2, the attribution guard**, anchor `if (isShippedViewItem(overlaid)) return undefined;`. - **Mutation:** the anchor went from 1 occurrence to 0, and the blob from `f853b383e1b2` to `55cc79455d20`. Predicted direction: only the at-rest pins turn red. - **Result: 8 failed, 313 passed.** Exactly the 8 at-rest attribution pins turned red. The 4 controls and every save-door pin stayed green. - **Restore** proven the same way. - **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, at `d5101d1831` derives **64** families. 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` and `release-pending-publish --self-test`; - `check:objectui-changeset` and `check:pm-changeset-deadline-census`. - **All 64 exited 0.** `pnpm check:dual-build-cjs-loads` first exited 3: PREREQUISITE NOT MET, because 8 packages had no `dist/`. It exited 0 after those were built through the lock. - `check:dual-build-cjs-loads` and `check:type-check-debt` ran through the verify lock. - **`--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, a declared narrowing.** - **Measured:** `eslint --no-inline-config --format json` over the 2 changed `.ts` files reports 2 files, 0 errors and 0 warnings, with no "file ignored" message. The changeset `.md` has no matching ESLint configuration. - **No untouched file can change verdict:** type-aware linting is off. `eslint.config.mjs:328` says so, and `--print-config` shows `parserOptions.project` and `projectService` both null. - Repo-wide `pnpm lint` is CI's. - **Branch state:** `origin/main` was merged twice, `f30588ceb7` and then `d5101d1831`. Neither moved a byte under `packages/metadata-protocol`. ## Acceptance notes - **Rows of one name in two bindings.** "The row under the save name" excludes every row of that name in the selection, as #21620's check did. Two containers sharing one name in two bindings are therefore not judged against each other: a package-less row and a writable package's row, or a duplicate whose name carries no namespace prefix. Read, not measured. - **An environment-wide save, an organization's sibling (row 20).** The save is allowed by the ruling's "in the caller's selection", and for that organization the sibling's view is gone. Noted, not filed. - **Restore and publish doors.** `rollbackMetaItem`, `revertCommit` and the draft promotion do not run the predicate, as for #21558 and #21620. A draft saved before its sibling existed can be promoted into a collision. - **A disabled package's shipped view item still counts as served,** because the registry's artifact read does not ask whether the package is enabled. - **A stored row's own name is not "elsewhere"** in the ruling. A container stored at a name before a sibling gains a member of that name keeps the name, as PR #21637 noted. - **The at-rest #21638 row still takes its own name's slot on both doors.** That is the read doors' name-keyed overlay, out of the claimed surface. - **Cost.** A container save reads the caller's active view rows once, through the readers' row cache, as #21620's check did. It also asks the registry's artifact read once per name it would serve. A view item pays nothing. - **Declaration bytes.** `dist/index.d.ts` swaps two private member lines for two: `viewContainerNameCollisionRefusal` and `overlaidShippedContainerViewNames`. No public member or exported type changes. - **The changeset** is `.changeset/21639-view-container-name-collision.md`: `'@objectstack/metadata-protocol': minor`, `Clause-②: no (narrowing)`, the **BREAKING** banner, the ADR-0087 disposition `not-required (no-migration-prescription)` written from this census, and a before/after per shape. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6ec54f0 commit eea82af

3 files changed

Lines changed: 746 additions & 183 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
The runtime save door refuses a view container whose save name, or any name its expansion produces, is a name already served from elsewhere; a package-less container row named after a view item a package ships belongs to no package
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 a refused container was meant as a member of the container that already serves the name, as a view item of that name, or as a member under a key of its own 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 no stored row is re-saved. The census, taken first over the set the ruling names, found no writer that saves a second container of one object, a container under a name another container expands, or a container under the name of a view item a package ships on purpose: the platform checklist's live view-authoring item saves one container per object (`qa_repair_asset_views` on `repair_asset`); the save door's own name rulings (P2, P2b) save one container; a container on another package's object expands under its own name; and Studio at the objectui pin creates view items (the metadata-admin create body, `createView`, `setViewConfig`) and re-saves a stored body under the name it carries. Package duplication can copy a container whose object is outside the copied package into a second container of that object: before this change the copy silently took the source package's view, and it is now reported as a failed item. The source registrars never reach this door, and the example apps seed no `sys_metadata` view rows; 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 container-name refusals shipped with.
12+
13+
**One rule.** `saveMetaItem`, which `PUT /api/v1/meta/view/:name` and the dispatcher's metadata save both call, now refuses an aggregated view container (`list` / `form` / `listViews` / `formViews`) when its save name, or any name its expansion produces, is already served from elsewhere: by another stored container's expansion in the caller's selection (environment-wide rows plus the caller's organization's), whatever that container's object, or by a view item (a body carrying `viewKind`) a package ships. A name the container's own expansion produces is refused as its save name too. Every name is judged where the read doors place it, so every member kind, the expander's de-duplicated names and a container on another package's object (which expands under its own name) are all covered. The refusal is `VALIDATION_ERROR` / 400, in draft and in publish mode, before anything is stored or registered, and it names the other owner: the stored container, the shipping package, or the container's own expansion.
14+
15+
**Before and after, per shape** (with `{ name: 'crm_lead', object: 'crm_lead', list, listViews: { pipeline } }` stored where a sibling is named):
16+
17+
- A container bound to **another object**, saved under a sibling's expanded name (`{ object: 'crm_account', list }` as `crm_lead.pipeline`). Before: accepted; the sibling's `crm_lead.pipeline` view was no longer served on either door, and the by-name read answered the raw container. After: refused, naming the container `crm_lead`.
18+
- An **unbound** container (`{ list }`) under the same name. Before and after: as above.
19+
- A **second container of one object** whose bare `list` takes `crm_lead.default`, under a free name (`{ object: 'crm_lead', list }` as `lead_other_views`), or the object-named container saved after such a one. Before: accepted; whichever container was read last replaced the other's default on both doors, and nothing said why. After: refused, naming the stored container that already serves the name.
20+
- A container saved under the name of a **view item a package ships** (`{ name: 'showcase_task.in_progress', object: 'showcase_task', list }` as `showcase_task.in_progress`), package-less, organization-scoped or in a writable package. Before: accepted; the packaged view was no longer served on the object door and the by-name read answered the raw container. Package-less, the row was also judged a container of the shipping package, so its bare `list` replaced the packaged `showcase_task.default` on both doors, wearing that package's `_packageId`. After: refused, naming the shipping package.
21+
- An overlay of a package's own container whose new member takes the name of a view item **the package ships on its own**. Before: accepted; the member replaced that packaged view on both doors. After: refused, naming the package.
22+
- A container under a name its own expansion produces, or under a name another stored container of the same object expands. Refused before and after, with the same envelope. The own-expansion refusal no longer tells the author to save the container under its object's name when a stored container already holds that name; it names that container to add the view to.
23+
24+
**What still saves.** A view item under any of these names: it is that name's sanctioned override. A container under its object's name, or under any other name of its own, whose expansion takes no name served elsewhere, and its own re-save. An overlay of a package's own container under that container's name. A container on another package's object, which expands under its own name. A container whose would-be sibling is in another organization: the caller's own selection decides, as it does for the read doors.
25+
26+
**Rows stored before this change.** They keep their bytes and are served as before, with one change: a package-less container row stored under the name of a view item a package ships now belongs to no package. On that package's object it expands under its own name (`showcase_task.showcase_task.in_progress` for a bare `list`), with no `_packageId` and no default, and the packaged views it used to replace are served again on both doors. The row itself still takes its own name's slot, as any stored row does. A new save of a row in a refused shape, a re-save included, is refused until its body stops colliding; `migrate meta --stored` and package duplication report such a row as failed with this refusal instead of re-saving it. Delete stays open.
27+
28+
**The fix.** Add the view as a member of the stored container that already serves the name (its `list`, `listViews`, `form` or `formViews`), or save a view item (`name`, `object`, `viewKind`, `config`) under that name to override it. For a name a package ships: save a view item under it to override the packaged view, or save the container under a name of its own that no package ships and no stored container expands.

0 commit comments

Comments
 (0)