From 00b04da6156819200f81b423debf12d86ebcb94b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:14:21 +0000 Subject: [PATCH 01/11] fix(metadata-protocol): a bare-list view container on another package's object expands under its own name On an object a code package owns, a runtime view container that belongs to another package (or to none) expanded its bare `list` to `.default` and replaced that package's default view on the object door, wearing the shadowed artifact's `_packageId` and protection. It now expands under the container's own name, `.`, and every expanded item carries the container's own package and only that package's artifact envelope. A container of the object's own package still expands to `.default`. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 151 +++++++++++++++++++-- 1 file changed, 141 insertions(+), 10 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 3d303b58b49..ffd20aa0ef5 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -8363,6 +8363,13 @@ export class ObjectStackProtocolImplementation implements // through), and re-deriving here from the row this call just // read is a byte-identical, never-stale restatement of the // same items — not a duplicate. + // + // [#21334] An upsert by name replaces whatever the merge seated + // under that name, so the name has to be one the container may + // write. {@link expandRuntimeViewContainer} decides it: on + // another package's object a bare `list` expands under the + // container's own name, never over that package's + // `.default`. if (isView) { const byName = new Map(); for (const it of items as any[]) { @@ -16131,6 +16138,35 @@ export class ObjectStackProtocolImplementation implements * expanded under the WRONG key or not at all. The three-deep fallback is * kept, unchanged, for every container written before this field was * consulted here. + * + * ## A bare `list` on ANOTHER package's object (#21334) + * + * The spec names a bare `list` (one that names no key) `.default`. + * For a container that belongs to the object's own package that is the + * object's default view, and it still expands there. For a container + * saved under any other name, in another package or in none, on an object + * a code package owns, `.default` is the OTHER package's item: + * ADR-0005 keys an overlay by its own name, and ADR-0126 rules out a + * silent override, so a row named `x` may not replace an item named + * `.default`. Both callers set each expansion by name — the list's + * inline pass over the merged items, the registry's bare key — so the + * expansion used to replace the packaged default on the object door + * (and, on an unscoped kernel, on the by-name read too), wearing the + * shadowed artifact's `_packageId` and protection. + * + * So, on another package's object, the bare `list` expands under the + * container's own name: `.`, the spec's own + * spelling of a `list` that names its key. The qualified form is the one + * the spec accepts for a ViewItem (`ViewItemNameSchema`); the flat + * container name on an expanded item is refused there, so the list door + * would serve it badged invalid. The view container contract + * (`view.zod.ts`, ADR-0017 §3.2) names the container after its object and + * states no arm for a name another package owns, which is why the arm + * taken is the container's own name rather than a refusal at save. + * + * 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. */ private expandRuntimeViewContainer( type: string, @@ -16146,25 +16182,120 @@ export class ObjectStackProtocolImplementation implements ?? container?.form?.data?.object ?? (typeof container.name === 'string' ? container.name : undefined); if (!viewObject) return []; + const ownPackageId = this.runtimeViewContainerPackage(type, container, options); + const ownKey = this.isAnotherPackagesObject(viewObject, ownPackageId) + ? this.bareListOwnKey(container) + : undefined; const out: Record[] = []; - for (const vi of expandViewContainer(viewObject, container)) { + for (const vi of expandViewContainer(viewObject, this.withBareListKey(container, ownKey))) { // Carry the container's package provenance onto each expanded item // so the package-disable filter and ADR-0048 artifact scoping judge // them by the same owner the container has. const item: Record = { ...(vi as any) }; - if (container._packageId !== undefined && item._packageId === undefined) { - item._packageId = container._packageId; - } - const viArtifact = this.lookupArtifactItem( - type, - vi.name, - (item._packageId as string | undefined) ?? options.packageId ?? undefined, - ); - out.push(mergeArtifactProtection(item, viArtifact) as Record); + if (typeof ownKey === 'string') this.restoreAuthoredBareList(item, viewObject, ownKey); + if (ownPackageId !== undefined) item._packageId = ownPackageId; + // [#21334] Only the container's own package's artifact lends its + // envelope; another package's artifact of this name is not this + // item's to wear. + const viArtifact = ownPackageId === undefined + ? undefined + : this.lookupArtifactItem(type, vi.name, ownPackageId); + const ownArtifact = (viArtifact as { _packageId?: unknown } | undefined)?._packageId === ownPackageId + ? viArtifact + : undefined; + out.push(mergeArtifactProtection(item, ownArtifact) as Record); } return out; } + /** + * [#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 + * name-keyed overlay of a packaged item (ADR-0005), such as a tenant's + * overlay of a package's `` container — the package of the + * artifact it overlays, which is the slot the package-aware merge seats + * it in. `undefined` for a package-less row that overlays nothing. + */ + private runtimeViewContainerPackage( + type: string, + container: Record, + options: { packageId?: string | null }, + ): string | undefined { + for (const bound of [container._packageId, options.packageId]) { + if (typeof bound === 'string' && bound !== '' && bound !== 'sys_metadata') return bound; + } + if (typeof container.name !== 'string' || container.name === '') return undefined; + const overlaid = (this.lookupArtifactItem(type, container.name) as { _packageId?: unknown } | undefined) + ?._packageId; + return typeof overlaid === 'string' && overlaid !== '' ? overlaid : undefined; + } + + /** + * [#21334] True when a code package owns `object` and it is not + * `ownPackageId`. The discriminator is `getPackagedObjectOwner`, the same + * "does a code package ship this?" test {@link classifyObjectContribution} + * asks. A runtime-authored object has no packaged owner, so a container on + * one keeps today's `.default`; so does a registry that cannot + * answer. + */ + private isAnotherPackagesObject(object: string, ownPackageId: string | undefined): boolean { + const registry: any = (this.engine as any)?.registry; + const owner = typeof registry?.getPackagedObjectOwner === 'function' + ? registry.getPackagedObjectOwner(object) + : undefined; + const ownerPackageId: unknown = owner?.packageId; + return typeof ownerPackageId === 'string' && ownerPackageId !== '' && ownerPackageId !== ownPackageId; + } + + /** + * [#21334] The key a container's bare `list` takes when it may not take + * `default`: the container's own name. `undefined` when the container has + * no bare `list` (none, or one that names its own key, which already + * expands under the name its author gave it). `null` when it has one but no + * name of its own to expand under, so the bare `list` is not expanded at + * all rather than under another package's name. + */ + private bareListOwnKey(container: Record): string | null | undefined { + const list = container.list; + if (!list || typeof list !== 'object') return undefined; + if (typeof list.name === 'string' && list.name !== '') return undefined; + return typeof container.name === 'string' && container.name !== '' ? container.name : null; + } + + /** + * [#21334] The container the spec expands: unchanged when `ownKey` is + * `undefined`; its bare `list` naming `ownKey`, so the spec's own key rule + * (and its in-container de-duplication) spells `.`; or, for + * `null`, without the bare `list`. A shallow copy — the caller's container + * is never mutated. + */ + private withBareListKey( + container: Record, + ownKey: string | null | undefined, + ): Record { + if (ownKey === undefined) return container; + if (ownKey === null) return { ...container, list: undefined }; + return { ...container, list: { ...container.list, name: ownKey } }; + } + + /** + * [#21334] Take back the `name` {@link withBareListKey} lent the bare + * `list`, so the expanded item's `config` is the list as authored. Matches + * only the item that key produced: a `list` item whose `config.name` is + * `ownKey` and whose name is `.` or the spec's + * de-duplicated `._N`. + */ + private restoreAuthoredBareList(item: Record, object: string, ownKey: string): void { + const config = item.config as Record | undefined; + if (item.viewKind !== 'list' || !config || config.name !== ownKey) return; + const requested = `${object}.${ownKey}`; + const name = String(item.name); + if (!name.startsWith(requested) || !/^(_\d+)?$/.test(name.slice(requested.length))) return; + const authored = { ...config }; + delete authored.name; + item.config = authored; + } + /** * [#7736] Register an aggregated `defineView` container's expansion * ({@link expandRuntimeViewContainer}) into the SchemaRegistry — at the From 63f50205248ebba21832350684d94ac0c7779f98 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:17:25 +0000 Subject: [PATCH 02/11] test(metadata-protocol): pin a cross-package bare-list container against the packaged default Both read doors answer the packaged `.default` after a package-scoped, an environment-wide and an organization-scoped container is saved on another package's object, on an environment-scoped and an unscoped kernel; the container's own view is served as `.` with its own package. Controls: a container of the object's own package, a package-less overlay of the package's own container, and the sanctioned by-name override. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- .../view-container-runtime-expansion.test.ts | 261 +++++++++++++++++- 1 file changed, 260 insertions(+), 1 deletion(-) diff --git a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts index 52f810d47b2..71bd9afd11f 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -28,7 +28,8 @@ * choke point all three runtime hydration callers share. */ import { describe, expect, it } from 'vitest'; -import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; +import { expandViewContainer } from '@objectstack/spec/ui'; import { ObjectStackProtocolImplementation } from './index.js'; interface Row { @@ -395,3 +396,261 @@ describe('#13407 org-scoped and environment-scoped runtime containers are served }); }); }); + +/** + * #21334 — a bare-`list` container on ANOTHER package's object must not take + * that package's `.default`. + * + * The spec names a bare `list` `.default`. A container saved under any + * other name — in another package, or in none — for an object a code package + * ships used to expand there and REPLACE the packaged default on the object + * door (`GET /meta/view?object=`), wearing the shadowed artifact's `_packageId`, + * while the by-name read kept the packaged item on an environment-scoped kernel + * (and served the shadow too on an unscoped one, through the registry's bare + * key). ADR-0005 keys an overlay by its own name and ADR-0126 rules out a silent + * override; the ruling takes the arm "expand under the container's own name". + * + * The registry double here is the real `SchemaRegistry`'s shape where this card + * turns on it: loader entries under `:`, a hydrated row under + * the bare name, `getItem`'s bare-slot-first precedence, `getArtifactItem`'s + * package-scoped code-artifact lookup, and `getPackagedObjectOwner`. The + * packaged views are produced by the spec's own `expandViewContainer`, as the + * source registrars produce them. The cold-boot proof over the real showcase + * composition, through the REST doors, is + * `view-container-cross-package-default.dogfood.test.ts`. + */ +describe('#21334 a bare-list container on another package\'s object never takes that package\'s .default', () => { + const SHOWCASE = 'com.example.showcase'; + const REPAIR = 'com.example.repairassets'; + const TASK = 'showcase_task'; + const DEFAULT = `${TASK}.default`; + const ORG = 'org_acme'; + const data = { provider: 'object', object: TASK }; + const PACKAGED_COLUMNS = ['title', 'project', 'assignee', 'status', 'priority', 'due_date', 'progress'] + .map((field) => ({ field })); + /** What the showcase ships: a `defineView` container with a bare default list and one keyed view. */ + const packagedTaskViews = { + list: { label: 'All Tasks', type: 'grid', data, columns: PACKAGED_COLUMNS }, + listViews: { + in_progress: { label: 'In Progress', type: 'grid', data, columns: PACKAGED_COLUMNS.slice(0, 4) }, + }, + }; + /** The card's own probe body, verbatim. */ + const probe = (name: string) => ({ name, object: TASK, list: { type: 'grid', columns: ['title', 'status'] } }); + + function faithfulRegistry() { + const byType = new Map>>(); + const objects = new Map }>(); + const collection = (type: string) => { + if (!byType.has(type)) byType.set(type, new Map()); + return byType.get(type)!; + }; + return { + registerItem(type: string, item: Record, keyField = 'name', packageId?: string) { + const name = String(item[keyField]); + if (packageId) { + if (item._packageId === undefined) item._packageId = packageId; + if (item._provenance === undefined) item._provenance = 'package'; + collection(type).set(`${packageId}:${name}`, item); + } else { + collection(type).set(name, item); + } + }, + listItems(type: string, packageId?: string) { + const all = [...(byType.get(type)?.values() ?? [])]; + return packageId ? all.filter((it) => it._packageId === packageId) : all; + }, + getItem(type: string, name: string, packageId?: string) { + const entries = byType.get(type); + if (!entries) return undefined; + const direct = entries.get(name); + if (direct) return direct; + if (packageId) { + const local = entries.get(`${packageId}:${name}`); + if (local) return local; + } + for (const [key, item] of entries) if (key.endsWith(`:${name}`)) return item; + return undefined; + }, + getArtifactItem(type: string, name: string, packageId?: string) { + const entries = [...(byType.get(type)?.entries() ?? [])]; + const scoped = entries.filter(([key, it]) => key.endsWith(`:${name}`) && isCodeArtifactBody(it)); + const local = packageId ? scoped.find(([, it]) => it._packageId === packageId) : undefined; + if (local) return local[1]; + if (scoped[0]) return scoped[0][1]; + const bare = byType.get(type)?.get(name); + return bare && isCodeArtifactBody(bare) ? bare : undefined; + }, + shipObject(name: string, packageId: string) { + objects.set(name, { packageId, ownership: 'own', definition: { name, _packageId: packageId } }); + }, + getPackagedObjectOwner: (name: string) => objects.get(name), + getObject: (name: string) => objects.get(name)?.definition, + registerObject: () => undefined, + getPackage: () => undefined, + isPackageDisabled: () => false, + isObjectPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }; + } + + /** The stub engine above, with the showcase's packaged task views in a faithful registry. */ + function showcaseHarness(environmentId?: string) { + const stub = makeStubEngine(); + const registry = faithfulRegistry(); + registry.shipObject(TASK, SHOWCASE); + registry.registerItem('view', { ...packagedTaskViews, name: TASK }, 'name', SHOWCASE); + for (const vi of expandViewContainer(TASK, packagedTaskViews)) { + registry.registerItem('view', { ...(vi as any) }, 'name', SHOWCASE); + } + stub.engine.registry = registry; + const protocol = new ObjectStackProtocolImplementation(stub.engine, undefined, environmentId); + return { ...stub, registry, protocol }; + } + + type Protocol = ObjectStackProtocolImplementation; + const scoped = (organizationId?: string) => (organizationId ? { organizationId } : {}); + const objectDoor = async (protocol: Protocol, organizationId?: string) => + switcherMatches(((await protocol.getMetaItems({ type: 'view', ...scoped(organizationId) } as any)) as any).items, TASK); + const byNameDoor = async (protocol: Protocol, name: string, organizationId?: string) => + ((await protocol.getMetaItem({ type: 'view', name, ...scoped(organizationId) } as any)) as any).item; + const named = (items: any[], name: string) => items.filter((v) => v.name === name); + /** The packaged default, as the source registrar registered it. */ + const expectPackagedDefault = (v: any) => { + expect(v?.label).toBe('All Tasks'); + expect(v?.config?.columns).toEqual(PACKAGED_COLUMNS); + expect(v?._packageId).toBe(SHOWCASE); + expect(v?.isDefault).toBe(true); + }; + + const KERNELS = [ + ['an environment-scoped kernel (the standalone stack stamps env_local)', 'env_local'], + ['an unscoped kernel (write-through hydrates the registry)', undefined], + ] as const; + const CONTAINERS = [ + { arm: 'package-scoped (saved into another writable package)', name: 'os_qa_shadow_probe', packageId: REPAIR, organizationId: undefined, ownPackage: REPAIR }, + { arm: 'package-less, environment-wide', name: 'os_qa_shadow_probe2', packageId: undefined, organizationId: undefined, ownPackage: undefined }, + { arm: 'package-less, organization-scoped', name: 'os_qa_shadow_probe3', packageId: undefined, organizationId: ORG, ownPackage: undefined }, + ] as const; + + for (const [kernel, environmentId] of KERNELS) { + describe(`on ${kernel}`, () => { + for (const c of CONTAINERS) { + it(`${c.arm}: the packaged default is unchanged on BOTH doors, and they answer the same row`, async () => { + const { protocol } = showcaseHarness(environmentId); + expectPackagedDefault(named(await objectDoor(protocol, c.organizationId), DEFAULT)[0]); + + await protocol.saveMetaItem({ + type: 'view', name: c.name, item: probe(c.name), + ...(c.packageId ? { packageId: c.packageId } : {}), + ...scoped(c.organizationId), + } as any); + + const listed = named(await objectDoor(protocol, c.organizationId), DEFAULT); + expect(listed, 'exactly one item answers .default on the object door').toHaveLength(1); + expectPackagedDefault(listed[0]); + const read = await byNameDoor(protocol, DEFAULT, c.organizationId); + expectPackagedDefault(read); + expect({ label: read.label, config: read.config, _packageId: read._packageId }) + .toEqual({ label: listed[0].label, config: listed[0].config, _packageId: listed[0]._packageId }); + }); + + it(`${c.arm}: the container's own view is served under its own name, carrying its own package`, async () => { + const { protocol } = showcaseHarness(environmentId); + await protocol.saveMetaItem({ + type: 'view', name: c.name, item: probe(c.name), + ...(c.packageId ? { packageId: c.packageId } : {}), + ...scoped(c.organizationId), + } as any); + + const own = named(await objectDoor(protocol, c.organizationId), `${TASK}.${c.name}`); + expect(own).toHaveLength(1); + expect(own[0].viewKind).toBe('list'); + expect(own[0].config, 'the list as authored, nothing lent left on it').toEqual(probe(c.name).list); + expect(own[0]._packageId).toBe(c.ownPackage); + expect(own[0]._provenance, 'never the packaged artifact\'s provenance').not.toBe('package'); + expect(own[0]._diagnostics?.valid, 'a qualified ViewItem name the spec accepts').toBe(true); + + // The by-name door answers the container's own name with its row. + const row = await byNameDoor(protocol, c.name, c.organizationId); + expect(row.list).toEqual(probe(c.name).list); + }); + } + + it('CONTROL — a container of the object\'s OWN package still expands to .default', async () => { + const { protocol, rows } = showcaseHarness(environmentId); + // A row bound to the package that owns the object (an installed + // package's own stored view), written straight to the store. + rows.set('own-pkg-row', { + id: 'r_own', type: 'view', name: 'os_qa_same_pkg', organization_id: null, + package_id: SHOWCASE, state: 'active', metadata: JSON.stringify(probe('os_qa_same_pkg')), + }); + + const listed = named(await objectDoor(protocol), DEFAULT); + expect(listed).toHaveLength(1); + expect(listed[0].config).toEqual(probe('os_qa_same_pkg').list); + expect(listed[0]._packageId).toBe(SHOWCASE); + expect(named(await objectDoor(protocol), `${TASK}.os_qa_same_pkg`)).toEqual([]); + }); + + it('CONTROL — a package-less overlay OF the package\'s own container keeps expanding to .default', async () => { + const { protocol } = showcaseHarness(environmentId); + // Name-keyed (ADR-0005): the row IS the overlay of the showcase's + // `showcase_task` container, so it stands in that package's slot. + const overlay = { name: TASK, list: { label: 'Customized', type: 'grid', data, columns: [{ field: 'title' }] } }; + await protocol.saveMetaItem({ type: 'view', name: TASK, item: overlay } as any); + + const listed = named(await objectDoor(protocol), DEFAULT); + expect(listed).toHaveLength(1); + expect(listed[0].label).toBe('Customized'); + expect(listed[0]._packageId).toBe(SHOWCASE); + expect(named(await objectDoor(protocol), `${TASK}.${TASK}`)).toEqual([]); + }); + + it('CONTROL — the sanctioned override (a write to .default by name) is served on both doors', async () => { + const { protocol } = showcaseHarness(environmentId); + const override = { + name: DEFAULT, object: TASK, viewKind: 'list', label: 'Overridden', + config: { type: 'grid', data, columns: [{ field: 'title' }] }, + }; + await protocol.saveMetaItem({ type: 'view', name: DEFAULT, item: override } as any); + + const listed = named(await objectDoor(protocol), DEFAULT); + expect(listed).toHaveLength(1); + expect(listed[0].label).toBe('Overridden'); + expect((await byNameDoor(protocol, DEFAULT)).label).toBe('Overridden'); + }); + }); + } + + it('the bare list keeps the spec\'s in-container de-duplication when a keyed view already takes the container\'s name', async () => { + const { protocol } = showcaseHarness('env_local'); + const container = { + name: 'probe_x', object: TASK, + list: { type: 'grid', columns: ['title', 'status'] }, + listViews: { probe_x: { label: 'Keyed', type: 'grid', columns: ['title'] } }, + }; + await protocol.saveMetaItem({ type: 'view', name: 'probe_x', item: container, packageId: REPAIR } as any); + + const served = await objectDoor(protocol); + expect(named(served, `${TASK}.probe_x`)[0]?.label).toBe('Keyed'); + const bare = named(served, `${TASK}.probe_x_2`)[0]; + expect(bare?.config).toEqual(container.list); + expectPackagedDefault(named(served, DEFAULT)[0]); + expect(named(served, DEFAULT)).toHaveLength(1); + }); + + it('a stored container with no name of its own does not expand its bare list under another package\'s name', async () => { + const { protocol, rows } = showcaseHarness('env_local'); + const nameless = { object: TASK, list: { type: 'grid', columns: ['title'] }, listViews: { mine: { label: 'Mine', type: 'grid', columns: ['title'] } } }; + rows.set('nameless-row', { + id: 'r_nameless', type: 'view', name: 'os_qa_nameless', organization_id: null, + package_id: REPAIR, state: 'active', metadata: JSON.stringify(nameless), + }); + + const served = await objectDoor(protocol); + expect(named(served, DEFAULT)).toHaveLength(1); + expectPackagedDefault(named(served, DEFAULT)[0]); + expect(named(served, `${TASK}.mine`)[0]?._packageId).toBe(REPAIR); + }); +}); From 400815e9bbe2e0d0637b734f98be6145a2c4772f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:19:45 +0000 Subject: [PATCH 03/11] test(dogfood): the card's steps for a cross-package bare-list view container, over the real showcase Through the REST doors: a container saved into a Studio-created package and a package-less one, both for `showcase_task`, leave `showcase_task.default` unchanged on the object door and the by-name read; each container's own view is served as `showcase_task.`. Control: the by-name override of `showcase_task.default` reaches both doors. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- ...iner-cross-package-default.dogfood.test.ts | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts diff --git a/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts b/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts new file mode 100644 index 00000000000..694aacf326d --- /dev/null +++ b/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#21334, ADR-0005, ADR-0126] A runtime view container with a bare `list`, +// saved for an object another package ships, must not replace that package's +// `.default` on the object door — over the real showcase composition, +// through the REST doors, following the card's own steps. +// +// ## What was broken +// +// The spec names a bare `list` `.default`. A container saved under any +// other name (here `os_qa_shadow_probe`, in a Studio-created package, and +// `os_qa_shadow_probe2`, in none) for `showcase_task` expanded there, and the +// list read set the expansion by name over the merged items. The object door +// then answered `showcase_task.default` with the probe's two columns, still +// stamped `_packageId: com.example.showcase`. On the standalone stack (an +// environment-scoped kernel) the by-name read kept the packaged item, so the +// two doors disagreed; on this harness's unscoped kernel the registry's bare +// key carried the shadow too, so both doors served it, and it outlived the +// container's own delete. +// +// ## The ruling these cases pin (triage's) +// +// The expansion of a bare `list` never produces a name another package owns: +// on another package's object it expands under the container's own name — +// `.`, the spec's qualified ViewItem spelling. The two +// doors answer the same row for `.default`: the packaged one, +// unchanged. Controls: the sanctioned override, a write to +// `showcase_task.default` by name, still reaches both doors. +// +// The same-package control (a container of the object's own package still +// expands to `.default`) and the environment-scoped topology are pinned +// in-process, in `packages/metadata-protocol/src/view-container-runtime-expansion.test.ts`. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const OBJECT = 'showcase_task'; +const DEFAULT = `${OBJECT}.default`; +const SHOWCASE = 'com.example.showcase'; +const REPAIR = 'com.example.repairassets'; +const PROBE_COLUMNS = ['title', 'status']; +const probe = (name: string) => ({ name, object: OBJECT, list: { type: 'grid', columns: PROBE_COLUMNS } }); + +interface ViewRow { + name: string; + label?: unknown; + isDefault?: boolean; + _packageId?: string; + _provenance?: string; + config?: { columns?: unknown[] }; + list?: unknown; +} + +describe('dogfood: a bare-list container on another package\'s object leaves its .default alone (#21334)', () => { + let stack: VerifyStack; + let token: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + token = await stack.signIn(); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + /** `GET /api/v1/meta/view?object=showcase_task` — the object door. */ + const objectDoor = async (): Promise => { + const res = await stack.apiAs(token, 'GET', `/meta/view?object=${OBJECT}`); + expect(res.status).toBe(200); + const body = (await res.json()) as { items?: ViewRow[] } | ViewRow[]; + return Array.isArray(body) ? body : (body.items ?? []); + }; + /** `GET /api/v1/meta/view/:name` — the by-name read. */ + const byName = async (name: string): Promise => { + const res = await stack.apiAs(token, 'GET', `/meta/view/${name}`); + expect(res.status).toBe(200); + const body = (await res.json()) as { item?: ViewRow } & ViewRow; + return body.item ?? body; + }; + const named = (rows: ViewRow[], name: string) => rows.filter((r) => r.name === name); + + /** The packaged default, as the showcase ships it (`src/ui/views/task.view.ts`). */ + const expectPackagedDefault = (row: ViewRow | undefined) => { + expect(row?.label).toBe('All Tasks'); + expect(row?.config?.columns).toHaveLength(7); + expect(row?._packageId).toBe(SHOWCASE); + expect(row?.isDefault).toBe(true); + }; + /** Both doors answer exactly one, packaged, identical `showcase_task.default`. */ + const expectDefaultUnchangedOnBothDoors = async () => { + const listed = named(await objectDoor(), DEFAULT); + expect(listed, 'exactly one item answers showcase_task.default on the object door').toHaveLength(1); + expectPackagedDefault(listed[0]); + const read = await byName(DEFAULT); + expectPackagedDefault(read); + expect({ label: read.label, config: read.config, _packageId: read._packageId }) + .toEqual({ label: listed[0].label, config: listed[0].config, _packageId: listed[0]._packageId }); + }; + + it('steps 1–7: a container saved into another package never replaces the packaged default, on either door', async () => { + // 1. Baseline. + await expectDefaultUnchangedOnBothDoors(); + + // 2. A Studio-created package to author into. + const created = await stack.apiAs(token, 'POST', '/packages', { + manifest: { id: REPAIR, name: 'Repair assets', version: '0.1.0', type: 'app', namespace: 'repair' }, + }); + expect(created.status).toBe(201); + + // 3. The card's probe, a bare-list container for showcase_task, as a draft. + const saved = await stack.apiAs( + token, 'PUT', `/meta/view/os_qa_shadow_probe?mode=draft&package=${REPAIR}`, probe('os_qa_shadow_probe'), + ); + expect(saved.status).toBe(200); + + // 4. Publish it. + const published = await stack.apiAs(token, 'POST', `/packages/${REPAIR}/publish-drafts`, {}); + expect(published.status).toBe(200); + const receipt = (await published.json()) as { data?: { outcome?: string; publishedCount?: number } }; + expect(receipt.data?.outcome).toBe('published'); + expect(receipt.data?.publishedCount).toBe(1); + + // 5 + 6. The packaged default is unchanged on BOTH doors, and they answer + // the same row; the probe's own view is served under its own name. + await expectDefaultUnchangedOnBothDoors(); + const own = named(await objectDoor(), `${OBJECT}.os_qa_shadow_probe`); + expect(own).toHaveLength(1); + expect(own[0].config?.columns).toEqual(PROBE_COLUMNS); + expect(own[0]._packageId).toBe(REPAIR); + expect(own[0]._provenance).not.toBe('package'); + // The by-name read answers the container's own name with its row. + expect((await byName('os_qa_shadow_probe')).list).toEqual(probe('os_qa_shadow_probe').list); + + // 7. Delete the probe: the packaged default is still what both doors answer. + const deleted = await stack.apiAs(token, 'DELETE', `/meta/view/os_qa_shadow_probe?package=${REPAIR}`); + expect(deleted.status).toBe(200); + await expectDefaultUnchangedOnBothDoors(); + }); + + it('step 8: the same with no package — a package-less container leaves the packaged default alone', async () => { + const saved = await stack.apiAs(token, 'PUT', '/meta/view/os_qa_shadow_probe2', probe('os_qa_shadow_probe2')); + expect(saved.status).toBe(200); + + await expectDefaultUnchangedOnBothDoors(); + const own = named(await objectDoor(), `${OBJECT}.os_qa_shadow_probe2`); + expect(own).toHaveLength(1); + expect(own[0].config?.columns).toEqual(PROBE_COLUMNS); + expect(own[0]._packageId, 'a package-less container lends its view no package').toBeUndefined(); + + const deleted = await stack.apiAs(token, 'DELETE', '/meta/view/os_qa_shadow_probe2'); + expect(deleted.status).toBe(200); + await expectDefaultUnchangedOnBothDoors(); + }); + + it('step 9 (control): the sanctioned override — a write to showcase_task.default by name — reaches both doors', async () => { + const saved = await stack.apiAs(token, 'PUT', `/meta/view/${DEFAULT}`, { + name: DEFAULT, + object: OBJECT, + viewKind: 'list', + label: 'Overridden', + config: { type: 'grid', columns: [{ field: 'title' }] }, + }); + expect(saved.status).toBe(200); + + const listed = named(await objectDoor(), DEFAULT); + expect(listed).toHaveLength(1); + expect(listed[0].label).toBe('Overridden'); + const read = await byName(DEFAULT); + expect(read.label).toBe('Overridden'); + expect(read.config).toEqual(listed[0].config); + }); +}); From 5175532f0be9cb519db6a88b494965ccb0452e15 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:22:25 +0000 Subject: [PATCH 04/11] test(dogfood): run the by-name override control first, on the pristine stack The control shared its stack with the shadow cases, so under a reverted fix the shadow the earlier cases left in the registry decided it. Run first and undone by its own delete, it reads nothing another case wrote. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- ...iner-cross-package-default.dogfood.test.ts | 47 +++++++++++-------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts b/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts index 694aacf326d..258a480378e 100644 --- a/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts +++ b/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts @@ -24,8 +24,9 @@ // on another package's object it expands under the container's own name — // `.`, the spec's qualified ViewItem spelling. The two // doors answer the same row for `.default`: the packaged one, -// unchanged. Controls: the sanctioned override, a write to -// `showcase_task.default` by name, still reaches both doors. +// unchanged. Control: the sanctioned override, a write to +// `showcase_task.default` by name, still reaches both doors — run FIRST, on +// the pristine stack, so it never reads another case's residue. // // The same-package control (a container of the object's own package still // expands to `.default`) and the environment-scoped topology are pinned @@ -99,6 +100,30 @@ describe('dogfood: a bare-list container on another package\'s object leaves its .toEqual({ label: listed[0].label, config: listed[0].config, _packageId: listed[0]._packageId }); }; + it('control, first: the sanctioned override — a write to showcase_task.default by name — reaches both doors', async () => { + // First, on the pristine stack, so no other case's residue can decide it. + const saved = await stack.apiAs(token, 'PUT', `/meta/view/${DEFAULT}`, { + name: DEFAULT, + object: OBJECT, + viewKind: 'list', + label: 'Overridden', + config: { type: 'grid', columns: [{ field: 'title' }] }, + }); + expect(saved.status).toBe(200); + + const listed = named(await objectDoor(), DEFAULT); + expect(listed).toHaveLength(1); + expect(listed[0].label).toBe('Overridden'); + const read = await byName(DEFAULT); + expect(read.label).toBe('Overridden'); + expect(read.config).toEqual(listed[0].config); + + // Deleting the override hands both doors back the packaged default. + const deleted = await stack.apiAs(token, 'DELETE', `/meta/view/${DEFAULT}`); + expect(deleted.status).toBe(200); + await expectDefaultUnchangedOnBothDoors(); + }); + it('steps 1–7: a container saved into another package never replaces the packaged default, on either door', async () => { // 1. Baseline. await expectDefaultUnchangedOnBothDoors(); @@ -153,22 +178,4 @@ describe('dogfood: a bare-list container on another package\'s object leaves its expect(deleted.status).toBe(200); await expectDefaultUnchangedOnBothDoors(); }); - - it('step 9 (control): the sanctioned override — a write to showcase_task.default by name — reaches both doors', async () => { - const saved = await stack.apiAs(token, 'PUT', `/meta/view/${DEFAULT}`, { - name: DEFAULT, - object: OBJECT, - viewKind: 'list', - label: 'Overridden', - config: { type: 'grid', columns: [{ field: 'title' }] }, - }); - expect(saved.status).toBe(200); - - const listed = named(await objectDoor(), DEFAULT); - expect(listed).toHaveLength(1); - expect(listed[0].label).toBe('Overridden'); - const read = await byName(DEFAULT); - expect(read.label).toBe('Overridden'); - expect(read.config).toEqual(listed[0].config); - }); }); From 00510db02e230807bb411c38e2bec8b3ef64730d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:27:51 +0000 Subject: [PATCH 05/11] chore(changeset): metadata-protocol patch for a cross-package bare-list view container Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- .../21334-view-container-cross-package-default.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/21334-view-container-cross-package-default.md diff --git a/.changeset/21334-view-container-cross-package-default.md b/.changeset/21334-view-container-cross-package-default.md new file mode 100644 index 00000000000..56a31f05b73 --- /dev/null +++ b/.changeset/21334-view-container-cross-package-default.md @@ -0,0 +1,13 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): a view container with a bare `list`, saved for an object another package ships, no longer replaces that package's `.default` + +Clause-②: no + +- **What was wrong.** A runtime view container whose `list` names no key expands that list to `.default`. Saved under another name, in another package or in none, for an object a code package ships, the expansion replaced that package's `.default` on `GET /api/v1/meta/view?object=`: the container's columns, still stamped with the shipping package's `_packageId` and `_provenance: 'package'`. On an environment-scoped kernel the by-name read `GET /api/v1/meta/view/.default` kept the packaged view, so the two reads disagreed. On an unscoped kernel the by-name read served the replacement too, for a container saved into a package or environment-wide. +- **What it does now.** For an object a code package ships, the bare `list` of a container that belongs to another package, or to none, expands under the container's own name, `.`. Both reads of `.default` answer the packaged view, unchanged. +- **What each expanded view carries.** The container's own package as `_packageId`, and an artifact's protection envelope only when the container's own package ships that artifact. +- **What stays.** These still expand their bare `list` to `.default`: a container bound to the package that ships the object, a package-less overlay of that package's own container saved under that container's name, and a container on an object no code package ships. A `list` that names its own key, `listViews`, `form` and `formViews` expand under the same names as before. A write to `.default` by its own name still overrides it on both reads. +- **What changes for a caller.** Such a container's default list is now listed as `.` instead of `.default`. A navigation `viewName` or a form-action `target` that named `.default` to reach that container's list now reaches the shipping package's view; name `.` instead. From f79124a005688991981abb33c503603af1cae729 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:28:39 +0000 Subject: [PATCH 06/11] chore(changeset): state what the renamed view carries, as the pins measure it Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- .changeset/21334-view-container-cross-package-default.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/21334-view-container-cross-package-default.md b/.changeset/21334-view-container-cross-package-default.md index 56a31f05b73..63633fa620b 100644 --- a/.changeset/21334-view-container-cross-package-default.md +++ b/.changeset/21334-view-container-cross-package-default.md @@ -8,6 +8,6 @@ Clause-②: no - **What was wrong.** A runtime view container whose `list` names no key expands that list to `.default`. Saved under another name, in another package or in none, for an object a code package ships, the expansion replaced that package's `.default` on `GET /api/v1/meta/view?object=`: the container's columns, still stamped with the shipping package's `_packageId` and `_provenance: 'package'`. On an environment-scoped kernel the by-name read `GET /api/v1/meta/view/.default` kept the packaged view, so the two reads disagreed. On an unscoped kernel the by-name read served the replacement too, for a container saved into a package or environment-wide. - **What it does now.** For an object a code package ships, the bare `list` of a container that belongs to another package, or to none, expands under the container's own name, `.`. Both reads of `.default` answer the packaged view, unchanged. -- **What each expanded view carries.** The container's own package as `_packageId`, and an artifact's protection envelope only when the container's own package ships that artifact. +- **What the renamed view carries.** The container's own package as `_packageId` (none for a package-less container), and no other package's `_provenance` or protection envelope. - **What stays.** These still expand their bare `list` to `.default`: a container bound to the package that ships the object, a package-less overlay of that package's own container saved under that container's name, and a container on an object no code package ships. A `list` that names its own key, `listViews`, `form` and `formViews` expand under the same names as before. A write to `.default` by its own name still overrides it on both reads. - **What changes for a caller.** Such a container's default list is now listed as `.` instead of `.default`. A navigation `viewName` or a form-action `target` that named `.default` to reach that container's list now reaches the shipping package's view; name `.` instead. From 73da9273f381ae338ed523d443918c6d60709903 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:40:02 +0000 Subject: [PATCH 07/11] fix(metadata-protocol): a container on another package's object expands every member under its own name and claims no default Patch round 1, the seat's answer to the two open questions. On an object a code package owns, a container of another package (or of none) now expands every member under its own name: the bare list as `.`, and each keyed member (a named list, `listViews`, `formViews`, `form`) as `..`, by the spec's own key rule run under that name. None of its views carries `isDefault`. Where the owning package ships `.`, the bare list stays at the spelling the spec gives it under that name. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 176 ++++++++++++--------- 1 file changed, 102 insertions(+), 74 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index ffd20aa0ef5..237a0530d7a 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -8367,9 +8367,9 @@ export class ObjectStackProtocolImplementation implements // [#21334] An upsert by name replaces whatever the merge seated // under that name, so the name has to be one the container may // write. {@link expandRuntimeViewContainer} decides it: on - // another package's object a bare `list` expands under the - // container's own name, never over that package's - // `.default`. + // another package's object every name a container expands + // derives from the container's own name, never one of that + // package's `.` names. if (isView) { const byName = new Map(); for (const it of items as any[]) { @@ -16139,30 +16139,40 @@ export class ObjectStackProtocolImplementation implements * kept, unchanged, for every container written before this field was * consulted here. * - * ## A bare `list` on ANOTHER package's object (#21334) - * - * The spec names a bare `list` (one that names no key) `.default`. - * For a container that belongs to the object's own package that is the - * object's default view, and it still expands there. For a container - * saved under any other name, in another package or in none, on an object - * a code package owns, `.default` is the OTHER package's item: - * ADR-0005 keys an overlay by its own name, and ADR-0126 rules out a - * silent override, so a row named `x` may not replace an item named - * `.default`. Both callers set each expansion by name — the list's + * ## A container on ANOTHER package's object (#21334) + * + * The spec names every expanded view `.`: a bare `list` (one + * that names no key) takes `.default`, a `form` `.form`, + * and each named member its own key. For a container of the object's own + * package those are that package's names, and it still expands there. For + * a container saved under any other name, in another package or in none, + * on an object a code package owns, they are the OTHER package's names: + * ADR-0005 keys an overlay by its own name, and ADR-0126 rules out a silent + * override, so a row named `x` may not replace an item named + * `.`. Both callers set each expansion by name — the list's * inline pass over the merged items, the registry's bare key — so the - * expansion used to replace the packaged default on the object door - * (and, on an unscoped kernel, on the by-name read too), wearing the - * shadowed artifact's `_packageId` and protection. - * - * So, on another package's object, the bare `list` expands under the - * container's own name: `.`, the spec's own - * spelling of a `list` that names its key. The qualified form is the one - * the spec accepts for a ViewItem (`ViewItemNameSchema`); the flat - * container name on an expanded item is refused there, so the list door - * would serve it badged invalid. The view container contract - * (`view.zod.ts`, ADR-0017 §3.2) names the container after its object and - * states no arm for a name another package owns, which is why the arm - * taken is the container's own name rather than a refusal at save. + * expansion used to replace the packaged view on the object door (and, on + * an unscoped kernel, on the by-name read too), wearing the shadowed + * artifact's `_packageId` and protection. + * + * So, on another package's object, every name the container expands + * derives from its own name (triage's ruling, and the seat's answer that + * extends it to the keyed members): the bare `list` is + * `.`, and every other member is + * `..` — the spec's own key rule and its + * in-container de-duplication, run under the container's name (see + * {@link expandUnderOwnName}). The qualified forms are the ones the spec + * accepts for a ViewItem (`ViewItemNameSchema`); the flat container name on + * an expanded item is refused there, so the list door would serve it + * badged invalid. The view container contract (`view.zod.ts`, ADR-0017 + * §3.2) names the container after its object and states no arm for a name + * another package owns, which is why the arm taken is the container's own + * name rather than a refusal at save. + * + * Such a container adds views to the object; it never declares the + * object's default, so none of its views carries `isDefault` — the + * 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). * * Every expanded item carries the container's OWN package and, where that * package ships an artifact of the same name, that artifact's envelope — @@ -16183,23 +16193,25 @@ export class ObjectStackProtocolImplementation implements ?? (typeof container.name === 'string' ? container.name : undefined); if (!viewObject) return []; const ownPackageId = this.runtimeViewContainerPackage(type, container, options); - const ownKey = this.isAnotherPackagesObject(viewObject, ownPackageId) - ? this.bareListOwnKey(container) - : undefined; + const crossPackage = this.isAnotherPackagesObject(viewObject, ownPackageId); + const expanded: ReadonlyArray> = crossPackage + ? this.expandUnderOwnName(type, viewObject, container, ownPackageId) + : (expandViewContainer(viewObject, container) as unknown as Record[]); const out: Record[] = []; - for (const vi of expandViewContainer(viewObject, this.withBareListKey(container, ownKey))) { + for (const vi of expanded) { // Carry the container's package provenance onto each expanded item // so the package-disable filter and ADR-0048 artifact scoping judge // them by the same owner the container has. - const item: Record = { ...(vi as any) }; - if (typeof ownKey === 'string') this.restoreAuthoredBareList(item, viewObject, ownKey); + const item: Record = { ...vi }; + // [#21334] Not the object's default: its owning package's is. + if (crossPackage) delete item.isDefault; if (ownPackageId !== undefined) item._packageId = ownPackageId; // [#21334] Only the container's own package's artifact lends its // envelope; another package's artifact of this name is not this // item's to wear. const viArtifact = ownPackageId === undefined ? undefined - : this.lookupArtifactItem(type, vi.name, ownPackageId); + : this.lookupArtifactItem(type, String(item.name), ownPackageId); const ownArtifact = (viArtifact as { _packageId?: unknown } | undefined)?._packageId === ownPackageId ? viArtifact : undefined; @@ -16235,7 +16247,7 @@ export class ObjectStackProtocolImplementation implements * `ownPackageId`. The discriminator is `getPackagedObjectOwner`, the same * "does a code package ship this?" test {@link classifyObjectContribution} * asks. A runtime-authored object has no packaged owner, so a container on - * one keeps today's `.default`; so does a registry that cannot + * one keeps today's `.` names; so does a registry that cannot * answer. */ private isAnotherPackagesObject(object: string, ownPackageId: string | undefined): boolean { @@ -16248,52 +16260,68 @@ export class ObjectStackProtocolImplementation implements } /** - * [#21334] The key a container's bare `list` takes when it may not take - * `default`: the container's own name. `undefined` when the container has - * no bare `list` (none, or one that names its own key, which already - * expands under the name its author gave it). `null` when it has one but no - * name of its own to expand under, so the bare `list` is not expanded at - * all rather than under another package's name. - */ - private bareListOwnKey(container: Record): string | null | undefined { - const list = container.list; - if (!list || typeof list !== 'object') return undefined; - if (typeof list.name === 'string' && list.name !== '') return undefined; - return typeof container.name === 'string' && container.name !== '' ? container.name : null; - } - - /** - * [#21334] The container the spec expands: unchanged when `ownKey` is - * `undefined`; its bare `list` naming `ownKey`, so the spec's own key rule - * (and its in-container de-duplication) spells `.`; or, for - * `null`, without the bare `list`. A shallow copy — the caller's container - * is never mutated. + * [#21334] Expand a container on another package's object under its own + * name. 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 + * later is placed the same way. Each item's `object` is set back to the + * object it binds. + * + * The bare `list` is lent the container's name as its key, then served as + * `.` itself, its `config` the list as authored. + * Where the owning package ships that very name (a container named after + * one of that package's keys), it stays at the spelling the spec gave it, + * `..`, so it never takes the + * packaged view's name. + * + * A container with no name of its own has nothing to expand under, and + * expands nothing. */ - private withBareListKey( + private expandUnderOwnName( + type: string, + object: string, container: Record, - ownKey: string | null | undefined, - ): Record { - if (ownKey === undefined) return container; - if (ownKey === null) return { ...container, list: undefined }; - return { ...container, list: { ...container.list, name: ownKey } }; + ownPackageId: string | undefined, + ): Record[] { + const ownName = typeof container.name === 'string' && container.name !== '' ? container.name : undefined; + if (ownName === undefined) return []; + const under = `${object}.${ownName}`; + const expandAt = under; + const list = container.list; + const bare = !!list && typeof list === 'object' && !(typeof list.name === 'string' && list.name !== ''); + const source = bare ? { ...container, list: { ...list, name: ownName } } : container; + const bareSpelled = `${expandAt}.${ownName}`; + const underIsShipped = this.isShippedByAnotherPackage(type, under, ownPackageId); + return expandViewContainer(expandAt, source).map((vi) => { + const item: Record = { ...vi, object }; + const name = String(item.name); + const fromBare = bare + && item.viewKind === 'list' + && item.config?.name === ownName + && name.startsWith(bareSpelled) + && /^(_\d+)?$/.test(name.slice(bareSpelled.length)); + if (fromBare) { + const authored = { ...item.config }; + delete authored.name; + item.config = authored; + if (!underIsShipped) { + item.name = under; + delete item._diagnostics; + } + } + return item; + }); } /** - * [#21334] Take back the `name` {@link withBareListKey} lent the bare - * `list`, so the expanded item's `config` is the list as authored. Matches - * only the item that key produced: a `list` item whose `config.name` is - * `ownKey` and whose name is `.` or the spec's - * de-duplicated `._N`. + * [#21334] True when a code package other than `ownPackageId` ships an + * artifact named `name` — the one case where the bare list's own name is + * not free to take. */ - private restoreAuthoredBareList(item: Record, object: string, ownKey: string): void { - const config = item.config as Record | undefined; - if (item.viewKind !== 'list' || !config || config.name !== ownKey) return; - const requested = `${object}.${ownKey}`; - const name = String(item.name); - if (!name.startsWith(requested) || !/^(_\d+)?$/.test(name.slice(requested.length))) return; - const authored = { ...config }; - delete authored.name; - item.config = authored; + private isShippedByAnotherPackage(type: string, name: string, ownPackageId: string | undefined): boolean { + const shipped = (this.lookupArtifactItem(type, name) as { _packageId?: unknown } | undefined)?._packageId; + return typeof shipped === 'string' && shipped !== '' && shipped !== ownPackageId; } /** From fbf3584ea9ce36b4ebd82e4e90b0231e8df326be Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:40:02 +0000 Subject: [PATCH 08/11] test(metadata-protocol,dogfood): enumerate every member kind of a cross-package container, and the single default Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- .../view-container-runtime-expansion.test.ts | 220 +++++++++++++----- ...iner-cross-package-default.dogfood.test.ts | 53 ++++- 2 files changed, 218 insertions(+), 55 deletions(-) diff --git a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts index 71bd9afd11f..ba5bd4c67c1 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -29,7 +29,7 @@ */ import { describe, expect, it } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; -import { expandViewContainer } from '@objectstack/spec/ui'; +import { expandViewContainer, ViewSchema } from '@objectstack/spec/ui'; import { ObjectStackProtocolImplementation } from './index.js'; interface Row { @@ -398,17 +398,20 @@ describe('#13407 org-scoped and environment-scoped runtime containers are served }); /** - * #21334 — a bare-`list` container on ANOTHER package's object must not take - * that package's `.default`. + * #21334 — a container on ANOTHER package's object must not take any of that + * package's names, nor its default. * - * The spec names a bare `list` `.default`. A container saved under any - * other name — in another package, or in none — for an object a code package - * ships used to expand there and REPLACE the packaged default on the object - * door (`GET /meta/view?object=`), wearing the shadowed artifact's `_packageId`, + * The spec names every expanded view `.` (a bare `list` takes + * `.default`). A container saved under any other name — in another + * package, or in none — for an object a code package ships used to expand + * there and REPLACE the packaged views on the object door + * (`GET /meta/view?object=`), wearing the shadowed artifact's `_packageId`, * while the by-name read kept the packaged item on an environment-scoped kernel * (and served the shadow too on an unscoped one, through the registry's bare * key). ADR-0005 keys an overlay by its own name and ADR-0126 rules out a silent - * override; the ruling takes the arm "expand under the container's own name". + * override. Triage's ruling takes the arm "expand under the container's own + * name"; the seat's answer extends it to every member of the container, and + * rules that such a container never declares the object's default. * * The registry double here is the real `SchemaRegistry`'s shape where this card * turns on it: loader entries under `:`, a hydrated row under @@ -419,7 +422,7 @@ describe('#13407 org-scoped and environment-scoped runtime containers are served * composition, through the REST doors, is * `view-container-cross-package-default.dogfood.test.ts`. */ -describe('#21334 a bare-list container on another package\'s object never takes that package\'s .default', () => { +describe('#21334 a container on another package\'s object never takes that package\'s names or its default', () => { const SHOWCASE = 'com.example.showcase'; const REPAIR = 'com.example.repairassets'; const TASK = 'showcase_task'; @@ -428,13 +431,22 @@ describe('#21334 a bare-list container on another package\'s object never takes const data = { provider: 'object', object: TASK }; const PACKAGED_COLUMNS = ['title', 'project', 'assignee', 'status', 'priority', 'due_date', 'progress'] .map((field) => ({ field })); - /** What the showcase ships: a `defineView` container with a bare default list and one keyed view. */ + /** + * What the showcase ships for `showcase_task`: a `defineView` container with + * one member of every kind the expander knows, so every probe below aims at + * a name the package really ships. + */ const packagedTaskViews = { list: { label: 'All Tasks', type: 'grid', data, columns: PACKAGED_COLUMNS }, listViews: { in_progress: { label: 'In Progress', type: 'grid', data, columns: PACKAGED_COLUMNS.slice(0, 4) }, }, + form: { label: 'Task Form', type: 'simple', sections: [{ label: 'Main', fields: ['title'] }] }, + formViews: { + edit: { label: 'Edit Task', type: 'simple', sections: [{ label: 'Edit', fields: ['title', 'status'] }] }, + }, }; + const PACKAGED = expandViewContainer(TASK, packagedTaskViews).map((vi) => ({ ...(vi as any) })); /** The card's own probe body, verbatim. */ const probe = (name: string) => ({ name, object: TASK, list: { type: 'grid', columns: ['title', 'status'] } }); @@ -522,29 +534,138 @@ describe('#21334 a bare-list container on another package\'s object never takes expect(v?._packageId).toBe(SHOWCASE); expect(v?.isDefault).toBe(true); }; + /** (a) Every name the showcase ships answers the packaged view, once, on BOTH doors — the same row. */ + const expectEveryPackagedNameIntact = async (protocol: Protocol, organizationId?: string) => { + const served = await objectDoor(protocol, organizationId); + for (const shipped of PACKAGED) { + const listed = named(served, shipped.name); + expect(listed, `exactly one item answers ${shipped.name} on the object door`).toHaveLength(1); + expect({ label: listed[0].label, config: listed[0].config, _packageId: listed[0]._packageId }) + .toEqual({ label: shipped.label, config: shipped.config, _packageId: SHOWCASE }); + const read = await byNameDoor(protocol, shipped.name, organizationId); + expect({ label: read?.label, config: read?.config, _packageId: read?._packageId }) + .toEqual({ label: shipped.label, config: shipped.config, _packageId: SHOWCASE }); + } + }; + /** (c) The object's only defaults are the ones its owning package declares. */ + const expectOnlyPackagedDefaults = (served: any[]) => { + for (const viewKind of ['list', 'form']) { + expect( + served.filter((v) => v.viewKind === viewKind && v.isDefault).map((v) => v.name), + `the ${viewKind} default stays the owning package's`, + ).toEqual(PACKAGED.filter((v) => v.viewKind === viewKind && v.isDefault).map((v) => v.name)); + } + }; + + /** + * Every member kind the spec's expander places, derived FROM the expander: + * each top-level key of the container schema is offered a single view and a + * record of views, and a key that yields an expanded item is a member kind. + * A single-view member is enumerated twice — bare, and naming its own key. + * A kind the spec adds later shows up here, and the enumeration below fails + * until it is placed. + */ + function memberKindsOfTheExpander(): string[] { + const slots = Object.keys((ViewSchema as unknown as { shape?: Record }).shape ?? {}); + expect(slots.length, 'the container schema\'s own keys are readable').toBeGreaterThan(0); + const view = { type: 'grid', label: 'probe' }; + const kinds: string[] = []; + for (const slot of slots) { + if (expandViewContainer('o', { [slot]: { ...view } }).length > 0) { + kinds.push(slot, `${slot}#named`); + } else if (expandViewContainer('o', { [slot]: { k: { ...view } } }).some((vi) => vi.name === 'o.k')) { + kinds.push(`${slot}.*`); + } + } + return kinds.sort(); + } + + const OWN = 'os_qa_probe'; + const listView = { type: 'grid', columns: ['title', 'status'] }; + const formView = { label: 'Probe Form', type: 'simple', sections: [{ label: 'Probe', fields: ['title'] }] }; + /** + * One case per member kind: a container on `showcase_task` with ONLY that + * member, whose key aims at a name the showcase ships, and the one name the + * member must be served under instead. + */ + const MEMBER_CASES: Record; authored: unknown; shadows: string; servedAs: string }> = { + list: { member: { list: listView }, authored: listView, shadows: DEFAULT, servedAs: `${TASK}.${OWN}` }, + 'list#named': { + member: { list: { ...listView, name: 'in_progress' } }, authored: { ...listView, name: 'in_progress' }, + shadows: `${TASK}.in_progress`, servedAs: `${TASK}.${OWN}.in_progress`, + }, + 'listViews.*': { + member: { listViews: { in_progress: listView } }, authored: listView, + shadows: `${TASK}.in_progress`, servedAs: `${TASK}.${OWN}.in_progress`, + }, + form: { member: { form: formView }, authored: formView, shadows: `${TASK}.form`, servedAs: `${TASK}.${OWN}.form` }, + 'form#named': { + member: { form: { ...formView, name: 'edit' } }, authored: { ...formView, name: 'edit' }, + shadows: `${TASK}.edit`, servedAs: `${TASK}.${OWN}.edit`, + }, + 'formViews.*': { + member: { formViews: { edit: formView } }, authored: formView, + shadows: `${TASK}.edit`, servedAs: `${TASK}.${OWN}.edit`, + }, + }; + + it('the enumeration covers every member kind the spec\'s expander places, and nothing else', () => { + expect(Object.keys(MEMBER_CASES).sort()).toEqual(memberKindsOfTheExpander()); + // Each probe really aims at a shipped name: without the fix it IS that name. + for (const [kind, c] of Object.entries(MEMBER_CASES)) { + const names = expandViewContainer(TASK, c.member).map((vi) => vi.name); + expect(names, kind).toEqual([c.shadows]); + expect(PACKAGED.map((v) => v.name), kind).toContain(c.shadows); + } + }); const KERNELS = [ ['an environment-scoped kernel (the standalone stack stamps env_local)', 'env_local'], ['an unscoped kernel (write-through hydrates the registry)', undefined], ] as const; const CONTAINERS = [ - { arm: 'package-scoped (saved into another writable package)', name: 'os_qa_shadow_probe', packageId: REPAIR, organizationId: undefined, ownPackage: REPAIR }, - { arm: 'package-less, environment-wide', name: 'os_qa_shadow_probe2', packageId: undefined, organizationId: undefined, ownPackage: undefined }, - { arm: 'package-less, organization-scoped', name: 'os_qa_shadow_probe3', packageId: undefined, organizationId: ORG, ownPackage: undefined }, + { arm: 'package-scoped (saved into another writable package)', packageId: REPAIR, organizationId: undefined, ownPackage: REPAIR }, + { arm: 'package-less, environment-wide', packageId: undefined, organizationId: undefined, ownPackage: undefined }, + { arm: 'package-less, organization-scoped', packageId: undefined, organizationId: ORG, ownPackage: undefined }, ] as const; + const save = (protocol: Protocol, name: string, item: unknown, c: (typeof CONTAINERS)[number]) => + protocol.saveMetaItem({ + type: 'view', name, item, + ...(c.packageId ? { packageId: c.packageId } : {}), + ...scoped(c.organizationId), + } as any); for (const [kernel, environmentId] of KERNELS) { describe(`on ${kernel}`, () => { for (const c of CONTAINERS) { - it(`${c.arm}: the packaged default is unchanged on BOTH doors, and they answer the same row`, async () => { - const { protocol } = showcaseHarness(environmentId); - expectPackagedDefault(named(await objectDoor(protocol, c.organizationId), DEFAULT)[0]); + for (const [kind, m] of Object.entries(MEMBER_CASES)) { + it(`${c.arm}, member ${kind}: no packaged name is replaced on either door, its own name is served with its own package, and it claims no default`, async () => { + const { protocol } = showcaseHarness(environmentId); + await save(protocol, OWN, { name: OWN, object: TASK, ...m.member }, c); + + // (a) + await expectEveryPackagedNameIntact(protocol, c.organizationId); + // (b) + const served = await objectDoor(protocol, c.organizationId); + const own = served.filter((v) => String(v.name).startsWith(`${TASK}.${OWN}`)); + expect(own.map((v) => v.name)).toEqual([m.servedAs]); + expect(own[0].object).toBe(TASK); + expect(own[0]._packageId).toBe(c.ownPackage); + expect(own[0]._provenance, 'never the packaged artifact\'s provenance').not.toBe('package'); + expect(own[0]._diagnostics?.valid, 'a qualified ViewItem name the spec accepts').toBe(true); + expect(own[0].config, 'the member as authored, nothing lent left on it').toEqual(m.authored); + // (c) + expect(own[0].isDefault).toBeUndefined(); + expectOnlyPackagedDefaults(served); + // The by-name door answers the container's own name with its row. + const row = await byNameDoor(protocol, OWN, c.organizationId); + expect(row?.object).toBe(TASK); + }); + } - await protocol.saveMetaItem({ - type: 'view', name: c.name, item: probe(c.name), - ...(c.packageId ? { packageId: c.packageId } : {}), - ...scoped(c.organizationId), - } as any); + it(`${c.arm}: the card's probe — the packaged default unchanged on BOTH doors, and the object keeps ONE list default`, async () => { + const { protocol } = showcaseHarness(environmentId); + await save(protocol, 'os_qa_shadow_probe', probe('os_qa_shadow_probe'), c); const listed = named(await objectDoor(protocol, c.organizationId), DEFAULT); expect(listed, 'exactly one item answers .default on the object door').toHaveLength(1); @@ -553,31 +674,13 @@ describe('#21334 a bare-list container on another package\'s object never takes expectPackagedDefault(read); expect({ label: read.label, config: read.config, _packageId: read._packageId }) .toEqual({ label: listed[0].label, config: listed[0].config, _packageId: listed[0]._packageId }); - }); - - it(`${c.arm}: the container's own view is served under its own name, carrying its own package`, async () => { - const { protocol } = showcaseHarness(environmentId); - await protocol.saveMetaItem({ - type: 'view', name: c.name, item: probe(c.name), - ...(c.packageId ? { packageId: c.packageId } : {}), - ...scoped(c.organizationId), - } as any); - - const own = named(await objectDoor(protocol, c.organizationId), `${TASK}.${c.name}`); - expect(own).toHaveLength(1); - expect(own[0].viewKind).toBe('list'); - expect(own[0].config, 'the list as authored, nothing lent left on it').toEqual(probe(c.name).list); - expect(own[0]._packageId).toBe(c.ownPackage); - expect(own[0]._provenance, 'never the packaged artifact\'s provenance').not.toBe('package'); - expect(own[0]._diagnostics?.valid, 'a qualified ViewItem name the spec accepts').toBe(true); - - // The by-name door answers the container's own name with its row. - const row = await byNameDoor(protocol, c.name, c.organizationId); - expect(row.list).toEqual(probe(c.name).list); + const served = await objectDoor(protocol, c.organizationId); + expect(served.filter((v) => v.viewKind === 'list' && v.isDefault).map((v) => v.name)).toEqual([DEFAULT]); + expect(named(served, `${TASK}.os_qa_shadow_probe`)[0]?.config).toEqual(probe('os_qa_shadow_probe').list); }); } - it('CONTROL — a container of the object\'s OWN package still expands to .default', async () => { + it('CONTROL — a container of the object\'s OWN package still expands to .default, as its default', async () => { const { protocol, rows } = showcaseHarness(environmentId); // A row bound to the package that owns the object (an installed // package's own stored view), written straight to the store. @@ -590,6 +693,7 @@ describe('#21334 a bare-list container on another package\'s object never takes expect(listed).toHaveLength(1); expect(listed[0].config).toEqual(probe('os_qa_same_pkg').list); expect(listed[0]._packageId).toBe(SHOWCASE); + expect(listed[0].isDefault).toBe(true); expect(named(await objectDoor(protocol), `${TASK}.os_qa_same_pkg`)).toEqual([]); }); @@ -604,7 +708,8 @@ describe('#21334 a bare-list container on another package\'s object never takes expect(listed).toHaveLength(1); expect(listed[0].label).toBe('Customized'); expect(listed[0]._packageId).toBe(SHOWCASE); - expect(named(await objectDoor(protocol), `${TASK}.${TASK}`)).toEqual([]); + expect(listed[0].isDefault).toBe(true); + expect((await objectDoor(protocol)).filter((v) => String(v.name).startsWith(`${TASK}.${TASK}`))).toEqual([]); }); it('CONTROL — the sanctioned override (a write to .default by name) is served on both doors', async () => { @@ -633,24 +738,31 @@ describe('#21334 a bare-list container on another package\'s object never takes await protocol.saveMetaItem({ type: 'view', name: 'probe_x', item: container, packageId: REPAIR } as any); const served = await objectDoor(protocol); - expect(named(served, `${TASK}.probe_x`)[0]?.label).toBe('Keyed'); - const bare = named(served, `${TASK}.probe_x_2`)[0]; - expect(bare?.config).toEqual(container.list); - expectPackagedDefault(named(served, DEFAULT)[0]); - expect(named(served, DEFAULT)).toHaveLength(1); + expect(named(served, `${TASK}.probe_x.probe_x`)[0]?.label).toBe('Keyed'); + expect(named(served, `${TASK}.probe_x`)[0]?.config).toEqual(container.list); + await expectEveryPackagedNameIntact(protocol); + }); + + it('a container named after a key the owning package ships keeps its bare list off that name', async () => { + const { protocol } = showcaseHarness('env_local'); + await protocol.saveMetaItem({ type: 'view', name: 'in_progress', item: probe('in_progress'), packageId: REPAIR } as any); + + const served = await objectDoor(protocol); + expect(named(served, `${TASK}.in_progress.in_progress`)[0]?.config).toEqual(probe('in_progress').list); + expect(named(served, `${TASK}.in_progress.in_progress`)[0]?._packageId).toBe(REPAIR); + await expectEveryPackagedNameIntact(protocol); }); - it('a stored container with no name of its own does not expand its bare list under another package\'s name', async () => { + it('a stored container with no name of its own expands nothing on another package\'s object', async () => { const { protocol, rows } = showcaseHarness('env_local'); - const nameless = { object: TASK, list: { type: 'grid', columns: ['title'] }, listViews: { mine: { label: 'Mine', type: 'grid', columns: ['title'] } } }; + const nameless = { object: TASK, list: { type: 'grid', columns: ['title'] }, listViews: { in_progress: { label: 'Mine', type: 'grid', columns: ['title'] } } }; rows.set('nameless-row', { id: 'r_nameless', type: 'view', name: 'os_qa_nameless', organization_id: null, package_id: REPAIR, state: 'active', metadata: JSON.stringify(nameless), }); const served = await objectDoor(protocol); - expect(named(served, DEFAULT)).toHaveLength(1); - expectPackagedDefault(named(served, DEFAULT)[0]); - expect(named(served, `${TASK}.mine`)[0]?._packageId).toBe(REPAIR); + expect(served.filter((v) => v._packageId === REPAIR)).toEqual([]); + await expectEveryPackagedNameIntact(protocol); }); }); diff --git a/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts b/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts index 258a480378e..15cf7439c29 100644 --- a/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts +++ b/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts @@ -24,7 +24,9 @@ // on another package's object it expands under the container's own name — // `.`, the spec's qualified ViewItem spelling. The two // doors answer the same row for `.default`: the packaged one, -// unchanged. Control: the sanctioned override, a write to +// unchanged. The seat's answer extends it: every keyed member expands under +// `..`, and such a container never claims the +// object's default, so the object keeps ONE list default — the showcase's. Control: the sanctioned override, a write to // `showcase_task.default` by name, still reaches both doors — run FIRST, on // the pristine stack, so it never reads another case's residue. // @@ -47,6 +49,8 @@ interface ViewRow { name: string; label?: unknown; isDefault?: boolean; + viewKind?: string; + _diagnostics?: { valid?: boolean }; _packageId?: string; _provenance?: string; config?: { columns?: unknown[] }; @@ -100,6 +104,12 @@ describe('dogfood: a bare-list container on another package\'s object leaves its .toEqual({ label: listed[0].label, config: listed[0].config, _packageId: listed[0]._packageId }); }; + /** The object keeps ONE list default — the showcase's own. */ + const expectOnlyThePackagedListDefault = async () => { + const defaults = (await objectDoor()).filter((r) => r.viewKind === 'list' && r.isDefault); + expect(defaults.map((r) => r.name), 'one list default, the packaged one').toEqual([DEFAULT]); + }; + it('control, first: the sanctioned override — a write to showcase_task.default by name — reaches both doors', async () => { // First, on the pristine stack, so no other case's residue can decide it. const saved = await stack.apiAs(token, 'PUT', `/meta/view/${DEFAULT}`, { @@ -155,6 +165,8 @@ describe('dogfood: a bare-list container on another package\'s object leaves its expect(own[0].config?.columns).toEqual(PROBE_COLUMNS); expect(own[0]._packageId).toBe(REPAIR); expect(own[0]._provenance).not.toBe('package'); + expect(own[0].isDefault, 'a container on another package\'s object claims no default').toBeUndefined(); + await expectOnlyThePackagedListDefault(); // The by-name read answers the container's own name with its row. expect((await byName('os_qa_shadow_probe')).list).toEqual(probe('os_qa_shadow_probe').list); @@ -173,9 +185,48 @@ describe('dogfood: a bare-list container on another package\'s object leaves its expect(own).toHaveLength(1); expect(own[0].config?.columns).toEqual(PROBE_COLUMNS); expect(own[0]._packageId, 'a package-less container lends its view no package').toBeUndefined(); + expect(own[0].isDefault).toBeUndefined(); + await expectOnlyThePackagedListDefault(); const deleted = await stack.apiAs(token, 'DELETE', '/meta/view/os_qa_shadow_probe2'); expect(deleted.status).toBe(200); await expectDefaultUnchangedOnBothDoors(); }); + + it('a keyed member (listViews.in_progress) leaves the packaged showcase_task.in_progress alone, on both doors', async () => { + const SHIPPED = `${OBJECT}.in_progress`; + const before = named(await objectDoor(), SHIPPED); + expect(before).toHaveLength(1); + expect(before[0]._packageId).toBe(SHOWCASE); + const readBefore = await byName(SHIPPED); + + const saved = await stack.apiAs(token, 'PUT', `/meta/view/os_qa_keyed_probe?package=${REPAIR}`, { + name: 'os_qa_keyed_probe', + object: OBJECT, + listViews: { in_progress: { label: 'Probe keyed', type: 'grid', columns: ['title'] } }, + }); + expect(saved.status).toBe(200); + + const after = named(await objectDoor(), SHIPPED); + expect(after, 'exactly one item answers showcase_task.in_progress on the object door').toHaveLength(1); + expect({ label: after[0].label, config: after[0].config, _packageId: after[0]._packageId }) + .toEqual({ label: before[0].label, config: before[0].config, _packageId: SHOWCASE }); + const readAfter = await byName(SHIPPED); + expect({ label: readAfter.label, config: readAfter.config, _packageId: readAfter._packageId }) + .toEqual({ label: readBefore.label, config: readBefore.config, _packageId: SHOWCASE }); + + // Served under the container's own name, carrying its own package. + const own = named(await objectDoor(), `${OBJECT}.os_qa_keyed_probe.in_progress`); + expect(own).toHaveLength(1); + expect(own[0].label).toBe('Probe keyed'); + expect(own[0]._packageId).toBe(REPAIR); + expect(own[0]._provenance).not.toBe('package'); + expect(own[0]._diagnostics?.valid).toBe(true); + expect(own[0].isDefault).toBeUndefined(); + await expectOnlyThePackagedListDefault(); + + const deleted = await stack.apiAs(token, 'DELETE', `/meta/view/os_qa_keyed_probe?package=${REPAIR}`); + expect(deleted.status).toBe(200); + await expectDefaultUnchangedOnBothDoors(); + }); }); From fbc2b95a748879949250b515966292bfda537d42 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:40:40 +0000 Subject: [PATCH 09/11] chore(changeset): cover the keyed members and the default flag Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- ...34-view-container-cross-package-default.md | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/.changeset/21334-view-container-cross-package-default.md b/.changeset/21334-view-container-cross-package-default.md index 63633fa620b..31f957a69bc 100644 --- a/.changeset/21334-view-container-cross-package-default.md +++ b/.changeset/21334-view-container-cross-package-default.md @@ -2,12 +2,30 @@ '@objectstack/metadata-protocol': patch --- -fix(metadata-protocol): a view container with a bare `list`, saved for an object another package ships, no longer replaces that package's `.default` +fix(metadata-protocol): a view container saved for an object another package ships no longer replaces that package's views or its default Clause-②: no -- **What was wrong.** A runtime view container whose `list` names no key expands that list to `.default`. Saved under another name, in another package or in none, for an object a code package ships, the expansion replaced that package's `.default` on `GET /api/v1/meta/view?object=`: the container's columns, still stamped with the shipping package's `_packageId` and `_provenance: 'package'`. On an environment-scoped kernel the by-name read `GET /api/v1/meta/view/.default` kept the packaged view, so the two reads disagreed. On an unscoped kernel the by-name read served the replacement too, for a container saved into a package or environment-wide. -- **What it does now.** For an object a code package ships, the bare `list` of a container that belongs to another package, or to none, expands under the container's own name, `.`. Both reads of `.default` answer the packaged view, unchanged. -- **What the renamed view carries.** The container's own package as `_packageId` (none for a package-less container), and no other package's `_provenance` or protection envelope. -- **What stays.** These still expand their bare `list` to `.default`: a container bound to the package that ships the object, a package-less overlay of that package's own container saved under that container's name, and a container on an object no code package ships. A `list` that names its own key, `listViews`, `form` and `formViews` expand under the same names as before. A write to `.default` by its own name still overrides it on both reads. -- **What changes for a caller.** Such a container's default list is now listed as `.` instead of `.default`. A navigation `viewName` or a form-action `target` that named `.default` to reach that container's list now reaches the shipping package's view; name `.` instead. +- **What was wrong.** A runtime view container expands each member to `.`. A `list` that names no key becomes `.default`, a `form` becomes `.form`, and every member that names a key uses that key. Saved under another name, in another package or in none, for an object a code package ships, those expansions replaced that package's views of the same names on `GET /api/v1/meta/view?object=`. The replacements were still stamped with the shipping package's `_packageId` and `_provenance: 'package'`. + - On an environment-scoped kernel, the by-name read `GET /api/v1/meta/view/` kept the packaged view, so the two reads disagreed. + - On an unscoped kernel, the by-name read served the replacement too, for a container saved into a package or environment-wide. + - The container's default list kept `isDefault: true`, so the object listed two list defaults. +- **What it does now.** For an object a code package ships, a container that belongs to another package, or to none, expands every member under its own name: + - a `list` that names no key becomes `.`; + - every other member becomes `..`. That covers a `list` that names its key, each `listViews` and `formViews` entry, and `form`. + + None of these views carries `isDefault`. Every name the shipping package serves answers its packaged view on both reads, unchanged, and the only `isDefault` views the object lists are the shipping package's. +- **One exception.** When the shipping package itself serves `.` (a container named after one of that package's keys), the container's default list becomes `..` instead. +- **A container with no name of its own** expands nothing on such an object. +- **What these views carry.** The container's own package as `_packageId` (none for a package-less container), and no other package's `_provenance` or protection envelope. +- **What stays.** Three kinds of container expand exactly as before, `isDefault` included: + - a container bound to the package that ships the object; + - a package-less overlay of that package's own container, saved under that container's name; + - a container on an object no code package ships. + + A write to `.default` by its own name still overrides it on both reads. +- **What changes for a caller.** Such a container's views are now served under new names: + - its default list as `.`, instead of `.default`; + - each keyed member as `..`, instead of `.`. + + A navigation `viewName` or a form-action `target` that used an old name to reach one of these views now reaches the shipping package's view. Use the new name instead. From f233f22fd710afc43efb53bf50f48f63c2fb28f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:08:37 +0000 Subject: [PATCH 10/11] test(metadata-protocol): a form member carries no label or name; enumerate a named variant only where the member's schema declares name The save door refuses `label` and `name` on a form view, so the probes and the packaged form fixtures carry neither, and the member-kind derivation offers a named variant only for a slot whose own schema declares `name`. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- .../view-container-runtime-expansion.test.ts | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts index ba5bd4c67c1..11997b725cb 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -441,9 +441,9 @@ describe('#21334 a container on another package\'s object never takes that packa listViews: { in_progress: { label: 'In Progress', type: 'grid', data, columns: PACKAGED_COLUMNS.slice(0, 4) }, }, - form: { label: 'Task Form', type: 'simple', sections: [{ label: 'Main', fields: ['title'] }] }, + form: { type: 'simple', sections: [{ label: 'Main', fields: ['title'] }] }, formViews: { - edit: { label: 'Edit Task', type: 'simple', sections: [{ label: 'Edit', fields: ['title', 'status'] }] }, + edit: { type: 'tabbed', sections: [{ label: 'Edit', fields: ['title', 'status'] }] }, }, }; const PACKAGED = expandViewContainer(TASK, packagedTaskViews).map((vi) => ({ ...(vi as any) })); @@ -561,18 +561,29 @@ describe('#21334 a container on another package\'s object never takes that packa * Every member kind the spec's expander places, derived FROM the expander: * each top-level key of the container schema is offered a single view and a * record of views, and a key that yields an expanded item is a member kind. - * A single-view member is enumerated twice — bare, and naming its own key. - * A kind the spec adds later shows up here, and the enumeration below fails - * until it is placed. + * A single-view member is enumerated twice — bare, and naming its own key — + * when its own schema declares `name` (a `list` does; a `form` does not, so + * a named `form` is not authorable). A kind the spec adds later shows up + * here, and the enumeration below fails until it is placed. */ function memberKindsOfTheExpander(): string[] { - const slots = Object.keys((ViewSchema as unknown as { shape?: Record }).shape ?? {}); + const shape = (ViewSchema as unknown as { shape?: Record }).shape ?? {}; + const slots = Object.keys(shape); expect(slots.length, 'the container schema\'s own keys are readable').toBeGreaterThan(0); + const declaresName = (schema: unknown): boolean => { + let s: any = schema; + for (let i = 0; i < 6 && s; i++) { + if (s.shape) return 'name' in s.shape; + s = s._zod?.def?.innerType ?? s._def?.innerType ?? (typeof s.unwrap === 'function' ? s.unwrap() : undefined); + } + return false; + }; const view = { type: 'grid', label: 'probe' }; const kinds: string[] = []; for (const slot of slots) { if (expandViewContainer('o', { [slot]: { ...view } }).length > 0) { - kinds.push(slot, `${slot}#named`); + kinds.push(slot); + if (declaresName(shape[slot])) kinds.push(`${slot}#named`); } else if (expandViewContainer('o', { [slot]: { k: { ...view } } }).some((vi) => vi.name === 'o.k')) { kinds.push(`${slot}.*`); } @@ -582,7 +593,7 @@ describe('#21334 a container on another package\'s object never takes that packa const OWN = 'os_qa_probe'; const listView = { type: 'grid', columns: ['title', 'status'] }; - const formView = { label: 'Probe Form', type: 'simple', sections: [{ label: 'Probe', fields: ['title'] }] }; + const formView = { type: 'simple', sections: [{ label: 'Probe', fields: ['title'] }] }; /** * One case per member kind: a container on `showcase_task` with ONLY that * member, whose key aims at a name the showcase ships, and the one name the @@ -599,10 +610,6 @@ describe('#21334 a container on another package\'s object never takes that packa shadows: `${TASK}.in_progress`, servedAs: `${TASK}.${OWN}.in_progress`, }, form: { member: { form: formView }, authored: formView, shadows: `${TASK}.form`, servedAs: `${TASK}.${OWN}.form` }, - 'form#named': { - member: { form: { ...formView, name: 'edit' } }, authored: { ...formView, name: 'edit' }, - shadows: `${TASK}.edit`, servedAs: `${TASK}.${OWN}.edit`, - }, 'formViews.*': { member: { formViews: { edit: formView } }, authored: formView, shadows: `${TASK}.edit`, servedAs: `${TASK}.${OWN}.edit`, From 8976a86f94027c9ffc6e9fcdf9979fdc34aadc73 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:29:11 +0000 Subject: [PATCH 11/11] chore(changeset): state the default flag's harm as measured Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- .changeset/21334-view-container-cross-package-default.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/21334-view-container-cross-package-default.md b/.changeset/21334-view-container-cross-package-default.md index 31f957a69bc..6797400a880 100644 --- a/.changeset/21334-view-container-cross-package-default.md +++ b/.changeset/21334-view-container-cross-package-default.md @@ -9,7 +9,7 @@ Clause-②: no - **What was wrong.** A runtime view container expands each member to `.`. A `list` that names no key becomes `.default`, a `form` becomes `.form`, and every member that names a key uses that key. Saved under another name, in another package or in none, for an object a code package ships, those expansions replaced that package's views of the same names on `GET /api/v1/meta/view?object=`. The replacements were still stamped with the shipping package's `_packageId` and `_provenance: 'package'`. - On an environment-scoped kernel, the by-name read `GET /api/v1/meta/view/` kept the packaged view, so the two reads disagreed. - On an unscoped kernel, the by-name read served the replacement too, for a container saved into a package or environment-wide. - - The container's default list kept `isDefault: true`, so the object listed two list defaults. + - The container's own default kept `isDefault: true`. It either replaced the object's default view or stood beside it as a second list default. - **What it does now.** For an object a code package ships, a container that belongs to another package, or to none, expands every member under its own name: - a `list` that names no key becomes `.`; - every other member becomes `..`. That covers a `list` that names its key, each `listViews` and `formViews` entry, and `form`.