From 2981f6eb78f2878ad9d29367005ca17d879b4949 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 09:56:20 +0000 Subject: [PATCH 1/4] test(metadata-protocol): pin each package's copy of a view container to its own package's slot The pins drive the protocol's real list and by-name reads, and the anonymous form doors' own verdict over the env-wide view list: - (a) one package's env-wide container copy saved with a form open, another package shipping it withdrawn; - (b) the same with the withdrawal saved in the other package's copy, in both save orders; - (c) a package-less copy stands in for every package with no copy of its own; - (d) the list's slot for a package, the by-name read naming it and the list scoped to it serve the same item; - (e) a stored row of the form's own name serves its own package's slot and hides no other package's copy. On the base protocol.ts: 19 red, 4 green (the (a) control, the two (e) controls, and (e) on the unscoped kernel, where registry hydration serves the other package's copy). Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../protocol.org-scoped-write-refused.test.ts | 272 +++++++++++++++++- 1 file changed, 270 insertions(+), 2 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 3823b386eca..81bcde1743d 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 @@ -73,12 +73,12 @@ import { afterEach, describe, expect, it } from 'vitest'; // #5480 update). Imported from `@objectstack/metadata-core`, never from // `@objectstack/objectql`: objectql DEPENDS ON this package, so that import // would close a dependency cycle turbo rejects outright. -import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; // The anonymous form doors' own verdict (`registerFormEndpoints` in // `@objectstack/rest`), applied here to the protocol's real list reads. import { anonymousFormIntakeCandidates, anonymousFormIntakeWithdrawnIn } from '@objectstack/metadata-core'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -import { SharingConfigSchema } from '@objectstack/spec/ui'; +import { expandViewContainer, SharingConfigSchema } from '@objectstack/spec/ui'; import { ObjectStackProtocolImplementation } from './protocol.js'; interface Row { @@ -1294,3 +1294,271 @@ describe('a publish that states no package, over a store that cannot be read', ( expect(Array.from(rows.values()).filter((r) => r.state === 'active')).toEqual([]); }); }); + +// Each package's copy of a view container expands into that package's own +// slot. Two packages ship the container `task`, and the source registrars +// register its form `task.intake_form` once per package. An env-wide copy of +// the container stored for one package writes that package's item of each +// name it expands, and a package-less copy stands in for every package with +// no copy of its own (ADR-0048). So the env-wide view list the anonymous doors +// judge against holds every package's body of the form, and any package's +// withdrawal of it, shipped or saved, closes it. The by-name read naming a +// package serves the item that package's slot in the list serves. +describe('each package\'s copy of a view container expands into its own package\'s slot', () => { + const NAME = 'task.intake_form'; + const SLUG = 'shared-intake'; + const sharing = (allowAnonymous: boolean) => ({ enabled: true, allowAnonymous, publicLink: `/forms/${SLUG}` }); + // Each body of the form is told apart by its title. + const container = (allowAnonymous: boolean, title = 'Intake') => ({ + name: 'task', object: 'task', formViews: { intake_form: { title, sharing: sharing(allowAnonymous) } }, + }); + const formView = (allowAnonymous: boolean, title: string) => ({ + name: NAME, label: title, object: 'task', viewKind: 'form', config: { title, sharing: sharing(allowAnonymous) }, + }); + + /** + * The registry where these pins turn on it, held as the real + * `SchemaRegistry` holds it: each package's entries under + * `:` (the container and the views the source registrars + * expand from it), a row an unscoped kernel hydrates under the bare name, + * `getItem` bare slot first, and `getArtifactItem`'s package-scoped + * code-artifact lookup. `owner` is the code package that owns the object. + */ + function packagesRegistry(shipped: Array<[string, Record]>, owner?: string) { + const entries = new Map>(); + const register = (item: Record, packageId?: string) => { + if (packageId) { + if (item._packageId === undefined) item._packageId = packageId; + if (item._provenance === undefined) item._provenance = 'package'; + } + entries.set(packageId ? `${packageId}:${String(item.name)}` : String(item.name), item); + }; + for (const [packageId, body] of shipped) { + register({ ...body, name: 'task' }, packageId); + for (const vi of expandViewContainer('task', body)) register({ ...(vi as any) }, packageId); + } + const composite = (name: string) => [...entries].filter(([key]) => key.endsWith(`:${name}`)).map(([, it]) => it); + return { + registerItem: (type: string, item: Record, _keyField?: string, packageId?: string) => { + if (type === 'view') register(item, packageId); + }, + listItems: (type: string, packageId?: string) => (type === 'view' + ? [...entries.values()].filter((it) => !packageId || it._packageId === packageId) + : []), + getItem: (type: string, name: string, packageId?: string) => (type !== 'view' + ? undefined + : entries.get(name) ?? (packageId ? entries.get(`${packageId}:${name}`) : undefined) ?? composite(name)[0]), + getArtifactItem: (type: string, name: string, packageId?: string) => { + if (type !== 'view') return undefined; + const shippedAs = composite(name).filter((it) => isCodeArtifactBody(it)); + return (packageId ? shippedAs.find((it) => it._packageId === packageId) : undefined) ?? shippedAs[0]; + }, + getPackagedObjectOwner: (name: string) => (owner && name === 'task' ? { packageId: owner, ownership: 'own' } : undefined), + getObject: () => undefined, + registerObject: () => {}, + getPackage: () => undefined, + isPackageDisabled: () => false, + isObjectPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }; + } + + const KERNELS = [ + ['an environment-scoped kernel', 'env_prod'], + ['an unscoped kernel (write-through hydrates the registry)', undefined], + ] as const; + + function harness(shipped: Array<[string, Record]>, environmentId: string | undefined, owner?: string) { + const { engine, rows } = makeStubEngine(); + engine.registry = packagesRegistry(shipped, owner); + const services = new Map([['tenancy', { defaultOrgId: async () => 'org_a' }]]); + const protocol = new ObjectStackProtocolImplementation(engine, () => services, environmentId) as any; + return { protocol, rows }; + } + + /** An organization overlay of the form, open, as a rollback restores it: the save check never judged it. */ + async function restoreOpenOverlay(protocol: any) { + await protocol.ensureOverlayIndex(); + await protocol.getOverlayRepo('org_a').put( + { type: 'view', name: NAME, org: 'org_a' }, + formView(true, 'Intake (org)'), + { parentVersion: null, actor: null, source: 'test.restored', intent: 'runtime-only', state: 'active', packageId: null }, + ); + } + + /** An env-wide copy of the container, stored for `packageId` (package-less when undefined). */ + async function saveCopy(protocol: any, packageId: string | undefined, allowAnonymous: boolean, title: string) { + expect((await protocol.saveMetaItem({ + type: 'view', name: 'task', item: container(allowAnonymous, title), ...(packageId ? { packageId } : {}), + })).success).toBe(true); + } + + /** Each body of the form in the env-wide view list: [package, allowAnonymous, title]. */ + async function envWideBodies(protocol: any): Promise> { + const envWide: any = await protocol.getMetaItems({ type: 'view' }); + return (envWide.items as any[]).filter((v) => v?.name === NAME) + .map((v): [unknown, unknown, unknown] => [v._packageId ?? null, v.config?.sharing?.allowAnonymous, v.config?.title]) + .sort((x, y) => String(x[0]).localeCompare(String(y[0]))); + } + + /** + * What the anonymous doors serve for the slug, by their own composition + * (`registerFormEndpoints`): the organization's read, each open candidate + * judged over the env-wide view list beneath it. + */ + async function doorsServe(protocol: any): Promise { + const org: any = await protocol.getMetaItems({ type: 'view', organizationId: 'org_a' }); + const envWide: any = await protocol.getMetaItems({ type: 'view' }); + return (org.items as any[]).filter((view) => anonymousFormIntakeCandidates(view) + .some((c) => c.slug === SLUG && !anonymousFormIntakeWithdrawnIn(envWide.items, view, c))); + } + + describe('(a) one package\'s env-wide copy is saved with the form open, and another package ships it withdrawn', () => { + for (const [kernel, environmentId] of KERNELS) { + for (const owner of [undefined, 'pkg_a'] as const) { + const where = `${kernel}, ${owner ? 'the copy\'s package owns the object' : 'no code package owns the object'}`; + it(`${where}: the env-wide list holds the shipped withdrawal beside the copy, and the doors serve no copy of the overlay`, async () => { + const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(false)]], environmentId, owner); + await restoreOpenOverlay(protocol); + await saveCopy(protocol, 'pkg_a', true, 'Intake (pkg_a copy)'); + + expect(await envWideBodies(protocol)).toEqual([ + ['pkg_a', true, 'Intake (pkg_a copy)'], + ['pkg_b', false, 'Intake'], + ]); + expect(await doorsServe(protocol)).toEqual([]); + }); + } + } + + it('control: with no package withdrawing the form, the doors serve the overlay', async () => { + const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], 'env_prod'); + await restoreOpenOverlay(protocol); + await saveCopy(protocol, 'pkg_a', true, 'Intake (pkg_a copy)'); + expect((await doorsServe(protocol)).length).toBeGreaterThan(0); + }); + }); + + describe('(b) the same, with the withdrawal saved in the other package\'s own env-wide copy', () => { + for (const [kernel, environmentId] of KERNELS) { + for (const order of [['pkg_a', 'pkg_b'], ['pkg_b', 'pkg_a']] as const) { + it(`${kernel}, ${order[0]}'s copy saved first: each copy serves its own package's slot, and the doors serve no copy of the overlay`, async () => { + const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], environmentId); + await restoreOpenOverlay(protocol); + for (const pkg of order) await saveCopy(protocol, pkg, pkg === 'pkg_a', `Intake (${pkg} copy)`); + + expect(await envWideBodies(protocol)).toEqual([ + ['pkg_a', true, 'Intake (pkg_a copy)'], + ['pkg_b', false, 'Intake (pkg_b copy)'], + ]); + expect(await doorsServe(protocol)).toEqual([]); + }); + } + } + }); + + describe('(c) a package-less env-wide copy stands in for every package with no copy of its own', () => { + for (const [kernel, environmentId] of KERNELS) { + it(`${kernel}: the package-less copy serves each package's slot, so the administrator's switch opens the form for every package`, async () => { + const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(false)]], environmentId); + await restoreOpenOverlay(protocol); + await saveCopy(protocol, undefined, true, 'Intake (env-wide copy)'); + + expect(await envWideBodies(protocol)).toEqual([ + ['pkg_a', true, 'Intake (env-wide copy)'], + ['pkg_b', true, 'Intake (env-wide copy)'], + ]); + expect((await doorsServe(protocol)).length).toBeGreaterThan(0); + }); + + for (const order of [[undefined, 'pkg_b'], ['pkg_b', undefined]] as const) { + it(`${kernel}, ${order[0] ?? 'the package-less'} copy saved first: a package's own copy serves that package's slot ahead of the package-less copy`, async () => { + const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], environmentId); + await restoreOpenOverlay(protocol); + for (const pkg of order) { + await saveCopy(protocol, pkg, pkg === undefined, pkg ? `Intake (${pkg} copy)` : 'Intake (env-wide copy)'); + } + + expect(await envWideBodies(protocol)).toEqual([ + ['pkg_a', true, 'Intake (env-wide copy)'], + ['pkg_b', false, 'Intake (pkg_b copy)'], + ]); + expect(await doorsServe(protocol)).toEqual([]); + }); + } + } + }); + + describe('(d) the list\'s slot for a package and the by-name read naming that package serve the same item', () => { + const projection = (v: any) => (v ? { name: v.name, viewKind: v.viewKind, config: v.config, _packageId: v._packageId } : v); + + /** For each package: one item in the env-wide list, and the by-name read and the list scoped to the package serve it. */ + async function expectAgreement(protocol: any, titles: Record) { + const envWide: any = await protocol.getMetaItems({ type: 'view' }); + for (const [pkg, title] of Object.entries(titles)) { + const slot = (envWide.items as any[]).filter((v) => v?.name === NAME && v._packageId === pkg); + expect(slot.map((v) => v.config?.title), `${pkg}: its one item in the env-wide list`).toEqual([title]); + const byName = (await protocol.getMetaItem({ type: 'view', name: NAME, packageId: pkg })).item; + expect(projection(byName), `${pkg}: the by-name read naming the package`).toEqual(projection(slot[0])); + const scoped: any = await protocol.getMetaItems({ type: 'view', packageId: pkg }); + expect((scoped.items as any[]).filter((v) => v?.name === NAME).map(projection), `${pkg}: the list scoped to the package`) + .toEqual([projection(slot[0])]); + } + } + + for (const [kernel, environmentId] of KERNELS) { + it(`${kernel}: a name two packages' own copies expand`, async () => { + const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], environmentId); + await saveCopy(protocol, 'pkg_a', true, 'Intake (pkg_a copy)'); + await saveCopy(protocol, 'pkg_b', false, 'Intake (pkg_b copy)'); + await expectAgreement(protocol, { pkg_a: 'Intake (pkg_a copy)', pkg_b: 'Intake (pkg_b copy)' }); + }); + + it(`${kernel}: a name a package-less copy expands, standing in for each package`, async () => { + const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], environmentId); + await saveCopy(protocol, undefined, true, 'Intake (env-wide copy)'); + await expectAgreement(protocol, { pkg_a: 'Intake (env-wide copy)', pkg_b: 'Intake (env-wide copy)' }); + }); + } + }); + + // A stored row under exactly the form's name is that name's own row + // (ADR-0005), and it is the row of its own package's slot (ADR-0048): it + // keeps another package's copy out of that package's slot only when it is + // package-less, as the by-name read naming that package decides. + describe('(e) a stored row of the form\'s own name serves its own package\'s slot, and hides no other package\'s copy', () => { + for (const [kernel, environmentId] of KERNELS) { + it(`${kernel}: one package's row of the name and another package's withdrawing copy are both in the env-wide list, and the doors serve no copy of the overlay`, async () => { + const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], environmentId); + await restoreOpenOverlay(protocol); + expect((await protocol.saveMetaItem({ + type: 'view', name: NAME, item: formView(true, 'Intake (pkg_a row)'), packageId: 'pkg_a', + })).success).toBe(true); + await saveCopy(protocol, 'pkg_b', false, 'Intake (pkg_b copy)'); + + expect(await envWideBodies(protocol)).toEqual([ + ['pkg_a', true, 'Intake (pkg_a row)'], + ['pkg_b', false, 'Intake (pkg_b copy)'], + ]); + expect(await doorsServe(protocol)).toEqual([]); + const byName = (await protocol.getMetaItem({ type: 'view', name: NAME, packageId: 'pkg_b' })).item; + expect([byName?.config?.title, byName?.config?.sharing?.allowAnonymous]).toEqual(['Intake (pkg_b copy)', false]); + }); + + it(`${kernel}, control: a package-less row of the name serves every package's slot, ahead of any copy`, async () => { + const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], environmentId); + expect((await protocol.saveMetaItem({ + type: 'view', name: NAME, item: formView(true, 'Intake (env-wide row)'), + })).success).toBe(true); + await saveCopy(protocol, 'pkg_b', false, 'Intake (pkg_b copy)'); + + expect(await envWideBodies(protocol)).toEqual([ + ['pkg_a', true, 'Intake (env-wide row)'], + ['pkg_b', true, 'Intake (env-wide row)'], + ]); + const byName = (await protocol.getMetaItem({ type: 'view', name: NAME, packageId: 'pkg_b' })).item; + expect(byName?.config?.title).toBe('Intake (env-wide row)'); + }); + } + }); +}); From c457f999351bf259457e178a4a62dc24fb7bf892 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 09:58:49 +0000 Subject: [PATCH 2/4] fix(metadata-protocol): a stored copy of a view container expands into its own package's slot of each name The list read's view branch upserted the last expansion of a name over every package's item of that name. So one package's stored env-wide copy of a view container displaced another package's item of each name the copy expands, and the anonymous form doors, which judge against the env-wide view list, could miss that package's withdrawal of a form, shipped or saved. Each slot of a name an expansion writes now serves, for its package, the expansion of the package's own container row, else of a package-less one, which stands in for every package with no container row of its own (ADR-0048). A slot neither reaches keeps its item. The selection is one function, servedViewExpansion, whose package order is the package dimension of servedOverlayRowCandidates. The by-name read (resolveRowlessExpandedView) selects through the same function: naming a package, the package-less rows in scope stand in, as in the list scoped to that package, which now expands them too in the slots the package seats (a stand-in never seats a slot). So a package's slot in the list, the list scoped to it and the by-name read naming it serve the same expansion. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 206 ++++++++++++++++----- 1 file changed, 164 insertions(+), 42 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index ae59646ef9d..5e6742f3b00 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2003,6 +2003,49 @@ function servedOverlayRowCandidates(address: { packages.map((packageId) => ({ ...scope, spelling, packageId })))); } +/** + * [#21967, ADR-0048] The stored view container expansion that serves `name` + * at the address of `packageId`, among `expansions` + * ({@link ObjectStackProtocolImplementation.expandStoredViewContainers}, each + * paired with the container row it came from); `undefined` when none does. + * + * The package order is the package dimension of + * {@link servedOverlayRowCandidates}, the one prefer-local resolution: with a + * package, the expansion of that package's own container row first, then the + * expansion of a package-less one, which stands in for every package with no + * container row of its own, and never another package's; with none, any + * expansion. Within one package the last expansion of the name wins, as + * before (two containers of one package that expand one name are refused at + * the save door). + * + * Both doors select through this function: the list read + * ({@link ObjectStackProtocolImplementation.readFlattenedMetaItems}) for each + * package's slot of the name, and the by-name read + * ({@link ObjectStackProtocolImplementation.resolveRowlessExpandedView}) for + * the package it names. So a package's slot in the list and the by-name read + * naming that package serve the same expansion. Before, the list upserted the + * last expansion of a name over every package's item of it, so one package's + * stored copy of a container displaced another package's item of each name + * the copy expands, a withdrawn public form included. + */ +function servedViewExpansion( + expansions: ReadonlyArray<{ item: Record; container: E }>, + name: string, + packageId: string | undefined, +): { item: Record; container: E } | undefined { + const packages = [...new Set(servedOverlayRowCandidates({ organizationId: undefined, packageId }) + .map((candidate) => candidate.packageId))]; + for (const pkg of packages) { + let served: { item: Record; container: E } | undefined; + for (const expanded of expansions) { + if (expanded.item.name !== name) continue; + if (pkg === undefined || (expanded.container.packageId ?? null) === pkg) served = expanded; + } + if (served !== undefined) return served; + } + return undefined; +} + /** * ADR-0048 (#1828) — package-aware overlay merge for the unscoped metadata list. * @@ -9061,47 +9104,110 @@ export class ObjectStackProtocolImplementation implements // expansion. The expansion still seats the slot, so a stand-in // held back by the merge is served there, as the package's. // - // [#21934] Only a name an expansion writes is upserted by name. - // Every other name keeps what the package-aware merge seated for - // it: one item per package that ships the name (ADR-0048), as the + // [#21934] Only a name an expansion writes is upserted. Every + // other name keeps what the package-aware merge seated for it: + // one item per package that ships the name (ADR-0048), as the // list serves it when no row is stored. The env-wide list is the // layer the anonymous form doors judge a withdrawal against, so // it holds every package's body of a name. - if (isView && records.length > 0) { + // + // [#21967] …and a name an expansion writes is upserted per + // package, never over every package's item of the name. Each + // slot of the name (the package of an item listed under it, or + // of a container row that expands it) serves + // {@link servedViewExpansion} for that package: the expansion of + // the package's own container row, else of a package-less one, + // which stands in for every package with no container row of its + // own (ADR-0048), as the by-name read naming the package serves + // it. A slot neither reaches keeps its item. Before, the last + // expansion of the name replaced every package's item of it, so + // one package's stored copy of a container displaced another + // package's item of each name the copy expands, and the + // anonymous form doors could miss that package's withdrawal of a + // form, shipped or saved. In a list scoped to a package the + // package-less container rows stand in the same way, in the + // slots the package seats only: a stand-in never seats a slot + // ([#21817]). + if (isView && (records.length > 0 || standInRows.length > 0)) { const ownRowNames = this.namesWithOwnStoredRow(records); const standInNames = this.namesWithOwnStoredRow(standInRows); - const expansions = this.expandStoredViewContainers(request.type, overlays) - .filter(({ item: vi }) => !ownRowNames.has(vi.name as string)); + const expansions = this.expandStoredViewContainers( + request.type, + packageId ? [...overlays, ...this.storedOverlayEntries(request, standInRows)] : overlays, + ).filter(({ item: vi }) => !ownRowNames.has(vi.name as string)); const written = new Set(expansions.map(({ item: vi }) => vi.name as string)); - const byName = new Map(); - for (const it of items as any[]) { - if (it && typeof it === 'object' && typeof it.name === 'string' && written.has(it.name)) { - byName.set(it.name, it); - } - } - for (const { item: vi } of expansions) { - const held = byName.get(vi.name as string) as Record | undefined; - if (held !== undefined && standInNames.has(vi.name as string)) { - // Seated: a copy the `unseated` record does not hold, - // stamped as the merge stamps a stand-in. - if (unseated?.has(held)) { - byName.set(vi.name as string, held._packageId === undefined ? { ...held, _packageId: packageId } : { ...held }); + const boundNames = new Set(expansions + .filter(({ container }) => container.packageId !== undefined) + .map(({ item: vi }) => vi.name as string)); + // The package of an item's slot, as the package-aware merge + // keys it; a list scoped to a package seats every item in + // that package's slot. + const slotPackage = (it: Record): string | undefined => packageId + ?? (typeof it._packageId === 'string' && it._packageId !== '' ? it._packageId : undefined); + const slotKey = (name: string, pkg: string | undefined) => `${name}\u0000${pkg ?? ''}`; + const filled = new Set(); + const filledNames = new Set(); + const serve = ( + name: string, + pkg: string | undefined, + held: Record | undefined, + ): Record | undefined => { + filled.add(slotKey(name, pkg)); + filledNames.add(name); + const served = servedViewExpansion(expansions, name, pkg); + // [#21817] In a list scoped to a package, a package-less + // row of the name stands in ahead of the expansion too: + // the by-name read naming the package serves that row + // before it asks any expansion. The package's own + // expansion still seats the slot, so a stand-in held back + // by the merge is served there, as the package's: a copy + // the `unseated` record does not hold, stamped as the + // merge stamps a stand-in. + if (held !== undefined && standInNames.has(name)) { + if (unseated?.has(held) && served !== undefined && served.container.packageId !== undefined) { + return held._packageId === undefined ? { ...held, _packageId: pkg } : { ...held }; } - continue; + return held; } - byName.set(vi.name as string, vi); - } + if (served === undefined) return held; + const own = served.container.packageId; + if (own !== undefined) filled.add(slotKey(name, own)); + // A package-less container's expansion standing in for a + // package carries that package's provenance, as a + // package-less row standing in does + // ({@link mergePackageAwareOverlay}), so the last pass + // below grafts that package's artifact envelope on it, as + // the by-name read naming the package does. + return pkg !== undefined && own === undefined ? { ...served.item, _packageId: pkg } : served.item; + }; const merged: unknown[] = []; for (const it of items as any[]) { if (!it || typeof it !== 'object' || typeof it.name !== 'string') continue; if (!written.has(it.name)) { merged.push(it); - } else if (byName.has(it.name)) { - merged.push(byName.get(it.name)); - byName.delete(it.name); + continue; } + const pkg = slotPackage(it); + if (filled.has(slotKey(it.name, pkg))) continue; + merged.push(serve(it.name, pkg, it)); } - items = [...merged, ...byName.values()]; + // A slot no listed item holds. A package's own container + // row's expansion seats that package's slot of the name. A + // package-less container's expansion of a name nothing else + // serves is listed on its own, in a list scoped to no package + // only. + for (const { item: vi, container } of expansions) { + const name = vi.name as string; + const own = container.packageId; + if (own !== undefined) { + if (filled.has(slotKey(name, own))) continue; + } else if (packageId !== undefined || filledNames.has(name) || boundNames.has(name)) { + continue; + } + const out = serve(name, own, undefined); + if (out !== undefined) merged.push(out); + } + items = merged; } // Only hydrate the global registry for unscoped (control-plane) @@ -9610,12 +9716,13 @@ export class ObjectStackProtocolImplementation implements /** * [#21442] Every item the stored view containers in `overlays` expand, in - * the order the list read upserts them by name (a later expansion of a - * name replaces an earlier one), each paired with the stored row it was - * expanded from. The one expansion pass both doors run: the list read - * serves the items, and {@link resolveRowlessExpandedView} also needs the - * row. Each container goes through {@link expandRuntimeViewContainer}, - * unchanged. + * row order, each paired with the stored row it was expanded from. The one + * expansion pass both doors run: the list read serves the items, and + * {@link resolveRowlessExpandedView} also needs the row. Each container + * goes through {@link expandRuntimeViewContainer}, unchanged. [#21967] + * Which of them serves a name at a package's address is + * {@link servedViewExpansion}'s answer, for both doors: within one package + * a later expansion of a name replaces an earlier one. */ private expandStoredViewContainers( type: string, @@ -9674,8 +9781,13 @@ export class ObjectStackProtocolImplementation implements * ({@link readActiveOverlayRows}, same `packageId`, same gated `orgId`), * the same parse ({@link storedOverlayEntries}) and the same expansion * ({@link expandStoredViewContainers} over - * {@link expandRuntimeViewContainer}), the last expansion of the name - * winning as it does in the list. Nothing is persisted or registered: an + * {@link expandRuntimeViewContainer}), selected for the address by the + * same function ({@link servedViewExpansion}). [#21967] Naming a package, + * that is the package's slot in the list: the expansion of the package's + * own container row, else of a package-less one, which stands in (the + * package-less rows are read as the list scoped to the package reads + * them); naming none, any expansion of the name, the last one winning. + * Nothing is persisted or registered: an * expansion is derived from its container on every read, so there is no * second copy to drift from it (ADR-0005 keys an overlay by its own name). * ⛔ No kernel-specific branch — every kernel answers through this path. @@ -9693,23 +9805,32 @@ export class ObjectStackProtocolImplementation implements ): Promise { if ((PLURAL_TO_SINGULAR[request.type] ?? request.type) !== 'view') return undefined; let records: any[] = []; + // [#21967] Naming a package, the package-less rows in scope stand in, + // read as the list scoped to that package reads them (the + // package-agnostic read, filtered back to the package-less rows). + let standInRows: any[] = []; try { records = await this.readActiveOverlayRows( { type: request.type, ...(request.packageId ? { packageId: request.packageId } : {}) }, orgId, ); + if (request.packageId) { + standInRows = (await this.readActiveOverlayRows({ type: request.type }, orgId)) + .filter((row) => (row?.package_id ?? null) === null); + } } catch (error) { // [#5532] The list read's rule: only an unprovisioned store means // "no rows". Any other failure is not answered as "nothing expands // this name". this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); } - if (this.namesWithOwnStoredRow(records).has(request.name)) return undefined; - let found: RowlessExpandedView | undefined; - for (const expanded of this.expandStoredViewContainers(request.type, this.storedOverlayEntries(request, records))) { - if (expanded.item.name === request.name) found = expanded; - } - return found; + const rows = [...records, ...standInRows]; + if (this.namesWithOwnStoredRow(rows).has(request.name)) return undefined; + return servedViewExpansion( + this.expandStoredViewContainers(request.type, this.storedOverlayEntries(request, rows)), + request.name, + request.packageId, + ); } /** @@ -19206,8 +19327,9 @@ export class ObjectStackProtocolImplementation implements * * Both read doors give a name with a stored row of its own that row * (#21510's one predicate, {@link namesWithOwnStoredRow}), and fill a - * row-less name with an expansion, the last one read winning - * ({@link expandStoredViewContainers}). So a container whose ROW name is + * row-less name with an expansion, the last one read winning within a + * package's slot and on a by-name read that names no package + * ({@link servedViewExpansion}). So a container whose ROW name is * served from elsewhere hides that view on both doors and, being no view * itself, leaves no read answering a view under the name; a container * whose EXPANSION takes such a name replaces that view on both doors with From c966e63a0a07a29c8f23c9a5df3d7442692e0f10 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 10:01:16 +0000 Subject: [PATCH 3/4] fix(metadata-protocol): a stored row of a view name keeps its own package's slot only The list read asked its own-row test with no package for every slot of a name, so one package's stored row of a name kept every other package's container expansion of that name out of the list. The by-name read naming the other package served that expansion, so the two doors disagreed, and the anonymous form doors could miss a withdrawal saved in the other package's copy of the container (on an environment-scoped kernel; an unscoped kernel's registry hydration happened to serve it). namesWithOwnStoredRow takes the package whose slot is being filled: a row counts when it is bound to that package or package-less (the package dimension of servedOverlayRowCandidates, shared with servedViewExpansion through addressPackages); with no package, every row counts. The list asks it per slot, the by-name read for the package it names. It is still the one predicate both doors ask. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 56 +++++++++++++++++----- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 5e6742f3b00..def1f5213f3 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2003,6 +2003,17 @@ function servedOverlayRowCandidates(address: { packages.map((packageId) => ({ ...scope, spelling, packageId })))); } +/** + * [#21967, ADR-0048] The package dimension of + * {@link servedOverlayRowCandidates}, in its order: with a package, that + * package (`packageId`) and then the package-less rows (`null`); with none, + * `undefined`, which matches a row of any package. + */ +function addressPackages(packageId: string | undefined): ReadonlyArray { + return [...new Set(servedOverlayRowCandidates({ organizationId: undefined, packageId }) + .map((candidate) => candidate.packageId))]; +} + /** * [#21967, ADR-0048] The stored view container expansion that serves `name` * at the address of `packageId`, among `expansions` @@ -2033,9 +2044,7 @@ function servedViewExpansion( name: string, packageId: string | undefined, ): { item: Record; container: E } | undefined { - const packages = [...new Set(servedOverlayRowCandidates({ organizationId: undefined, packageId }) - .map((candidate) => candidate.packageId))]; - for (const pkg of packages) { + for (const pkg of addressPackages(packageId)) { let served: { item: Record; container: E } | undefined; for (const expanded of expansions) { if (expanded.item.name !== name) continue; @@ -9094,9 +9103,10 @@ export class ObjectStackProtocolImplementation implements // override for it (ADR-0005 keys an overlay by its own name); // an expansion fills only a name with no row of its own. The // test is {@link namesWithOwnStoredRow} over this caller's - // `records`, the one the by-name read asks, so the two doors - // answer the same row for the name. An item the registry or a - // package supplies under the name is still replaced, as before. + // `records` at the slot's package ([#21967]), the one the + // by-name read asks, so the two doors answer the same row for + // the name. An item the registry or a package supplies under the + // name is still replaced, as before. // // [#21817] In a list scoped to a package, a package-less row // of the name stands in ahead of the expansion too: the by-name @@ -9129,12 +9139,22 @@ export class ObjectStackProtocolImplementation implements // slots the package seats only: a stand-in never seats a slot // ([#21817]). if (isView && (records.length > 0 || standInRows.length > 0)) { - const ownRowNames = this.namesWithOwnStoredRow(records); + // [#21510, #21967] The names a stored row of its own holds + // at a slot's package ({@link namesWithOwnStoredRow}, the + // predicate the by-name read asks for the package it + // names): an expansion never displaces such a row, and a + // row of one package keeps no other package's slot. + const ownRowNamesAt = new Map>(); + const ownRowNames = (pkg: string | undefined): ReadonlySet => { + let names = ownRowNamesAt.get(pkg); + if (names === undefined) ownRowNamesAt.set(pkg, (names = this.namesWithOwnStoredRow(records, pkg))); + return names; + }; const standInNames = this.namesWithOwnStoredRow(standInRows); const expansions = this.expandStoredViewContainers( request.type, packageId ? [...overlays, ...this.storedOverlayEntries(request, standInRows)] : overlays, - ).filter(({ item: vi }) => !ownRowNames.has(vi.name as string)); + ); const written = new Set(expansions.map(({ item: vi }) => vi.name as string)); const boundNames = new Set(expansions .filter(({ container }) => container.packageId !== undefined) @@ -9169,7 +9189,7 @@ export class ObjectStackProtocolImplementation implements } return held; } - if (served === undefined) return held; + if (served === undefined || ownRowNames(pkg).has(name)) return held; const own = served.container.packageId; if (own !== undefined) filled.add(slotKey(name, own)); // A package-less container's expansion standing in for a @@ -9752,11 +9772,23 @@ export class ObjectStackProtocolImplementation implements * so a name that has a row in one organization only is row-less for every * other caller. ⛔ Never a second test of "this name has its own row": * two tests are two rules, and the doors would disagree again. + * + * [#21967] …and both pass the package whose slot they fill, so a name that + * has a row in one package only is row-less for every other package's + * slot. With `packageId`, a row counts when it is bound to that package or + * package-less, the package dimension of {@link servedOverlayRowCandidates} + * (a package-less row stands in for every package, ADR-0048); with none, + * every row counts. Before, the list asked with no package for every slot, + * so one package's row of a name kept every other package's container + * expansion of it out of the list, while the by-name read naming that + * other package served the expansion. */ - private namesWithOwnStoredRow(records: readonly any[]): ReadonlySet { + private namesWithOwnStoredRow(records: readonly any[], packageId?: string): ReadonlySet { + const packages = addressPackages(packageId); const names = new Set(); for (const record of records) { - if (typeof record?.name === 'string') names.add(record.name); + if (typeof record?.name !== 'string') continue; + if (packages.some((pkg) => pkg === undefined || (record?.package_id ?? null) === pkg)) names.add(record.name); } return names; } @@ -9825,7 +9857,7 @@ export class ObjectStackProtocolImplementation implements this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); } const rows = [...records, ...standInRows]; - if (this.namesWithOwnStoredRow(rows).has(request.name)) return undefined; + if (this.namesWithOwnStoredRow(rows, request.packageId).has(request.name)) return undefined; return servedViewExpansion( this.expandStoredViewContainers(request.type, this.storedOverlayEntries(request, rows)), request.name, From dc853419db59c78e54ec31404716d3a66038dc7d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 10:08:26 +0000 Subject: [PATCH 4/4] docs(metadata-protocol): the endpoints' package exception leaves the public form page and the intake docblock The public data collection page's "Known limit: packages and names" stated one exception for the anonymous form endpoints: a saved environment-wide copy of a view container served its expansion alone for each form it expands. The view list now serves each package's own item of such a name, so the exception and its tracking clause are removed. The over-close sentence stays. The anonymousFormIntakeWithdrawnIn docblock says the same. The changeset states the change in the view list. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .changeset/21967-view-expansion-per-package.md | 18 ++++++++++++++++++ content/docs/ui/public-data-collection.mdx | 2 +- .../metadata-core/src/anonymous-form-intake.ts | 7 +++---- 3 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 .changeset/21967-view-expansion-per-package.md diff --git a/.changeset/21967-view-expansion-per-package.md b/.changeset/21967-view-expansion-per-package.md new file mode 100644 index 00000000000..7798183f66a --- /dev/null +++ b/.changeset/21967-view-expansion-per-package.md @@ -0,0 +1,18 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of a view container are saved + +Clause-②: no + +- **What was wrong.** Where packages ship the same view container, the view list (`getMetaItems` for `view`) served one item for each name a saved environment-wide copy of that container expands: the copy's own expansion. Every other package's item of that name, shipped or saved, was left out. The anonymous form endpoints judge a withdrawal against the environment-wide view list, so they could miss another package's withdrawal of such a form. +- **What it does now.** The view list serves each package its own item of such a name: + - a package's saved copy of the container serves that package's item of each name it expands; + - a package-less saved copy stands in for every package that has no copy of its own (ADR-0048); + - any other package keeps its own item. + + So another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of the container are saved. The organization-scoped save check reads the same list, so it judges each package's item too. +- **A stored view row of exactly such a name** keeps its own package's slot only. A package-less row still serves every package's slot. Before, any package's row of the name kept every package's copy expansion of it out of the list. +- **The by-name read agrees.** `getMetaItem` naming a package serves the item that package's slot in the list serves. Where no copy belongs to that package, a package-less copy now stands in for it. A list scoped to a package (`GET /api/v1/meta/view?package=`) serves the same item in each slot the package lists. A package-less copy adds no item to that list. +- **What does not change.** Within one package, a later expansion of a name still replaces an earlier one, and the save door's view container collision check is unchanged. A by-name read that names no package answers as before. No key, export, status or error code changes. diff --git a/content/docs/ui/public-data-collection.mdx b/content/docs/ui/public-data-collection.mdx index 46b3c624937..2e9c776e914 100644 --- a/content/docs/ui/public-data-collection.mdx +++ b/content/docs/ui/public-data-collection.mdx @@ -68,7 +68,7 @@ A withdrawal is a kill switch across metadata layers. If the environment-wide de **Forms a package ships.** A package's form is part of the environment-wide definition, not a separate layer beneath it. A definition parsed by the stack schema (strict `defineStack`, the default) gets the schema's default `enabled: false`, so a shipped form that keeps its link without setting `enabled: true` counts as withdrawn and an organization's copy cannot open it. A definition loaded without that parse (`defineStack(..., { strict: false })` or a hand-built manifest) is judged as written: a switch it leaves out is absent, which is not a withdrawal, so set `enabled: false` explicitly to ship a form closed. The environment-wide definition is the administrator's switch: an environment-wide save may open a form that the package ships closed. -**Known limit: packages and names.** A withdrawal of a view name closes that name in every package. When two packages each ship a view of the same name, one package's withdrawal also closes the other package's form of that name, so this may close more than was meant. The organization-scoped save check judges every package's environment-wide definition of the name. The endpoints do too, with one exception: where a package's environment-wide copy of a view container is saved, the endpoints read that copy's expansion alone for each form it expands, and can miss another package's withdrawal of that form, whether saved or shipped. To close such a form at the endpoints, withdraw it in every saved environment-wide copy of that container as well. Reading each package's expansion separately is tracked in #21967. +**Known limit: packages and names.** A withdrawal of a view name closes that name in every package. When two packages each ship a view of the same name, one package's withdrawal also closes the other package's form of that name, so this may close more than was meant. The organization-scoped save check judges every package's environment-wide definition of the name. The endpoints do too. **Known limit.** The save check runs only when an organization's copy is saved or published. A copy that was already stored before the environment-wide withdrawal, or that a rollback or revert restores, is judged only by the endpoints, which match by served item name. If that copy keeps the form open under a different key or place than the environment-wide definition, the endpoints can still serve it. To close it, withdraw the form in that organization's copy too; the next organization-scoped save of a copy that keeps it open is refused. diff --git a/packages/metadata-core/src/anonymous-form-intake.ts b/packages/metadata-core/src/anonymous-form-intake.ts index 120014958ba..f5d1031c4e8 100644 --- a/packages/metadata-core/src/anonymous-form-intake.ts +++ b/packages/metadata-core/src/anonymous-form-intake.ts @@ -326,10 +326,9 @@ function anonymousFormExplicitWithdrawals(view: unknown): Array<{ slot: string; * name in every package only when its layer holds every package's body of the * name. The organization-scoped write door anchors one body per package. The * env-wide view list the anonymous doors read holds one item per package of a - * name, with one exception: where a package's env-wide copy of a view - * container is saved, the list holds that copy's expansion alone for each form - * it expands, so the doors can miss another package's withdrawal of that - * form, whether saved or shipped (per-package expansion is #21967). A layer + * name: a package's saved env-wide copy of a view container serves that + * package's item of each form it expands, and a package-less copy stands in + * for every package with no copy of its own. A layer * with no body of the row, or whose body has no explicit withdrawal, withdraws * nothing, so a form published only in an organization stays open there. */