Skip to content

Commit e8e0060

Browse files
committed
fix(metadata-protocol): a hydrated view expansion carries its container's tenant marker
On an unscoped kernel, registry hydration registered each expansion of a stored environment-wide view container without the tenant-authorship marker its container carries. An expansion of a package-bound container then read as a code artifact through the registry's bare-key fallback: the by-name read answered resettable and the layers read answered the expansion as its code layer, where env_local answered neither. hydrateExpandedViewItems now asks expandRuntimeViewContainer for tenant-authored expansions: stateTenantAuthorship first, then the expansion's own artifact envelope, the order the container gets. The registry-free reads are unchanged. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
1 parent a7ab047 commit e8e0060

3 files changed

Lines changed: 173 additions & 5 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
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
6+
7+
Clause-②: no
8+
9+
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`.
10+
11+
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.
12+
13+
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.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1717,8 +1717,10 @@ function stripDerivedProvenance(item: unknown): unknown {
17171717
* the same verdict from the same row (cloud#970's shape, for non-`object`
17181718
* types).
17191719
*
1720-
* ⚠️ Its ONE caller applies it BEFORE {@link mergeArtifactProtection}, and the
1721-
* order is the whole contract: where a real artifact exists the artifact's
1720+
* ⚠️ Both callers — the container in `hydrateOverlayIntoRegistry` and, since
1721+
* #21511, each view expansion it registers (`expandRuntimeViewContainer`
1722+
* under `tenantAuthored`) — apply it BEFORE {@link mergeArtifactProtection},
1723+
* and the order is the whole contract: where a real artifact exists the artifact's
17221724
* envelope still overwrites `_provenance` (and `_packageId` /
17231725
* `_packageVersion` / `_lock*`) on the way out, so package protection is
17241726
* untouched — ADR-0010 §3.3 precedence is unchanged in both directions.
@@ -16789,7 +16791,18 @@ export class ObjectStackProtocolImplementation implements
1678916791
private expandRuntimeViewContainer(
1679016792
type: string,
1679116793
data: unknown,
16792-
options: { packageId?: string | null },
16794+
options: {
16795+
packageId?: string | null;
16796+
/**
16797+
* [#21511] State each expansion's authorship the way
16798+
* {@link hydrateOverlayIntoRegistry} states its container's:
16799+
* {@link stateTenantAuthorship} first, then the item's own
16800+
* artifact envelope merged over it. Set only by the caller that
16801+
* REGISTERS the expansions ({@link hydrateExpandedViewItems}); the
16802+
* registry-free reads serve exactly what they served before.
16803+
*/
16804+
tenantAuthored?: boolean;
16805+
},
1679316806
): Record<string, unknown>[] {
1679416807
if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'view') return [];
1679516808
if (!isAggregatedViewContainer(data)) return [];
@@ -16823,7 +16836,11 @@ export class ObjectStackProtocolImplementation implements
1682316836
const ownArtifact = (viArtifact as { _packageId?: unknown } | undefined)?._packageId === ownPackageId
1682416837
? viArtifact
1682516838
: undefined;
16826-
out.push(mergeArtifactProtection(item, ownArtifact) as Record<string, unknown>);
16839+
// [#21511] The marker goes on BEFORE the envelope, never after:
16840+
// where the item's own artifact exists, its `_provenance` still
16841+
// wins (ADR-0010 §3.3), as it does on the container.
16842+
const authored = options.tenantAuthored ? stateTenantAuthorship(item) : item;
16843+
out.push(mergeArtifactProtection(authored, ownArtifact) as Record<string, unknown>);
1682716844
}
1682816845
return out;
1682916846
}
@@ -16990,14 +17007,29 @@ export class ObjectStackProtocolImplementation implements
1699017007
* always did (env-wide rows on an unscoped/control-plane kernel — the ONLY
1699117008
* combination #7736's own pin ever exercised), now with the corrected
1699217009
* derivation chain.
17010+
*
17011+
* ## [#21511] An expansion inherits its container's authorship
17012+
*
17013+
* Every expansion registered here is derived from a stored row, so it is
17014+
* tenant-authored exactly as its container is, and it carries the same
17015+
* marker {@link hydrateOverlayIntoRegistry} stamps on the container
17016+
* ({@link stateTenantAuthorship}, applied before the item's own artifact
17017+
* envelope). Without it, an expansion of a package-bound container sat
17018+
* under its bare name wearing that package's `_packageId` and no tenant
17019+
* marker, so `SchemaRegistry.getArtifactItem`'s bare-key fallback took it
17020+
* for a code artifact: on an unscoped kernel the by-name read reported
17021+
* the expanded view `resettable` and the layers read reported it as its
17022+
* own `code` layer (for a package-less container too, through the
17023+
* runtime-only `getItem` arm), where `env_local`, which registers nothing,
17024+
* reported neither. With the marker both kernels give one answer.
1699317025
*/
1699417026
private hydrateExpandedViewItems(
1699517027
type: string,
1699617028
data: unknown,
1699717029
options: { packageId?: string | null; organizationId: string | null },
1699817030
registry: any,
1699917031
): void {
17000-
for (const item of this.expandRuntimeViewContainer(type, data, options)) {
17032+
for (const item of this.expandRuntimeViewContainer(type, data, { ...options, tenantAuthored: true })) {
1700117033
registry.registerItem(type, item, 'name' as any);
1700217034
}
1700317035
}

‎packages/metadata-protocol/src/view-container-runtime-expansion.test.ts‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,6 +1062,129 @@ describe('#21334 a container on another package\'s object never takes that packa
10621062
});
10631063
}
10641064
});
1065+
1066+
/**
1067+
* #21511 — an expansion inherits its container's authorship, so the
1068+
* unscoped kernel answers an expanded view as `env_local` does.
1069+
*
1070+
* Registry hydration (an unscoped kernel's environment-wide rows only)
1071+
* registered each expansion of a stored container under its bare name with
1072+
* no tenant marker, while the container itself carries one
1073+
* (`_provenance: 'org'`). Measured on `origin/main` before this change, with
1074+
* this harness and the card's probe: `getMetaItem(...).resettable` answered
1075+
* `true` for a package-bound container (`env_local`: `false`), and
1076+
* `getMetaItemLayered` answered the hydrated expansion as the `code` layer
1077+
* for a package-bound and a package-less container alike (`env_local`:
1078+
* `null`). Triage's ruling: each expansion carries its container's marker,
1079+
* and the save door's acceptance of a write by an expanded name is
1080+
* unchanged.
1081+
*/
1082+
describe('#21511 an expanded view of a stored container answers as tenant-authored on both kernels', () => {
1083+
/** The arms registry hydration registers: environment-wide rows. */
1084+
const ENV_WIDE = CONTAINERS.filter((c) => c.organizationId === undefined);
1085+
/** What the two reads tell a caller about an item's code layer and its affordances. */
1086+
const answer = async (protocol: Protocol, name: string) => {
1087+
const meta = (await protocol.getMetaItem({ type: 'view', name } as any)) as any;
1088+
const layered = (await protocol.getMetaItemLayered({ type: 'view', name })) as any;
1089+
return {
1090+
resettable: meta.resettable, editable: meta.editable, deletable: meta.deletable, lock: meta.lock,
1091+
provenance: meta.provenance, packageId: meta.packageId, code: layered.code,
1092+
};
1093+
};
1094+
const kernelName = (environmentId: string | undefined) => environmentId ?? 'unscoped';
1095+
1096+
for (const c of ENV_WIDE) {
1097+
for (const [kind, m] of Object.entries(MEMBER_CASES)) {
1098+
it(`${c.arm}, member ${kind}: the expanded view is not resettable and has no code layer, on both kernels alike`, async () => {
1099+
const answers = [];
1100+
for (const [, environmentId] of KERNELS) {
1101+
const { protocol } = showcaseHarness(environmentId);
1102+
await save(protocol, OWN, { name: OWN, object: TASK, ...m.member }, c);
1103+
const a = await answer(protocol, m.servedAs);
1104+
expect(a.resettable, `${kernelName(environmentId)}: no package ships it`).toBe(false);
1105+
expect(a.code, `${kernelName(environmentId)}: no artifact, so no code layer`).toBeNull();
1106+
answers.push(a);
1107+
}
1108+
expect(answers[1], 'the unscoped kernel answers as env_local does').toEqual(answers[0]);
1109+
});
1110+
}
1111+
1112+
it(`${c.arm}: on an unscoped kernel each registered expansion carries its container's tenant marker`, async () => {
1113+
const { protocol, registry } = showcaseHarness(undefined);
1114+
const m = MEMBER_CASES['listViews.*'];
1115+
await save(protocol, OWN, { name: OWN, object: TASK, ...m.member }, c);
1116+
1117+
const container = registry.getItem('view', OWN);
1118+
expect(container?._provenance, 'the container is registered as tenant-authored').toBe('org');
1119+
const expansions = registry.listItems('view').filter((it) => String(it.name).startsWith(`${TASK}.${OWN}`));
1120+
expect(expansions.map((it) => it.name), 'hydration registered the expansion').toEqual([m.servedAs]);
1121+
for (const it of expansions) {
1122+
expect(it._provenance, `${it.name} inherits the container's marker`).toBe(container?._provenance);
1123+
expect(it._packageId, `${it.name} keeps its container's package`).toBe(c.ownPackage);
1124+
expect(isCodeArtifactBody(it), `${it.name} is no code artifact`).toBe(false);
1125+
}
1126+
});
1127+
1128+
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 () => {
1129+
const m = MEMBER_CASES['listViews.*'];
1130+
const byName = {
1131+
name: m.servedAs, object: TASK, viewKind: 'list', label: 'ByName',
1132+
config: { type: 'grid', data, columns: [{ field: 'title' }] },
1133+
};
1134+
const outcomes = [];
1135+
for (const [, environmentId] of KERNELS) {
1136+
const { protocol, rows } = showcaseHarness(environmentId);
1137+
await save(protocol, OWN, { name: OWN, object: TASK, ...m.member }, c);
1138+
const saved = (await save(protocol, m.servedAs, byName, c)) as any;
1139+
const stored = [...rows.values()]
1140+
.filter((r) => r.name === m.servedAs)
1141+
.map((r) => ({ package_id: r.package_id, organization_id: r.organization_id, state: r.state }));
1142+
expect(stored, `${kernelName(environmentId)}: stored once, in the container's scope`)
1143+
.toEqual([{ package_id: c.packageId ?? null, organization_id: null, state: 'active' }]);
1144+
expect((await byNameDoor(protocol, m.servedAs))?.label).toBe('ByName');
1145+
expect(named(await objectDoor(protocol), m.servedAs).map((v) => v.label)).toEqual(['ByName']);
1146+
outcomes.push({ success: saved?.success, stored, ...(await answer(protocol, m.servedAs)) });
1147+
}
1148+
expect(outcomes[0].success).toBe(true);
1149+
expect(outcomes[1], 'the unscoped kernel answers the write as env_local does').toEqual(outcomes[0]);
1150+
});
1151+
}
1152+
1153+
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 () => {
1154+
const overlay = {
1155+
name: TASK,
1156+
list: { label: 'Customized', type: 'grid', data, columns: [{ field: 'title' }] },
1157+
listViews: { in_progress: { label: 'Customized In Progress', type: 'grid', data, columns: [{ field: 'title' }] } },
1158+
};
1159+
const shipped = [DEFAULT, `${TASK}.in_progress`];
1160+
const answers: Record<string, unknown>[][] = [];
1161+
for (const [, environmentId] of KERNELS) {
1162+
const { protocol, registry } = showcaseHarness(environmentId);
1163+
// A package-less overlay OF the showcase's own container (ADR-0005,
1164+
// name-keyed): it expands to names the showcase ships, in its slot.
1165+
await protocol.saveMetaItem({ type: 'view', name: TASK, item: overlay } as any);
1166+
const perKernel = [];
1167+
for (const name of shipped) {
1168+
const a = await answer(protocol, name);
1169+
expect(a.resettable, `${kernelName(environmentId)}, ${name}: the showcase ships it`).toBe(true);
1170+
expect((a.code as any)?.label, `${kernelName(environmentId)}, ${name}: the packaged code layer`)
1171+
.toBe(PACKAGED.find((v) => v.name === name)?.label);
1172+
perKernel.push(a);
1173+
if (environmentId === undefined) {
1174+
const hydrated = registry.listItems('view')
1175+
.filter((it) => it.name === name && String(it.label).startsWith('Customized'));
1176+
expect(hydrated, `${name}: hydration registered the overlay's expansion`).toHaveLength(1);
1177+
expect(
1178+
{ _provenance: hydrated[0]._provenance, _packageId: hydrated[0]._packageId },
1179+
`${name}: the artifact's envelope is merged over the marker, not under it`,
1180+
).toEqual({ _provenance: 'package', _packageId: SHOWCASE });
1181+
}
1182+
}
1183+
answers.push(perKernel);
1184+
}
1185+
expect(answers[1], 'the unscoped kernel answers as env_local does').toEqual(answers[0]);
1186+
});
1187+
});
10651188
});
10661189

10671190
/**

0 commit comments

Comments
 (0)