Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/21511-expansion-tenant-marker.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 37 additions & 5 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, unknown>[] {
if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'view') return [];
if (!isAggregatedViewContainer(data)) return [];
Expand Down Expand Up @@ -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<string, unknown>);
// [#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<string, unknown>);
}
return out;
}
Expand Down Expand Up @@ -16991,14 +17008,29 @@ 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,
data: unknown,
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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>[][] = [];
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]);
});
});
});

/**
Expand Down
Loading