Skip to content

Commit 8caa131

Browse files
fix(metadata-protocol)!: a package's stored copy of a container it ships overlays its shipped views, so a withdrawal saved in the copy holds at the anonymous form doors (#22023)
Fixes #21980 Clause-②: no (narrowing) A package's stored copy of a view container it ships now overlays that package's shipped views, so a withdrawal saved in the copy holds at the anonymous form doors. This is triage's direction for the card (6014736043, unlocked by 6016790773). It lands under the maintainer's ruling on the two questions the first round returned: batch #282 item 1, decision card #22004 (record 6020103367, pointer 6020226416 on the card), 「同意」 at 2026-10-06T15:59Z. OQ1 is answered A: the unscoped hydration line, under one measurement condition (below). OQ2 is answered A: the narrowing arm, `minor`. All source edits are in `@objectstack/metadata-protocol` (`packages/metadata-protocol/src/protocol.ts`). There is no `packages/spec` edit, no loader edit, no `rest-server.ts` edit and no governed path. ## What changes - **The copy's names.** `expandRuntimeViewContainer` gives the loaders' arm to a stored copy of a container its own package ships, bound to the same object (`copiesOwnShippedViewContainer`). Each member is served as `OBJECT.KEY`, in the copying package's own slot, which the list and the by-name read already select per package (`servedViewExpansion`). - Every other container on another package's object keeps the own-name arm. - A copy on another package's object still declares no default view (the seat's answer on the own-name arm, unchanged). - The loaders' names do not change. - "The copying package ships this container" is read through the one existing lookup, now a helper `shippedViewContainerOf` that `overlaidShippedContainerViewNames` shares (no behaviour change there). The object binding is read by `runtimeViewContainerObject`, which is the base derivation chain extracted unchanged. - **The unscoped registry hydration** (OQ1 A). `hydrateExpandedViewItems` no longer registers an expansion under the bare name when another package ships that name (`shippedArtifactsOf`). - `SchemaRegistry.getItem` answers the bare slot ahead of any package's own entry, so on an unscoped kernel the by-name read naming the other package used to serve this container's view. - This predates the copy's new names. It happens when two packages ship one container and one of them stores a copy (measured on base `protocol.ts`). - Every kernel's by-name read already answers such an expansion from its stored row, ahead of the registry (`resolveRowlessExpandedView`). - **Changeset:** `.changeset/21980-copy-overlays-shipped-names.md`, `@objectstack/metadata-protocol` `minor`. It carries `Clause-②: no (narrowing)`, a BREAKING line naming the three save-door shapes with their remedy, and the ADR-0087 `not-required (no-migration-prescription)` marker in the shape of the earlier save-door narrowing. The marker states that no census of the writers of such copies was taken. ## The save door narrows in three shapes The collision predicate is unchanged; it judges the copy at its new names. Each shape is a package's copy of a container it ships on another package's object. Each was accepted on base and is refused on head with `VALIDATION_ERROR` / 400, measured on both kernels: - the copy adds a bare `list` whose loader name, `OBJECT.default`, only the other package ships; - the copy adds a keyed member whose loader name only the other package ships (measured with a `formViews` key); - another stored container, under a different row name, already expands a name the copy now expands. This is reachable only by install order. Unchanged: - a copy with only its shipped members; - the package's own view item row of a name the copy expands, which keeps its slot. A package-less copy whose package is resolved by registry order now takes the loaders' names in both orders. At base it took them in one order only. ## The ruling's condition, measured before opening this The condition: on an unscoped kernel, for a name two packages share, the by-name read naming NO package must answer, never an absence. It was measured through `getMetaItem` with no `packageId`: - on both kernels and in both registry orders; - for each member, with the object owned by the other package or by none; - at three points: base `aa09db58c9`, the direction `b7a2a8f547`, and the hydration line `2322b5bb04`. Each point was a trap-guarded swap of `protocol.ts`, and each restore was proven by blob equality and an empty `git diff HEAD`. Results: - **No read answers nothing**, anywhere. - **The hydration line leaves this read unchanged** on both kernels: the direction's rows and head's rows are identical. - **Which body the read answers is not owner-stable, and it was not at base either.** - At base it answered whichever package registered first. That was the owner's shipped item in one order and the copying package's in the other. - From the direction on, it answers the copy's body, which is the last expansion of the name (`servedViewExpansion` naming no package). - When the other package registered first, that answer carries the other package's `_packageId`. The same mislabel happens on base in the no-owner case. See the Acceptance notes. This is pinned as block (h): an answer on both kernels, the same body on both, and a body the env-wide list serves under the name. Census of the readers of the hydrated bare entry outside `metadata-protocol`: - No non-test file calls `getItem`, `listItems` or `getArtifactItem` on `view` directly. - `MetadataManager.getViewsByObject` reads its own loader store. - The objectql facade's generic `get` and `list` are `getMetaItem`'s step 2, which runs after step 1b has answered the expansion from its row. ## Pins All pins are in `packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts`, inside #21967's block, reusing its registry double and its composition of the doors. - **(f) The family's enumeration**, derived as a cross product: who owns the object (the copying package, another package, none), whether the copying package ships the container, and the member (bare list, keyed member, default form). Both kernels. Each placement asserts: - reach on both read doors; - the default the copy declares; - no name that only another package ships; - the owner's names intact on both read doors; - for a form, the withdrawal shuts the anonymous doors, with a control: saved open, the form is served. - **(g) The earlier no-owner case.** Two packages ship container `task`, and either one stores a copy. The by-name read naming each package answers that package's own item, on both kernels. - **(h)** The condition above. ## Measured - **Base reading** of (f) at `5de8db7939`: 10 red / 51 green. The reds are exactly the placements where the copying package ships the container on another package's object. - **Full `@objectstack/metadata-protocol` suite** at `ca60b61d7b` (merged `origin/main` at `803764a36f`): 218 files and 28127 tests passed, 19 skipped (`os-verify-lock` VERDICT command-exit 0). Typecheck is green, and the pin file is in the program. - **Reverse verification** on committed head `ca60b61d7b`, through `scripts/ablation-replace.mjs`. Each anchor hit once and the blob changed; each restore was proven (blob equals HEAD `400cd431ef84`, `git diff HEAD` empty). The subject is imported from source, so there is no dist leg. Predictions were written first. - The hydration line taken out: predicted 9 red / 162 green, measured 9 / 162. The reds are (f)'s 3 unscoped owner reads and (g)'s 6 unscoped cases; (h) stays green, as the condition measurement predicted. - The own-name arm restored for the copy: predicted 10 red / 161 green, measured 10 / 161. - **Clause-②**, measured against the built entry declarations: `dist/index.d.ts` and `dist/index.d.cts` were built from `origin/main`'s `protocol.ts` and from head, then diffed. Apart from comments, the only difference is three untyped private member lines on `ObjectStackProtocolImplementation`; no exported signature moves. The narrowing arm is for the save door's accept set (above). - **Gates:** - `dispatch-gates --commands` (no paths) derives 64; all 64 exit 0, and `--ran` reports 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN. - The artifact-roster block: 51 of 54 exit 0 before this PR existed. The other three need a PR's context (`check-closing-target-claim`, `check-partof-closing-keyword`, `check-single-claim-paths`). - The four symbol-anchor sweeps are green. - Lint, narrowed: the population is read from `eslint.config.mjs` (its TS glob covers both `.ts` files; no glob matches `.md`). The JSON count is 2 files, 0 errors, 0 warnings. There is no typed linting and the config's only disk reads are two untouched baselines, so the diff cannot move an untouched file's verdict. ## Acceptance notes - **The by-name read naming no package, for a name two packages ship**, answers the copy's body wearing the envelope of whichever package registered first. `getMetaItem` (`:10431`) grafts `lookupArtifactItem(type, name)` with no package onto the body it serves. On base this happens when two packages ship one container on an object nobody owns and one stores a copy. From this PR on, it also happens when the object's owner is the other package. Not changed here: it is a by-name read change outside the ruling. Reported to the seat with evidence; the pin (h) does not pin which package's body or stamp that read answers. - The loaders keep `isDefault` on the default list of a container a package ships on another package's object, while that package's stored copy of it declares no default (the seat's earlier answer, kept). Read only, not measured on a door. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9a0401f commit 8caa131

3 files changed

Lines changed: 370 additions & 10 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
A package's stored copy of a view container it ships overlays that package's shipped views, so a withdrawal saved in the copy holds at the anonymous form endpoints; the runtime save door refuses three copies it accepted before
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, the consequence of where the read doors now place a stored copy's views: 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 copy meant its added member as a view of its own, as an override of the other package's view, or 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. No census of the writers that save such copies (a package's stored copy of a container it ships on another package's object) was taken: Studio, package duplication, `migrate meta --stored`, the example apps, 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 view container refusals shipped with.
12+
13+
- **What was wrong.** The source loaders register a view container a package ships as `OBJECT.KEY` views for that package, whichever package owns the object. When that package stored a copy of the same container (a `PUT /api/v1/meta/view/NAME` of the container) and the object belonged to another code package, the copy expanded under its own name instead, as `OBJECT.CONTAINER.KEY` (a bare `list` as `OBJECT.CONTAINER`). So the copy overlaid none of the views its package ships: a form withdrawn from anonymous intake in the copy stayed open in the package's shipped form of that name, and the anonymous form endpoints kept serving it.
14+
- **What it does now.** A package's stored copy of a container that package ships, bound to the same object, expands as the loaders expand the shipped container: each member is served under the loaders' name, `OBJECT.KEY`, in the copying package's own slot on the view list and on the by-name read naming that package. So a withdrawal saved in the copy holds at the anonymous form endpoints. The copy still declares no default view for an object another package owns. Any other container on another package's object keeps expanding under its own name, unchanged.
15+
- **The by-name read on an unscoped kernel.** On a kernel with no environment id, a stored container's expanded views are also registered in the schema registry, under the bare name. A view whose name another package also ships is no longer registered there: the registry answered that bare entry ahead of the other package's own view, so `getMetaItem` naming the other package served this container's view. Every kernel's by-name read already serves such a view from its stored row, for its own package and for a read that names no package. Two packages that ship one container, with one of them storing a copy, were affected before this change too.
16+
17+
**What is refused now.** `saveMetaItem`, which `PUT /api/v1/meta/view/NAME` and the dispatcher's metadata save both call, judges a copy at the names it now expands to, so three copies it accepted before are refused with `VALIDATION_ERROR` / 400, before anything is stored. Each is a package's copy of a container it ships on another package's object:
18+
19+
- The copy adds a bare `list` whose name, `OBJECT.default`, only the other package ships. Before: accepted, served as `OBJECT.CONTAINER`. After: refused, naming the package that ships `OBJECT.default`.
20+
- The copy adds a keyed member (a `formViews` or `listViews` entry, a named `list`, or a `form`) whose name, `OBJECT.KEY`, only the other package ships. Before: accepted, served as `OBJECT.CONTAINER.KEY`. After: refused, naming the package that ships `OBJECT.KEY`.
21+
- Another stored container, saved under a different name, already expands a name the copy now expands. Before: accepted. After: refused, naming that stored container.
22+
23+
**The fix.** For the first two, give the added member a key of its own that no package ships and no stored container expands, or save a view item (`name`, `object`, `viewKind`, `config`) under that name to override the other package's view. For the third, add the view as a member of the stored container that already expands the name, or save a view item under that name.
24+
25+
**What still saves.** A copy that keeps the members its package's shipped container has, their contents edited, under the container's own name. A copy that adds a member under a key no package ships and no stored container expands. A view item under any of these names. Every container that is not a copy of its own package's shipped container, as before.
26+
27+
**Rows stored before this change.** They keep their bytes. A stored copy of a container its package ships, on another package's object, is now served under the loaders' names (`OBJECT.KEY`) instead of `OBJECT.CONTAINER.KEY`, so it overlays the package's shipped views from the next read on, with no re-save. A reference to one of its old names (a navigation `viewName`, a form action `target`) no longer resolves; point it at `OBJECT.KEY`. A new save of a stored copy in one of the refused shapes, a re-save included, is refused until its body stops colliding. Delete stays open.

0 commit comments

Comments
 (0)