diff --git a/.changeset/21511-expansion-tenant-marker.md b/.changeset/21511-expansion-tenant-marker.md new file mode 100644 index 0000000000..ea70b615ea --- /dev/null +++ b/.changeset/21511-expansion-tenant-marker.md @@ -0,0 +1,13 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +An expanded view of a stored view container is reported as tenant-authored on an unscoped kernel, as it already was on an environment-scoped one: not resettable, and with no `code` layer + +Clause-②: no + +On an unscoped (control-plane) kernel, registry hydration registers each view a stored environment-wide container expands, under that view's own name. The container was registered with the tenant-authorship marker (`_provenance: 'org'`), and its expansions were not. An expansion of a container bound to a package therefore carried that package's id and no marker, and the registry's artifact lookup took it for a view the package ships. For such a name `getMetaItem` (`GET /api/v1/meta/view/NAME`) answered `resettable: true`, and `getMetaItemLayered` (`/layers`) answered the stored container's expansion as the `code` layer. The `code` layer was also wrong for an expansion of a package-less container. An environment-scoped kernel registers nothing, and answered `resettable: false` and `code: null`. + +Each registered expansion now carries its container's marker, applied before the expansion's own artifact envelope, in the same order the container gets it. Where the container's own package ships a view of that name, that artifact's envelope still wins (ADR-0010 §3.3). Both kernels now give the same answer for every expanded name. Studio's reset affordance and its code-versus-overlay diff are drawn from these two values. + +The save door is unchanged: it accepts a write by an expanded name on both kernels, as before, and the stored row then answers that name. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 24cd0629f9..240c2100ed 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1717,8 +1717,10 @@ function stripDerivedProvenance(item: unknown): unknown { * the same verdict from the same row (cloud#970's shape, for non-`object` * types). * - * ⚠️ Its ONE caller applies it BEFORE {@link mergeArtifactProtection}, and the - * order is the whole contract: where a real artifact exists the artifact's + * ⚠️ Both callers — the container in `hydrateOverlayIntoRegistry` and, since + * #21511, each view expansion it registers (`expandRuntimeViewContainer` + * under `tenantAuthored`) — apply it BEFORE {@link mergeArtifactProtection}, + * and the order is the whole contract: where a real artifact exists the artifact's * envelope still overwrites `_provenance` (and `_packageId` / * `_packageVersion` / `_lock*`) on the way out, so package protection is * untouched — ADR-0010 §3.3 precedence is unchanged in both directions. @@ -16790,7 +16792,18 @@ export class ObjectStackProtocolImplementation implements private expandRuntimeViewContainer( type: string, data: unknown, - options: { packageId?: string | null }, + options: { + packageId?: string | null; + /** + * [#21511] State each expansion's authorship the way + * {@link hydrateOverlayIntoRegistry} states its container's: + * {@link stateTenantAuthorship} first, then the item's own + * artifact envelope merged over it. Set only by the caller that + * REGISTERS the expansions ({@link hydrateExpandedViewItems}); the + * registry-free reads serve exactly what they served before. + */ + tenantAuthored?: boolean; + }, ): Record[] { if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'view') return []; if (!isAggregatedViewContainer(data)) return []; @@ -16824,7 +16837,11 @@ export class ObjectStackProtocolImplementation implements const ownArtifact = (viArtifact as { _packageId?: unknown } | undefined)?._packageId === ownPackageId ? viArtifact : undefined; - out.push(mergeArtifactProtection(item, ownArtifact) as Record); + // [#21511] The marker goes on BEFORE the envelope, never after: + // where the item's own artifact exists, its `_provenance` still + // wins (ADR-0010 §3.3), as it does on the container. + const authored = options.tenantAuthored ? stateTenantAuthorship(item) : item; + out.push(mergeArtifactProtection(authored, ownArtifact) as Record); } return out; } @@ -16991,6 +17008,21 @@ export class ObjectStackProtocolImplementation implements * always did (env-wide rows on an unscoped/control-plane kernel — the ONLY * combination #7736's own pin ever exercised), now with the corrected * derivation chain. + * + * ## [#21511] An expansion inherits its container's authorship + * + * Every expansion registered here is derived from a stored row, so it is + * tenant-authored exactly as its container is, and it carries the same + * marker {@link hydrateOverlayIntoRegistry} stamps on the container + * ({@link stateTenantAuthorship}, applied before the item's own artifact + * envelope). Without it, an expansion of a package-bound container sat + * under its bare name wearing that package's `_packageId` and no tenant + * marker, so `SchemaRegistry.getArtifactItem`'s bare-key fallback took it + * for a code artifact: on an unscoped kernel the by-name read reported + * the expanded view `resettable` and the layers read reported it as its + * own `code` layer (for a package-less container too, through the + * runtime-only `getItem` arm), where `env_local`, which registers nothing, + * reported neither. With the marker both kernels give one answer. */ private hydrateExpandedViewItems( type: string, @@ -16998,7 +17030,7 @@ export class ObjectStackProtocolImplementation implements options: { packageId?: string | null; organizationId: string | null }, registry: any, ): void { - for (const item of this.expandRuntimeViewContainer(type, data, options)) { + for (const item of this.expandRuntimeViewContainer(type, data, { ...options, tenantAuthored: true })) { registry.registerItem(type, item, 'name' as any); } } 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 71add017ea..6a5cb38c83 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -1062,6 +1062,129 @@ describe('#21334 a container on another package\'s object never takes that packa }); } }); + + /** + * #21511 — an expansion inherits its container's authorship, so the + * unscoped kernel answers an expanded view as `env_local` does. + * + * Registry hydration (an unscoped kernel's environment-wide rows only) + * registered each expansion of a stored container under its bare name with + * no tenant marker, while the container itself carries one + * (`_provenance: 'org'`). Measured on `origin/main` before this change, with + * this harness and the card's probe: `getMetaItem(...).resettable` answered + * `true` for a package-bound container (`env_local`: `false`), and + * `getMetaItemLayered` answered the hydrated expansion as the `code` layer + * for a package-bound and a package-less container alike (`env_local`: + * `null`). Triage's ruling: each expansion carries its container's marker, + * and the save door's acceptance of a write by an expanded name is + * unchanged. + */ + describe('#21511 an expanded view of a stored container answers as tenant-authored on both kernels', () => { + /** The arms registry hydration registers: environment-wide rows. */ + const ENV_WIDE = CONTAINERS.filter((c) => c.organizationId === undefined); + /** What the two reads tell a caller about an item's code layer and its affordances. */ + const answer = async (protocol: Protocol, name: string) => { + const meta = (await protocol.getMetaItem({ type: 'view', name } as any)) as any; + const layered = (await protocol.getMetaItemLayered({ type: 'view', name })) as any; + return { + resettable: meta.resettable, editable: meta.editable, deletable: meta.deletable, lock: meta.lock, + provenance: meta.provenance, packageId: meta.packageId, code: layered.code, + }; + }; + const kernelName = (environmentId: string | undefined) => environmentId ?? 'unscoped'; + + for (const c of ENV_WIDE) { + for (const [kind, m] of Object.entries(MEMBER_CASES)) { + it(`${c.arm}, member ${kind}: the expanded view is not resettable and has no code layer, on both kernels alike`, async () => { + const answers = []; + for (const [, environmentId] of KERNELS) { + const { protocol } = showcaseHarness(environmentId); + await save(protocol, OWN, { name: OWN, object: TASK, ...m.member }, c); + const a = await answer(protocol, m.servedAs); + expect(a.resettable, `${kernelName(environmentId)}: no package ships it`).toBe(false); + expect(a.code, `${kernelName(environmentId)}: no artifact, so no code layer`).toBeNull(); + answers.push(a); + } + expect(answers[1], 'the unscoped kernel answers as env_local does').toEqual(answers[0]); + }); + } + + it(`${c.arm}: on an unscoped kernel each registered expansion carries its container's tenant marker`, async () => { + const { protocol, registry } = showcaseHarness(undefined); + const m = MEMBER_CASES['listViews.*']; + await save(protocol, OWN, { name: OWN, object: TASK, ...m.member }, c); + + const container = registry.getItem('view', OWN); + expect(container?._provenance, 'the container is registered as tenant-authored').toBe('org'); + const expansions = registry.listItems('view').filter((it) => String(it.name).startsWith(`${TASK}.${OWN}`)); + expect(expansions.map((it) => it.name), 'hydration registered the expansion').toEqual([m.servedAs]); + for (const it of expansions) { + expect(it._provenance, `${it.name} inherits the container's marker`).toBe(container?._provenance); + expect(it._packageId, `${it.name} keeps its container's package`).toBe(c.ownPackage); + expect(isCodeArtifactBody(it), `${it.name} is no code artifact`).toBe(false); + } + }); + + it(`${c.arm}: the save door still accepts a write by an expanded name, alike on both kernels, and that row then answers the name`, async () => { + const m = MEMBER_CASES['listViews.*']; + const byName = { + name: m.servedAs, object: TASK, viewKind: 'list', label: 'ByName', + config: { type: 'grid', data, columns: [{ field: 'title' }] }, + }; + const outcomes = []; + for (const [, environmentId] of KERNELS) { + const { protocol, rows } = showcaseHarness(environmentId); + await save(protocol, OWN, { name: OWN, object: TASK, ...m.member }, c); + const saved = (await save(protocol, m.servedAs, byName, c)) as any; + const stored = [...rows.values()] + .filter((r) => r.name === m.servedAs) + .map((r) => ({ package_id: r.package_id, organization_id: r.organization_id, state: r.state })); + expect(stored, `${kernelName(environmentId)}: stored once, in the container's scope`) + .toEqual([{ package_id: c.packageId ?? null, organization_id: null, state: 'active' }]); + expect((await byNameDoor(protocol, m.servedAs))?.label).toBe('ByName'); + expect(named(await objectDoor(protocol), m.servedAs).map((v) => v.label)).toEqual(['ByName']); + outcomes.push({ success: saved?.success, stored, ...(await answer(protocol, m.servedAs)) }); + } + expect(outcomes[0].success).toBe(true); + expect(outcomes[1], 'the unscoped kernel answers the write as env_local does').toEqual(outcomes[0]); + }); + } + + it('CONTROL — an expansion its own package ships keeps that artifact\'s envelope over the marker (ADR-0010 §3.3): resettable, with the packaged code layer, on both kernels alike', async () => { + const overlay = { + name: TASK, + list: { label: 'Customized', type: 'grid', data, columns: [{ field: 'title' }] }, + listViews: { in_progress: { label: 'Customized In Progress', type: 'grid', data, columns: [{ field: 'title' }] } }, + }; + const shipped = [DEFAULT, `${TASK}.in_progress`]; + const answers: Record[][] = []; + for (const [, environmentId] of KERNELS) { + const { protocol, registry } = showcaseHarness(environmentId); + // A package-less overlay OF the showcase's own container (ADR-0005, + // name-keyed): it expands to names the showcase ships, in its slot. + await protocol.saveMetaItem({ type: 'view', name: TASK, item: overlay } as any); + const perKernel = []; + for (const name of shipped) { + const a = await answer(protocol, name); + expect(a.resettable, `${kernelName(environmentId)}, ${name}: the showcase ships it`).toBe(true); + expect((a.code as any)?.label, `${kernelName(environmentId)}, ${name}: the packaged code layer`) + .toBe(PACKAGED.find((v) => v.name === name)?.label); + perKernel.push(a); + if (environmentId === undefined) { + const hydrated = registry.listItems('view') + .filter((it) => it.name === name && String(it.label).startsWith('Customized')); + expect(hydrated, `${name}: hydration registered the overlay's expansion`).toHaveLength(1); + expect( + { _provenance: hydrated[0]._provenance, _packageId: hydrated[0]._packageId }, + `${name}: the artifact's envelope is merged over the marker, not under it`, + ).toEqual({ _provenance: 'package', _packageId: SHOWCASE }); + } + } + answers.push(perKernel); + } + expect(answers[1], 'the unscoped kernel answers as env_local does').toEqual(answers[0]); + }); + }); }); /**