From 5de8db79399e71120a99046cd8c760fd0589f05f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 13:22:56 +0000 Subject: [PATCH 1/5] test(metadata-protocol): every placement a package's stored copy of a view container can take The withdrawal-reach family's closing enumeration, committed red against the base: who owns the object (the copying package, another package, none) x whether the copying package ships the container x the member the copy changes (the bare list, a keyed member, the default form), on both kernels. Base reading: 10 red / 51 green, the reds exactly the placements where the copying package ships the container on another package's object. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi --- .../protocol.org-scoped-write-refused.test.ts | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 81bcde1743d..8aa42ef9d97 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -1561,4 +1561,168 @@ describe('each package\'s copy of a view container expands into its own package\ }); } }); + + // The family's closing enumeration. The loaders register a container a + // package ships as `.` for that package, whichever package + // owns the object, so a stored copy of that container overlays those + // names, in that package's own slot. Any other container on another + // package's object keeps the own-name arm. Every placement a package's + // stored copy can take is derived below as a cross product: who owns the + // object (the copying package, another package, none), whether the + // copying package ships the container, and which member the copy changes + // (the bare list, a keyed member, the default form). For each one, on + // both kernels: + // - the copy's member is served under the name its placement gives it, + // in the copying package's slot of the env-wide list and on the + // by-name read naming that package; + // - no name it writes is another package's, and every name the other + // package ships keeps that package's body on both read doors; + // - for a form, the withdrawal saved in the copy closes the anonymous + // doors (their own composition, as above), and the same copy saved + // open leaves them serving. A list carries no anonymous intake + // (`anonymousFormIntakeCandidates` reads forms only), so the bare + // list's reach is pinned on the two read doors alone. + describe('(f) every placement a package\'s stored copy of a view container can take', () => { + const COPYING = 'pkg_b'; + const OTHER = 'pkg_a'; + // The other package's own form is its own to serve: it gets a slug of + // its own, so the doors' answer at SLUG is the copy's alone. + const OTHER_SLUG = 'owner-intake'; + const COPY_TITLE = `Intake (${COPYING} copy)`; + const SHIPPED_TITLE = (pkg: string) => `Intake (shipped by ${pkg})`; + const OWNERS = [ + ['the copying package owns the object', COPYING], + ['another package owns the object', OTHER], + ['no code package owns the object', undefined], + ] as const; + const SHIPPING = [ + ['the copying package ships the container', true], + ['the copying package ships no container of that name', false], + ] as const; + const form = (allowAnonymous: boolean, title: string, slug: string) => ({ + title, sharing: { enabled: true, allowAnonymous, publicLink: `/forms/${slug}` }, + }); + interface Member { + readonly member: string; + readonly isForm: boolean; + /** The name the own-name arm gives it: the container's own name, `task`, under the object. */ + readonly ownNameAs: string; + readonly body: (title: string, allowAnonymous: boolean, slug: string) => Record; + } + const MEMBERS: readonly Member[] = [ + { + member: 'the bare list', isForm: false, ownNameAs: 'task.task', + body: (title) => ({ list: { type: 'grid', label: title, columns: ['name'] } }), + }, + { + member: 'a keyed member', isForm: true, ownNameAs: 'task.task.intake_form', + body: (title, allowAnonymous, slug) => ({ formViews: { intake_form: form(allowAnonymous, title, slug) } }), + }, + { + member: 'the default form', isForm: true, ownNameAs: 'task.task.form', + body: (title, allowAnonymous, slug) => ({ form: form(allowAnonymous, title, slug) }), + }, + ]; + const PLACEMENTS = OWNERS.flatMap(([ownership, owner]) => SHIPPING.flatMap(([shipping, ships]) => + MEMBERS.map((m) => ({ ownership, owner, shipping, ships, m })))); + type Placement = (typeof PLACEMENTS)[number]; + + /** The names a source loader registers for a container body: the spec's expander under the object's name. */ + const loaderNames = (body: Record) => expandViewContainer('task', body).map((vi) => String(vi.name)); + const shippedBy = (p: Placement, pkg: string) => ({ + object: 'task', ...p.m.body(SHIPPED_TITLE(pkg), true, pkg === COPYING ? SLUG : OTHER_SLUG), + }); + /** What each package ships in the placement: the object's other owner its own member, the copying package the container it copies. */ + const shippedIn = (p: Placement): Array<[string, Record]> => [ + ...(p.owner === OTHER ? [[OTHER, shippedBy(p, OTHER)] as [string, Record]] : []), + ...(p.ships ? [[COPYING, shippedBy(p, COPYING)] as [string, Record]] : []), + ]; + /** + * The name the copy's member is served under: the name the loaders + * gave the copying package where it ships the container, the own-name + * arm on another package's object otherwise, and `.` + * on an object of its own or of none. + */ + const servedName = (p: Placement) => (p.owner === OTHER && !p.ships + ? p.m.ownNameAs + : loaderNames(p.m.body(COPY_TITLE, true, SLUG))[0]); + const label = (p: Placement) => `${p.ownership}, ${p.shipping}, ${p.m.member}`; + + async function saveCopyOf(protocol: any, p: Placement, allowAnonymous: boolean) { + expect((await protocol.saveMetaItem({ + type: 'view', name: 'task', packageId: COPYING, + item: { name: 'task', object: 'task', ...p.m.body(COPY_TITLE, allowAnonymous, SLUG) }, + })).success).toBe(true); + } + /** An organization overlay of the form under `name`, open, as a rollback restores it. */ + async function restoreOpenOverlayAt(protocol: any, name: string) { + await protocol.ensureOverlayIndex(); + await protocol.getOverlayRepo('org_a').put( + { type: 'view', name, org: 'org_a' }, + { name, label: 'Intake (org)', object: 'task', viewKind: 'form', config: form(true, 'Intake (org)', SLUG) }, + { parentVersion: null, actor: null, source: 'test.restored', intent: 'runtime-only', state: 'active', packageId: null }, + ); + } + const titleOf = (v: any): unknown => v?.config?.title ?? v?.config?.label; + /** The titles a package's slot of `name` serves in the env-wide list. */ + async function slotTitles(protocol: any, name: string, pkg: string): Promise { + const envWide: any = await protocol.getMetaItems({ type: 'view' }); + return (envWide.items as any[]).filter((v) => v?.name === name && v._packageId === pkg).map(titleOf); + } + /** Every name under which the env-wide list serves the copy's body for the copying package. */ + async function namesOfTheCopy(protocol: any): Promise { + const envWide: any = await protocol.getMetaItems({ type: 'view' }); + return (envWide.items as any[]) + .filter((v) => v?._packageId === COPYING && titleOf(v) === COPY_TITLE) + .map((v) => String(v.name)); + } + async function byNameTitle(protocol: any, name: string, pkg: string): Promise { + return titleOf((await protocol.getMetaItem({ type: 'view', name, packageId: pkg })).item); + } + + it('the population is the cross product of the three dimensions, each placement once', () => { + expect(PLACEMENTS).toHaveLength(OWNERS.length * SHIPPING.length * MEMBERS.length); + expect(new Set(PLACEMENTS.map(label)).size).toBe(PLACEMENTS.length); + // The three members, as the loaders place them: the bare list, a keyed member, the default form. + expect(MEMBERS.map((m) => expandViewContainer('task', m.body('x', true, SLUG)) + .map((vi) => [vi.name, vi.viewKind, vi.isDefault === true]))) + .toEqual([[['task.default', 'list', true]], [['task.intake_form', 'form', false]], [['task.form', 'form', true]]]); + }); + + for (const [kernel, environmentId] of KERNELS) { + for (const p of PLACEMENTS) { + it(`${kernel}; ${label(p)}: served as ${servedName(p)} in the copying package's slot on both read doors, writing no other package's name`, async () => { + const { protocol } = harness(shippedIn(p), environmentId, p.owner); + await saveCopyOf(protocol, p, false); + const name = servedName(p); + + expect(await namesOfTheCopy(protocol), 'the names the copy is served under').toEqual([name]); + expect(await slotTitles(protocol, name, COPYING), 'the copying package\'s slot').toEqual([COPY_TITLE]); + expect(await byNameTitle(protocol, name, COPYING), 'the by-name read naming the copying package').toBe(COPY_TITLE); + + const otherNames = p.owner === OTHER ? loaderNames(shippedBy(p, OTHER)) : []; + const ownNames = p.ships ? loaderNames(shippedBy(p, COPYING)) : []; + expect(otherNames.includes(name) && !ownNames.includes(name), 'a name only another package ships').toBe(false); + for (const shipped of otherNames) { + expect(await slotTitles(protocol, shipped, OTHER), `${OTHER}'s slot of ${shipped}`).toEqual([SHIPPED_TITLE(OTHER)]); + expect(await byNameTitle(protocol, shipped, OTHER), `the by-name read of ${shipped} naming ${OTHER}`) + .toBe(SHIPPED_TITLE(OTHER)); + } + }); + + if (p.m.isForm) { + it(`${kernel}; ${label(p)}: the withdrawal saved in the copy closes the anonymous doors, and saved open the doors serve`, async () => { + for (const allowAnonymous of [false, true]) { + const { protocol } = harness(shippedIn(p), environmentId, p.owner); + await restoreOpenOverlayAt(protocol, servedName(p)); + await saveCopyOf(protocol, p, allowAnonymous); + const served = await doorsServe(protocol); + if (allowAnonymous) expect(served.length, 'saved open: the doors serve the overlay').toBeGreaterThan(0); + else expect(served, 'withdrawn in the copy: the doors serve no copy of the overlay').toEqual([]); + } + }); + } + } + } + }); }); From b7a2a8f54788bdeaf41269cc1f4f1a77e8602f9d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 13:26:52 +0000 Subject: [PATCH 2/5] fix(metadata-protocol): a package's stored copy of a container it ships expands to the loaders' names Triage's direction for a stored copy of a view container its own package ships: on another package's object it expands as the source loaders expand the shipped container (`.`, in the copying package's own slot), not under the own-name arm. Any other container on another package's object keeps the own-name arm, and no copy declares the object's default. - `copiesOwnShippedViewContainer`: the copying package ships a view container under the copy's name, bound to the same object. - `shippedViewContainerOf`: the one artifact lookup for "the container a package ships under a name", now shared with `overlaidShippedContainerViewNames` (no behaviour change there). - `runtimeViewContainerObject`: the object derivation chain, extracted unchanged so the shipped container's binding is read the same way. - The enumeration pin asserts the default each placement declares. Measured: the pin file is 144 green / 3 red. The three reds are the unscoped kernel's by-name read naming the object's owning package, for a name the copy now shares with it: the registry hydration of the copy's expansion under the bare name answers it (the same mechanism already answers that read on main when two packages ship one name and only one has a stored copy). Closing it needs a change beyond the per-package keying, so the card returns to triage as needs_decision. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi --- .../protocol.org-scoped-write-refused.test.ts | 16 ++-- packages/metadata-protocol/src/protocol.ts | 93 +++++++++++++++++-- 2 files changed, 93 insertions(+), 16 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 8aa42ef9d97..9850795b425 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -1669,12 +1669,10 @@ describe('each package\'s copy of a view container expands into its own package\ const envWide: any = await protocol.getMetaItems({ type: 'view' }); return (envWide.items as any[]).filter((v) => v?.name === name && v._packageId === pkg).map(titleOf); } - /** Every name under which the env-wide list serves the copy's body for the copying package. */ - async function namesOfTheCopy(protocol: any): Promise { + /** Every item through which the env-wide list serves the copy's body for the copying package. */ + async function itemsOfTheCopy(protocol: any): Promise { const envWide: any = await protocol.getMetaItems({ type: 'view' }); - return (envWide.items as any[]) - .filter((v) => v?._packageId === COPYING && titleOf(v) === COPY_TITLE) - .map((v) => String(v.name)); + return (envWide.items as any[]).filter((v) => v?._packageId === COPYING && titleOf(v) === COPY_TITLE); } async function byNameTitle(protocol: any, name: string, pkg: string): Promise { return titleOf((await protocol.getMetaItem({ type: 'view', name, packageId: pkg })).item); @@ -1696,7 +1694,13 @@ describe('each package\'s copy of a view container expands into its own package\ await saveCopyOf(protocol, p, false); const name = servedName(p); - expect(await namesOfTheCopy(protocol), 'the names the copy is served under').toEqual([name]); + const copyItems = await itemsOfTheCopy(protocol); + expect(copyItems.map((v) => v.name), 'the names the copy is served under').toEqual([name]); + // The seat's answer on the own-name arm stands for every container on + // another package's object, a copy included: it declares no default. + const declaresDefault = expandViewContainer('task', p.m.body(COPY_TITLE, false, SLUG))[0]?.isDefault === true; + expect(copyItems.map((v) => v.isDefault === true), 'the default it declares') + .toEqual([p.owner !== OTHER && declaresDefault]); expect(await slotTitles(protocol, name, COPYING), 'the copying package\'s slot').toEqual([COPY_TITLE]); expect(await byNameTitle(protocol, name, COPYING), 'the by-name read naming the copying package').toBe(COPY_TITLE); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 177e9170e4d..269a8e6bb4f 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -18533,6 +18533,30 @@ export class ObjectStackProtocolImplementation implements * switcher's default stays the owning package's (the by-name override and * a user's own saved default are the routes that change it). * + * ## …except a stored copy of a container its own package ships (#21980) + * + * The source loaders register a container a package ships under the + * object's name, whichever package owns the object: every member is + * `.`, registered for the shipping package + * (`objectql`'s boot loop and `metadata`'s artifact loader, both through + * the spec's `expandViewContainer`). Those names are that package's, + * already published, and an overlay is keyed by its own name (ADR-0005), + * so the package's stored copy of that container overlays them: it + * expands as the loaders expand the container, and each name lands in + * the copying package's own slot, which the list and the by-name read + * select per package ({@link servedViewExpansion}). Under its own name, + * the copy overlaid none of them, so a form withdrawn in the copy stayed + * open in the package's shipped form, at the anonymous doors too. + * + * The copy is the container the package ships under the copy's name, + * bound to the same object ({@link copiesOwnShippedViewContainer}). Every + * other container on another package's object keeps the own-name arm, + * and a copy, like any container on another package's object, still + * declares no default. No expansion writes a name another package + * ships: the copy's names are its own package's, and the save door + * refuses a member whose name only another package ships + * ({@link viewContainerNameCollisionRefusal}). + * * Every expanded item carries the container's OWN package and, where that * package ships an artifact of the same name, that artifact's envelope — * never the envelope of an artifact another package ships. @@ -18556,15 +18580,15 @@ export class ObjectStackProtocolImplementation implements if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'view') return []; if (!isAggregatedViewContainer(data)) return []; const container = data as Record; - const viewObject = - (typeof container.object === 'string' && container.object ? container.object : undefined) - ?? container?.list?.data?.object - ?? container?.form?.data?.object - ?? (typeof container.name === 'string' ? container.name : undefined); + const viewObject = this.runtimeViewContainerObject(container); if (!viewObject) return []; const ownPackageId = this.runtimeViewContainerPackage(type, container, options); const crossPackage = this.isAnotherPackagesObject(viewObject, ownPackageId); - const expanded: ReadonlyArray> = crossPackage + // [#21980] A copy of its own package's shipped container takes the + // loaders' names, not the own-name arm. + const underOwnName = crossPackage + && !this.copiesOwnShippedViewContainer(type, viewObject, container, ownPackageId); + const expanded: ReadonlyArray> = underOwnName ? this.expandUnderOwnName(type, viewObject, container, ownPackageId) : (expandViewContainer(viewObject, container) as unknown as Record[]); const out: Record[] = []; @@ -18594,6 +18618,54 @@ export class ObjectStackProtocolImplementation implements return out; } + /** + * [#13407] The object a runtime view container binds: its own top-level + * `object`, then `list.data.object`, `form.data.object`, and its own + * `name`. See {@link expandRuntimeViewContainer}'s "Object-name + * derivation". + */ + private runtimeViewContainerObject(container: Record): string | undefined { + return (typeof container.object === 'string' && container.object ? container.object : undefined) + ?? container?.list?.data?.object + ?? container?.form?.data?.object + ?? (typeof container.name === 'string' ? container.name : undefined); + } + + /** + * [#21639, #21980] The view container `packageId` ships under `name`: the + * registry's artifact read ({@link lookupArtifactItem}) in that package, + * kept only when it is a container and that package's own, since the read + * falls back to another package's artifact of the name. `undefined` for + * no package, and where the package ships no container of the name. + */ + private shippedViewContainerOf( + type: string, + name: unknown, + packageId: string | undefined, + ): Record | undefined { + if (packageId === undefined || typeof name !== 'string' || name === '') return undefined; + const shipped = this.lookupArtifactItem(type, name, packageId) as Record | undefined; + return isAggregatedViewContainer(shipped) && shipped?._packageId === packageId ? shipped : undefined; + } + + /** + * [#21980] True when `container`, bound to `object`, is a stored copy of a + * container its own package ships: `ownPackageId` ships a view container + * under the copy's name ({@link shippedViewContainerOf}), bound to the + * same object. The loaders registered that container's views under + * `.` for that package, so the copy's names are that + * package's, and the copy expands to them. + */ + private copiesOwnShippedViewContainer( + type: string, + object: string, + container: Record, + ownPackageId: string | undefined, + ): boolean { + const shipped = this.shippedViewContainerOf(type, container.name, ownPackageId); + return shipped !== undefined && this.runtimeViewContainerObject(shipped) === object; + } + /** * [#21334] The package a runtime view container row belongs to: the * package its row is bound to, else — for a package-less row that is the @@ -18649,7 +18721,9 @@ export class ObjectStackProtocolImplementation implements /** * [#21334] Expand a container on another package's object under its own - * name. The spec's expander runs with `.` as its + * name: every such container but a stored copy of a container its own + * package ships, which takes the loaders' names ([#21980], + * {@link copiesOwnShippedViewContainer}). The spec's expander runs with `.` as its * base, so every member it knows — today a named `list`, `listViews`, * `formViews`, `form` — comes out as `..`, * de-duplicated by the spec's own rule, and a member kind the spec adds @@ -19651,9 +19725,8 @@ export class ObjectStackProtocolImplementation implements packageId: string | null | undefined, ): ReadonlySet { const ownPackageId = this.runtimeViewContainerPackage(type, container, { packageId }); - if (ownPackageId === undefined || typeof container.name !== 'string') return new Set(); - const shipped = this.lookupArtifactItem(type, container.name, ownPackageId) as Record | undefined; - if (!isAggregatedViewContainer(shipped) || shipped?._packageId !== ownPackageId) return new Set(); + const shipped = this.shippedViewContainerOf(type, container.name, ownPackageId); + if (shipped === undefined) return new Set(); return new Set( this.expandRuntimeViewContainer(type, shipped, { packageId: ownPackageId }).map((view) => String(view.name)), ); From 2322b5bb04ed45b3089b9d8dfd9726530a9d78d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 16:37:34 +0000 Subject: [PATCH 3/5] fix(metadata-protocol): the unscoped registry hydration never registers a view expansion under a name another package ships The maintainer's ruling on OQ1 (A). On an unscoped kernel, hydrateExpandedViewItems registered every expansion of a stored view container under the bare name, and SchemaRegistry.getItem answers the bare slot ahead of any package's own entry. So the by-name read naming another package that ships the same name served this container's view, while the list served that package's own item in its slot. It predates the copy's new names (two packages ship one container, one stores a copy), and the copy's new names reached it on another package's object. An expansion whose name another package ships (shippedArtifactsOf) is no longer registered there. Every kernel's by-name read answers it from its stored row ahead of the registry (resolveRowlessExpandedView), and the list expands the row itself. Pins: block (f)'s owner reads on the unscoped kernel are green, and block (g) pins the earlier case: two packages ship the container, either one stores a copy, and the by-name read naming each package answers that package's own item on both kernels. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi --- .../protocol.org-scoped-write-refused.test.ts | 33 +++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 21 ++++++++++++ 2 files changed, 54 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 9850795b425..ee337095a29 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -1728,5 +1728,38 @@ describe('each package\'s copy of a view container expands into its own package\ } } } + + // Two packages ship the container on an object no code package owns, + // and only one of them stores a copy. On an unscoped kernel the copy's + // expansion used to be registered under the bare name, which the + // registry answers ahead of either package's own entry, so the by-name + // read naming the OTHER package served the copy while the list served + // that package's own item. Each package's by-name read answers its own + // item, on both kernels, whichever package stores the copy. + describe('(g) two packages ship the container and one stores a copy: the by-name read naming each package answers its own item', () => { + const PACKAGES = [OTHER, COPYING] as const; + for (const [kernel, environmentId] of KERNELS) { + for (const m of MEMBERS) { + for (const copying of PACKAGES) { + it(`${kernel}; ${m.member}; ${copying} stores the copy`, async () => { + const copyTitle = `Intake (${copying} copy)`; + const shipped = PACKAGES.map((pkg): [string, Record] => + [pkg, { object: 'task', ...m.body(SHIPPED_TITLE(pkg), true, SLUG) }]); + const { protocol } = harness(shipped, environmentId); + expect((await protocol.saveMetaItem({ + type: 'view', name: 'task', packageId: copying, + item: { name: 'task', object: 'task', ...m.body(copyTitle, false, SLUG) }, + })).success).toBe(true); + const [name] = loaderNames(m.body('x', true, SLUG)); + for (const pkg of PACKAGES) { + const own = pkg === copying ? copyTitle : SHIPPED_TITLE(pkg); + expect(await byNameTitle(protocol, name, pkg), `the by-name read of ${name} naming ${pkg}`).toBe(own); + expect(await slotTitles(protocol, name, pkg), `${pkg}'s slot of ${name}`).toEqual([own]); + } + }); + } + } + } + }); }); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 269a8e6bb4f..400cd431ef8 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -18859,6 +18859,21 @@ export class ObjectStackProtocolImplementation implements * own `code` layer (for a package-less container too, through the * runtime-only `getItem` arm), where `env_local`, which registers nothing, * reported neither. With the marker both kernels give one answer. + * + * ## [#21980] Never under a name another package ships + * + * The registry keeps one bare slot per name, and `SchemaRegistry.getItem` + * answers it before any package's own entry, whichever package the read + * names. So an expansion registered there under a name another package + * also ships answered THAT package's by-name read on an unscoped kernel: + * `getMetaItem` naming the other package fell through to the registry and + * served this container's view, while the list served the other package's + * own item in its slot ({@link servedViewExpansion}). Such an expansion is + * not registered. Every kernel's by-name read already answers it from its + * stored row, ahead of the registry ({@link resolveRowlessExpandedView}), + * for its own package and for a read that names none, and the list expands + * the row itself. A name that only the container's own package ships, or + * that no package ships, is registered as before. */ private hydrateExpandedViewItems( type: string, @@ -18866,7 +18881,13 @@ export class ObjectStackProtocolImplementation implements options: { packageId?: string | null; organizationId: string | null }, registry: any, ): void { + let shipping: ShippingPackages | undefined; for (const item of this.expandRuntimeViewContainer(type, data, { ...options, tenantAuthored: true })) { + shipping ??= this.shippingPackagesOf(type); + const own = item._packageId; + const anotherShips = this.shippedArtifactsOf(type, String(item.name), shipping) + .some((artifact) => (artifact as { _packageId?: unknown })._packageId !== own); + if (anotherShips) continue; registry.registerItem(type, item, 'name' as any); } } From 7a799e8c7edc62a9c99ee935acf1810576b19076 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 16:41:44 +0000 Subject: [PATCH 4/5] test(metadata-protocol): the by-name read naming no package answers a name two packages ship, on both kernels The ruling's condition on the hydration line: for a name two packages ship, once one of them stores a copy, the by-name read that names no package answers on both kernels (never an absence), both kernels answer the same body, and that body is one the env-wide list serves under the name. Block (h) runs it for each member, with the object owned by the other package or by none, in both registry orders. Measured before writing it, over base aa09db58c9, the direction b7a2a8f547 and 2322b5bb04: no read answered nothing anywhere, and the hydration line leaves this read unchanged on both kernels. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi --- .../protocol.org-scoped-write-refused.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index ee337095a29..02075cacb21 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -1761,5 +1761,43 @@ describe('each package\'s copy of a view container expands into its own package\ } } }); + + // The condition on the hydration line above: the by-name read that + // names NO package is the generic reader of a hydrated bare entry. For + // a name two packages ship, after one of them stores a copy, it answers + // on both kernels, never an absence; both kernels answer the same body; + // and that body is one the env-wide list serves under the name. Which + // package's body it is stays ADR-0048's ambiguous case (two packages + // ship one name): it is the copy's, the last expansion of the name, as + // the read naming no package selects (servedViewExpansion). + describe('(h) a name two packages ship: the by-name read naming no package answers on both kernels, with the same body', () => { + const ORDERS = [[OTHER, COPYING], [COPYING, OTHER]] as const; + for (const m of MEMBERS) { + for (const owner of [OTHER, undefined] as const) { + for (const order of ORDERS) { + const ownership = owner ? 'another package owns the object' : 'no code package owns the object'; + it(`${m.member}; ${ownership}; ${order[0]} registered first`, async () => { + const [name] = loaderNames(m.body('x', true, SLUG)); + const answers: unknown[] = []; + for (const [kernel, environmentId] of KERNELS) { + const shipped = order.map((pkg): [string, Record] => + [pkg, { object: 'task', ...m.body(SHIPPED_TITLE(pkg), true, pkg === COPYING ? SLUG : OTHER_SLUG) }]); + const { protocol } = harness(shipped, environmentId, owner); + const placement = PLACEMENTS.find((q) => q.m === m && q.owner === owner && q.ships); + if (!placement) throw new Error(`no placement for ${ownership}, ${m.member}`); + await saveCopyOf(protocol, placement, false); + const item = (await protocol.getMetaItem({ type: 'view', name })).item; + expect(item, `${kernel}: the read naming no package answers ${name}`).toBeTruthy(); + const envWide: any = await protocol.getMetaItems({ type: 'view' }); + const listed = (envWide.items as any[]).filter((v) => v?.name === name).map(titleOf); + expect(listed, `${kernel}: a body the env-wide list serves under ${name}`).toContain(titleOf(item)); + answers.push(titleOf(item)); + } + expect(answers[1], 'the unscoped kernel answers what the environment-scoped kernel answers').toBe(answers[0]); + }); + } + } + } + }); }); }); From bcd4915012821949b96c6a2b95f4641a2d7797d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 16:42:36 +0000 Subject: [PATCH 5/5] fix(metadata-protocol)!: a package's stored copy of a container it ships overlays its shipped views; the save door refuses three copies it accepted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset for this card, `@objectstack/metadata-protocol` minor, carrying `Clause-②: no (narrowing)` per the maintainer's ruling on OQ2. 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. Judged at its new names, the save door now refuses three copies it accepted before: an added bare list or keyed member whose name only another package ships, and a copy expanding a name another stored container already expands. The BREAKING line names the three shapes and their remedy, and the ADR-0087 marker is not-required (no-migration-prescription), stating that no census of the writers of such copies was taken. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi --- .../21980-copy-overlays-shipped-names.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .changeset/21980-copy-overlays-shipped-names.md diff --git a/.changeset/21980-copy-overlays-shipped-names.md b/.changeset/21980-copy-overlays-shipped-names.md new file mode 100644 index 00000000000..f3ef8468e20 --- /dev/null +++ b/.changeset/21980-copy-overlays-shipped-names.md @@ -0,0 +1,27 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +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 + +Clause-②: no (narrowing) + + + +**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. + +- **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. +- **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. +- **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. + +**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: + +- 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`. +- 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`. +- Another stored container, saved under a different name, already expands a name the copy now expands. Before: accepted. After: refused, naming that stored container. + +**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. + +**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. + +**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.