Skip to content

Commit ec390ec

Browse files
fix(metadata-protocol): a hydrated view expansion carries its container's tenant marker, so an unscoped kernel answers an expanded view as env_local does (#21511) (#21603)
Fixes #21511 Clause-②: no ## What this changes On an unscoped (control-plane) kernel, registry hydration registers every view a stored environment-wide view container expands, each under its own name. `hydrateOverlayIntoRegistry` registers the container with the tenant-authorship marker (`stateTenantAuthorship`, ADR-0010 `_provenance: 'org'`), and `hydrateExpandedViewItems` registered the expansions without it. An expansion of a package-bound container therefore sat under its bare name, wearing that package's `_packageId` and no marker, and `SchemaRegistry.getArtifactItem`'s bare-key fallback took it for a view the package ships. This PR implements triage's ruling (comment 5964348889): each expansion inherits its container's authorship. - `hydrateExpandedViewItems` asks `expandRuntimeViewContainer` for tenant-authored expansions (`tenantAuthored: true`). - Under that option, `expandRuntimeViewContainer` applies `stateTenantAuthorship` to each expansion BEFORE that expansion's own artifact envelope is merged over it. This is the order the container gets (`mergeArtifactProtection(stateTenantAuthorship(data), envelope)`), so where the container's own package ships a view of that name, the artifact's `_provenance`, `_packageId` and `_lock` still win (ADR-0010 §3.3). - The two registry-free reads that call `expandRuntimeViewContainer` (the list read's expansion pass and the by-name read's step 1b, through `expandStoredViewContainers`) pass no such option and serve exactly what they served before. - No reader changed. `isArtifactBacked` (which `resettable` reads) and the layered read's `code` arm already ask `isTenantAuthored`; they now get the marker to read. ## Measured before the change At base `a7ab047c`, with the #21508 harness (`showcaseHarness`) and the card's probe (a container `os_qa_probe` on `showcase_task` with `listViews.in_progress`, read as `showcase_task.os_qa_probe.in_progress`): | kernel | container | `getMetaItem(...).resettable` | `getMetaItemLayered(...).code` | |---|---|---|---| | `env_local` | package-bound (`com.example.repairassets`) | `false` | `null` | | `env_local` | package-less, environment-wide | `false` | `null` | | unscoped | package-bound | **`true`** | **the hydrated expansion** | | unscoped | package-less, environment-wide | `false` | **the hydrated expansion** | At the base the registry held the container as `{ name: 'os_qa_probe', _provenance: 'org' }` and the expansion as `{ name: 'showcase_task.os_qa_probe.in_progress', _packageId: 'com.example.repairassets' }`, with no `_provenance`. For the package-less arm the expansion carried no `_packageId`, so `resettable` was already `false`. Its `code` layer was still wrong, through the layered read's runtime-only `getItem` arm, which drops only tenant-marked entries. After the change, both kernels give `env_local`'s answer for every member kind in both arms. ## The save door (ruling: no save-door rule change) The fix changes what `isArtifactBacked` answers for an expanded name on the unscoped kernel, and the save door reads that predicate. The door's acceptance is pinned rather than assumed. After the container is saved, a write by the expanded name is accepted on both kernels and in both arms, stored once in the container's scope, and that row then answers the name on the by-name read and on the object door. The outcome is identical across the two kernels. The same probe was accepted on all four kernel and arm combinations at the base. Under reverse verification leg 1 below, the save-door pins stay green, so the acceptance does not move with the fix. ## Tests `packages/metadata-protocol/src/view-container-runtime-expansion.test.ts` gets a new describe block, `#21511 an expanded view of a stored container answers as tenant-authored on both kernels`, with 15 tests in #21508's harness. It covers the package-bound and package-less environment-wide arms. Hydration never registers an organization-scoped row. - For each arm and each of the five member kinds: the expanded view is not resettable and has no `code` layer on either kernel, and the unscoped kernel's whole answer equals `env_local`'s (`resettable`, `editable`, `deletable`, `lock`, `provenance`, `packageId`, `code`). That is 10 tests. - For each arm: on the unscoped kernel, every registered expansion carries the container's marker, keeps the container's package, and is not a code artifact (`isCodeArtifactBody`). - For each arm: the save-door pin described above. - CONTROL: a package-less overlay of the showcase's own `showcase_task` container. Its expansions, `showcase_task.default` and `showcase_task.in_progress`, stay resettable with the packaged `code` layer on both kernels, and on the unscoped kernel the registered expansion keeps the artifact's envelope (`_provenance: 'package'`, `_packageId: com.example.showcase`) over the marker. ## Reverse verification (both runs recorded) Each run starts from the committed fix, and each leg restores with `git checkout HEAD -- ABSOLUTE_PATH`. Each restore is proven by blob hash equal to the HEAD blob, an empty `git diff HEAD`, and a clean `git status`, all inside a script armed with `trap restore EXIT INT TERM`. The subject is imported from source (`./index.js`), so no `dist/` is involved. **Run 1, at `e8e00609`** (the fix commit, before merging main): - Leg 1: `protocol.ts` was reverted to the base blob `3ac2573f` (`git restore --source=a7ab047c`). The landing was proven by the on-disk blob equalling the base blob; the base blob carries 0 occurrences of `tenantAuthored: true`, and HEAD carries 1. Result: **12 failed, 132 passed (144)**. The 10 `resettable`/`code` pins failed with `unscoped: no package ships it: expected true to be false` (package-bound) and `unscoped: no artifact, so no code layer: expected {…} to be null` (package-less). The 2 marker pins failed with `expected undefined to be 'org'`. The 2 save-door pins and the CONTROL stayed green. - Leg 2: the marker was applied after the envelope instead of before, through `scripts/ablation-replace.mjs`, with anchor 1→0, replacement 0→1, and blob `b106f11e` → `5d6e6263`. Result: **1 failed, 143 passed**. Only the CONTROL failed: `showcase_task.default: the artifact's envelope is merged over the marker, not under it: expected { _provenance: 'org' } … { _provenance: 'package' }`. - Restored HEAD: **144 passed**. **Run 2, at `09033d87`** (after merging `origin/main` `6c5697df`, which includes the landed #21545 as `eb9ef791`): - Leg 1 reverted to the merged base's blob `24cd0629` (`6c5697df`): **12 failed, 132 passed**, with the same 12 tests and the same messages. - Leg 2: **1 failed, 143 passed**, the CONTROL alone. - HEAD: **144 passed**. ## Verification at `09033d87` - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: Test Files 207 passed, 3 skipped (210); Tests 3207 passed, 19 skipped (3226); `VERDICT command-exit 0`. - `pnpm --filter @objectstack/metadata-protocol typecheck` (`tsc --noEmit`) exited 0. Its `--listFiles` reaches 210 of the package's 210 test files, including the edited test file. - Lint, as a proven narrowing: `eslint --no-inline-config --format json` over the two edited `.ts` files gives 2 files, 0 errors and 0 warnings. The changeset `.md` is outside every `files` glob of `eslint.config.mjs`. `--print-config` shows no `parserOptions.project` or `projectService` (type-aware linting is not enabled), so this diff cannot move the verdict on any untouched file. The full `pnpm lint` is CI's. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived 64 families for this change set. 63 exited 0. `pnpm check:dual-build-cjs-loads` is **NOT MEASURED**: it exited 3 (PREREQUISITE NOT MET), because it needs every package's `dist/` and 67 had none. This diff changes no exports, entry points or build config. `--ran` reconciliation: 64 accounted, 63 run, 1 NOT MEASURED. Two first runs were prerequisite misses and were re-run green after the prerequisite was met. `check-plugin-teardown-shape --self-test` needed its pinned fixture commit fetched into the shallow clone. `check:lean-entry-closure` needed `@objectstack/objectql` built. ## Region and surface `protocol.ts` hunks: the `stateTenantAuthorship` docblock (its "ONE caller" sentence now names both callers), `expandRuntimeViewContainer`'s options type and its merge line, and `hydrateExpandedViewItems`' call and docblock. The claim names the `hydrateExpandedViewItems` region. `expandRuntimeViewContainer` sits in the same hydration block, and the stamp must go there so that it precedes each expansion's own envelope (the order above), without a second copy of the envelope rule. That is the one widening of the region, declared here. The save door and the data door's read region are not edited. #21545's hunks (landed as `eb9ef791`, merged here) are disjoint from these. ## Acceptance notes - **Card premise, refined:** for the package-less environment-wide arm, only the `code` layer was wrong at the base; `resettable` was already `false` (table above). Both values are pinned now. - **Save-door intent on the unscoped kernel:** for a write by an expanded name of a package-bound container, `isArtifactBacked` now answers `false`, as on `env_local`. Reading the save path (not separately measured), the write intent it derives is therefore the runtime-only one rather than the artifact-override one. The ruling expects this ("layered by the corrected predicate on both kernels"). Acceptance is unchanged, as measured above. - **Not measured over REST.** The reads are pinned at the protocol methods the by-name and `/layers` REST routes call. - The branch was merged with `origin/main` at `6c5697df`. One later main commit (`f6b75208`, spec conformance-case notes and a lint test) is not merged. It touches none of this PR's files. Changeset: `.changeset/21511-expansion-tenant-marker.md`, `patch` for `@objectstack/metadata-protocol`. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9a4182a commit ec390ec

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.
@@ -16790,7 +16792,18 @@ export class ObjectStackProtocolImplementation implements
1679016792
private expandRuntimeViewContainer(
1679116793
type: string,
1679216794
data: unknown,
16793-
options: { packageId?: string | null },
16795+
options: {
16796+
packageId?: string | null;
16797+
/**
16798+
* [#21511] State each expansion's authorship the way
16799+
* {@link hydrateOverlayIntoRegistry} states its container's:
16800+
* {@link stateTenantAuthorship} first, then the item's own
16801+
* artifact envelope merged over it. Set only by the caller that
16802+
* REGISTERS the expansions ({@link hydrateExpandedViewItems}); the
16803+
* registry-free reads serve exactly what they served before.
16804+
*/
16805+
tenantAuthored?: boolean;
16806+
},
1679416807
): Record<string, unknown>[] {
1679516808
if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'view') return [];
1679616809
if (!isAggregatedViewContainer(data)) return [];
@@ -16824,7 +16837,11 @@ export class ObjectStackProtocolImplementation implements
1682416837
const ownArtifact = (viArtifact as { _packageId?: unknown } | undefined)?._packageId === ownPackageId
1682516838
? viArtifact
1682616839
: undefined;
16827-
out.push(mergeArtifactProtection(item, ownArtifact) as Record<string, unknown>);
16840+
// [#21511] The marker goes on BEFORE the envelope, never after:
16841+
// where the item's own artifact exists, its `_provenance` still
16842+
// wins (ADR-0010 §3.3), as it does on the container.
16843+
const authored = options.tenantAuthored ? stateTenantAuthorship(item) : item;
16844+
out.push(mergeArtifactProtection(authored, ownArtifact) as Record<string, unknown>);
1682816845
}
1682916846
return out;
1683016847
}
@@ -16991,14 +17008,29 @@ export class ObjectStackProtocolImplementation implements
1699117008
* always did (env-wide rows on an unscoped/control-plane kernel — the ONLY
1699217009
* combination #7736's own pin ever exercised), now with the corrected
1699317010
* derivation chain.
17011+
*
17012+
* ## [#21511] An expansion inherits its container's authorship
17013+
*
17014+
* Every expansion registered here is derived from a stored row, so it is
17015+
* tenant-authored exactly as its container is, and it carries the same
17016+
* marker {@link hydrateOverlayIntoRegistry} stamps on the container
17017+
* ({@link stateTenantAuthorship}, applied before the item's own artifact
17018+
* envelope). Without it, an expansion of a package-bound container sat
17019+
* under its bare name wearing that package's `_packageId` and no tenant
17020+
* marker, so `SchemaRegistry.getArtifactItem`'s bare-key fallback took it
17021+
* for a code artifact: on an unscoped kernel the by-name read reported
17022+
* the expanded view `resettable` and the layers read reported it as its
17023+
* own `code` layer (for a package-less container too, through the
17024+
* runtime-only `getItem` arm), where `env_local`, which registers nothing,
17025+
* reported neither. With the marker both kernels give one answer.
1699417026
*/
1699517027
private hydrateExpandedViewItems(
1699617028
type: string,
1699717029
data: unknown,
1699817030
options: { packageId?: string | null; organizationId: string | null },
1699917031
registry: any,
1700017032
): void {
17001-
for (const item of this.expandRuntimeViewContainer(type, data, options)) {
17033+
for (const item of this.expandRuntimeViewContainer(type, data, { ...options, tenantAuthored: true })) {
1700217034
registry.registerItem(type, item, 'name' as any);
1700317035
}
1700417036
}

‎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)