Skip to content

Commit 94990a2

Browse files
fix(metadata-protocol): the layered read of a shipped flow name reports the loader's body as the effective layer, as the by-name read and the list do (#21002) (#21043)
Part of #21002 Clause-②: no ## What For a flow name the loader ships from a managed package, the layered read (`GET /api/v1/meta/flow/NAME/layers`, and the deprecated layers flag on the by-name door, which uses the same helper) now reports the loader's body as the effective layer. That is the body the by-name read (`GET /api/v1/meta/flow/NAME`) and the flow list (`GET /api/v1/meta/flow`) have answered for that name since #20946 and #20913. A stored row of that name is still reported, as a separate shadowed layer of its own scope, and is never the effective layer under the package's lock and provenance flags. `getMetaItemLayered` in `packages/metadata-protocol/src/protocol.ts` now decides its effective layer with the stored-row predicate PR #20942 introduced and PR #20994 reuses, `isShippedFlowName`, judged by name. It adds no precedence rule of its own. - **The predicate is called, not edited.** Only `getMetaItemLayered` moves in `protocol.ts`: the effective-layer binding and its docblock (+29 / -2 there). - **The response shape is unchanged.** No key is added or removed. The stored row stays where the layered answer already reports a stored row, beside the effective layer, with its own scope. - **The registry half needs no call here.** The code layer reads the loader's set (`lookupArtifactItem`, blind to tenant-authored rows) before the registry's bare slot, and a shipped name is one that set holds by definition. So `isStoredFlowEntryOfShippedName` would add an unreachable branch. - **The lock and provenance flags are unchanged.** They already resolved from the code layer first. ## Why - Triage direction `5923270373`: "In `getMetaItemLayered`, the effective layer for a shipped flow name is the loader's body, decided by the predicate PR #20942 / #20994 put on the list and the by-name read. ⛔ No fourth precedence path." And: "The stored row appears **as a shadowed layer**, with its own provenance, never as the effective layer under the package's lock flags." - ADR-0126 §2 (`flow` is Regime C): "⛔ Never silent override, never an overlay read path". ADR-0131 D6: managed definitions are sealed. - #20761's ruling `5904938166`, rule 1: a body's package-provenance stamps are display only. - The method's own docblock, and the spec's description of the layered response, say the effective layer is what the ordinary by-name read returns. For a shipped flow name that is now true. What becomes of the stored rows themselves (keep, refuse, migrate) belongs to #15206. This PR does not decide it. ## Why this PR is `Part of`: the published-snapshot door does not follow The triage expected the published-snapshot read to follow the effective layer automatically. Measured, it does not. - `GET /api/v1/meta/flow/NAME/published` (`packages/rest/src/rest-server.ts`, about `:8413`–`:8425`) reads the layered answer, but it picks a layer itself: when a stored layer is present it serves that layer, and it never reads the effective one. - Its dispatcher twin in `packages/runtime/src/domains/meta.ts` (about `:1121`–`:1137`) has the same shape, by source reading. It was not measured: the dogfood stack routes through the REST transport. - So, with the stored row kept as a shadowed layer as the triage requires, that door still answers `200` with the stored body, both before and after this change. The dispatch said to stop there and report, and not to edit that door in this card. The measurement and the options are in the report on #21002. #21002 remains open for that half. ## Repro, before and after Showcase composition on a database file, cold boot. A stored row is at rest under a shipped flow name, with a body that can be told apart from the loader's. There is also an organization-scoped row under a second shipped name, and an environment-wide row under a name no package ships. | Door or reading | `origin/main` `2f2fa11d75` | this branch | |---|---|---| | `/meta/flow/NAME/layers`, shipped name with a stored row: the effective layer | 200, the stored body, under the package's provenance and package id | 200, the loader's body, same flags | | the same answer: the stored row | reported as a separate layer, environment scope | unchanged: reported, shadowed | | the deprecated layers flag on the by-name door | 200, effective layer is the stored body | 200, effective layer is the loader's body | | `GET /meta/flow/NAME` | 200, the loader's body | unchanged | | `GET /meta/flow`, the entry for NAME | the loader's body | unchanged | | `GET /meta/flow/NAME/published` | 200, the stored body | **unchanged: 200, the stored body** (see above) | | control: a shipped name with no stored row, layers | effective layer is the loader's body, no stored layer | unchanged | | control: the same name, published | `501 NOT_IMPLEMENTED` (this kernel has no code/package store) | unchanged | | control: a shipped name with an organization-scoped row only, layers | effective layer is the loader's body, no stored layer | unchanged | | control: an unshipped name with a stored row, layers | effective layer is the stored body | unchanged | | control: the same name, published | 200, the stored body | unchanged | ## Pins - **Unit:** `packages/metadata-protocol/src/protocol.flow-layered-shipped-name.test.ts`, 9 cases. It reuses the registry double of PR #20994's unit pin: the real `SchemaRegistry` key shapes, its `getItem` precedence (the bare slot first) and its artifact lookup. - A shipped name with a stored row: the effective and code layers are the loader's body, the stored row is reported with its own scope, and the package's flags stand. This holds before and after the row is hydrated. - The layered read, the by-name read and the list answer one and the same body. - The package-scoped read and the plural type spelling answer the same. - A row bound to the shipping package, or one whose body claims the package's stamps, is judged by name alone. - Controls: an unshipped name keeps its stored row as the effective layer; a shipped name with no row is unchanged; an organization-scoped row is out of reach; an overlay-regime type keeps overlay-wins. - **Dogfood cold boot:** `packages/qa/dogfood/test/flow-shipped-name-layered-read.dogfood.test.ts`, 8 cases, a new file. - The layered door reports the loader's body as the effective layer, under the package's flags. - The stored row is still reported, as a shadowed layer of its own scope. - The layered door, the by-name read and the list answer one and the same body. - The deprecated layers flag answers the same. - Three controls: a shipped name with no stored row, an organization-scoped row, and an unshipped name. - `flow-shipped-name-by-name-read.dogfood.test.ts` and `flow-provenance-server-held.dogfood.test.ts` are not touched. - The published-snapshot door is **not** pinned. The file's header says why. ## Verification, at head `39ed9ac48a` `protocol.ts` and both pins are byte-identical between `5cdb27e44d` and `39ed9ac48a`. The last commit adds only the changeset and the ledger row. - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2` (the whole package), at `39ed9ac48a`: 196 files passed, 3 skipped; 2905 tests passed, 19 skipped. - `pnpm --filter @objectstack/metadata-protocol run typecheck`: exit 0. `tsc --listFiles` includes the new unit pin. - Dogfood, `vitest run` over four files: the new pin, PR #20994's `flow-shipped-name-by-name-read`, PR #20942's `flow-shipped-name-stored-row-boot` and `flow-provenance-server-held`. 4 files, 36 tests passed. The metadata-protocol `dist` carries the fix: the guard's text is in 2 built files. - `pnpm --filter @objectstack/dogfood run typecheck`: exit 0. `--listFiles` includes the new pin. - **Red before:** on the `origin/main` build of metadata-protocol, the layered door's effective layer for the subject was the stored body. The table above shows this. **Ablation.** The fix was committed first (`d690943261`). Each leg ran through `scripts/ablation-replace.mjs` and deleted the predicate clause from the effective-layer binding. The anchor hit once, and the blob changed from `6056394ec7a6` to `ed01c7ddc863`. | Leg | Resolution | Result | |---|---|---| | A1, the unit pin | `./protocol.js` from source, no rebuild | 5 failed, 4 passed. The 5 are every shipped-name case; the 4 controls pass. | | A2, the dogfood pin | the metadata-protocol `dist`, rebuilt after the mutation | 3 failed, 5 passed. Failed: the effective layer, the three-way agreement and the deprecated flag. Passed: the store check, the shadowed-row report and the three controls. | - **A2 dist proof:** `scripts/ablation-dist-preflight.mjs` found the guard absent from all 24 built files after the mutated build. - **Restores:** each restore was proven: the blob equals HEAD, and `git diff HEAD` is empty. After the restored build, the guard is present in 2 built files and the working tree is clean against HEAD. - **A first A1 attempt was a no-op.** The replacement text was a substring of the anchor, so the tool refused it before any test ran and restored the file. It produced no measurement. **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` printed 74 commands for this tree at `39ed9ac48a`. All 74 were run, each exit code captured before any pipe. `--ran` reconciliation: 74 derived, 74 run, 0 NOT-MEASURED, 0 unrun. - **First pass:** `check:dual-build-cjs-loads` exited 3, `PREREQUISITE NOT MET`. Eight packages outside this diff had no `dist/` in this fresh worktree. After building those eight (all turbo cache hits), it exited 0. - **Roster gates:** the ten roster gates whose roster sits beside a path of this diff were also run, all exit 0. They are `check-changeset-fixed`, `check-published-list-mirrors` (plain and `--self-test`), `check:authz-resolver`, `check:console-injection`, `check:engine-double-contract`, `check:error-code-casing`, `check:i18n-stale-fill`, `check:published-readme-exports` and `check-dts-references --self-test`. - **Base:** `origin/main` has not moved since the branch point `2f2fa11d75`. **Lint, a proven narrowing of `pnpm lint` (the repo-wide run is CI's), at `39ed9ac48a`:** 1. **Population, from eslint's own config:** of the 5 touched paths, the config matches the 3 `.ts` files. The `.md` and `.json` files answer "File ignored because no matching configuration was supplied." 2. **Count, from `--format json`:** 5 results. The 3 linted files have 0 errors and 0 warnings. 3. **Invariance:** `eslint.config.mjs` never enables type-aware linting. All seven `parserOptions` blocks are `ecmaVersion` and `sourceType` only, with no `project`. The only other files the config reads are `scripts/slot-lookup-baseline.json` and `scripts/query-options-erasure-baseline.json`, and this diff touches neither. So the diff cannot move the verdict on any untouched file. **NOT MEASURED locally, declared to CI:** - Test Core shards, Temporal Conformance, the full Dogfood Regression Gate and Dogfood Verify CLI. - Build Core and the workspace type-check lanes. - The runtime dispatcher's published twin (source reading only). ## Deviations 1. **`Part of #21002`, not a closing line.** The dispatch named a closing line. The published-snapshot door half of the card is measured unresolved and is now a decision for the seat, so this PR does not close the card. The seat can rewrite the first line if it rules that half out of the card. 2. **`scripts/engine-double-contract.pinned.json`, one generated row.** The new unit pin's engine double has a `findOne`, so `check:engine-double-contract` requires the coverage ledger to learn the file, through `--write`. The diff is exactly that one row. PR #20994 has the same precedent. 3. **No published-snapshot pin.** The dispatch said to stop and report if that door picks a layer by itself. It does, so the door is measured and reported here, not pinned. ## Acceptance notes - **Unchanged, and named:** - every other metadata type (the predicate gates on `flow` first); - flow names no managed package ships; - organization-scoped flow rows, which this read never reaches because `flow` declares no org override; - the lock, provenance and affordance flags, which already resolved from the code layer. - **For an unshipped name with a stored row,** the layered door's code layer is the stored body (measured on both trees). The code-layer fallback reaches the registry's bare slot, which holds the hydrated row. The spec describes that layer as null when no artifact ships the item. This is reported separately and is not touched here. - **In the showcase composition,** the published-snapshot door answers `501 NOT_IMPLEMENTED` for a shipped flow with no stored row. That kernel has no code/package store for it to fall back to. This bears on what that door could answer for a shipped name, so it is part of the report. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2821e9f commit 94990a2

5 files changed

Lines changed: 658 additions & 2 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+
fix(metadata-protocol): the layered read of a flow name a managed package ships reports the package's flow as the effective layer, as the by-name read and the flow list do (#21002)
6+
7+
Clause-②: no
8+
9+
`flow` is in ADR-0126's Regime C: a managed package's flow is sealed, and there is no overlay read path for it. The flow list, `GET /api/v1/meta/flow`, and the by-name read, `GET /api/v1/meta/flow/:name`, already serve the package's flow for a name a managed package ships (#20913, #20946). The layered read, `GET /api/v1/meta/flow/:name/layers`, did not: for such a name it reported a stored flow of that name as the effective layer, while its lock and provenance flags named the package. So the layered read and the other two read doors answered two different flows for one name.
10+
11+
The layered read now decides the effective layer with the same check the other two doors use. For a name a managed package ships, the effective layer is the package's flow, with or without a package scope, whatever the stored flow's own package binding or markings say. The stored flow is still reported, as a separate layer of its own scope that does not take effect. The deprecated layers flag on the by-name read answers the same.
12+
13+
The stored flow is not deleted, rewritten or refused. Flow names no managed package ships, organization-scoped rows and every other metadata type are read as before.
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21002, #20761 ruling rule 1, ADR-0126 §2 / §3] The layered read of a flow
5+
* name the loader's set holds reports the loader's body as the effective layer
6+
* — the body the by-name read and the list serve for that name — and a stored
7+
* row of that name as a shadowed layer, never as the effective layer under the
8+
* package's lock and provenance flags.
9+
*
10+
* `flow` is Regime C: the packaged base is locked, "⛔ Never silent override,
11+
* never an overlay read path". Since #20913 the flattened view and since
12+
* #20946 `getMetaItem` apply that by name, with the stored-row predicate
13+
* (`isShippedFlowName`). `getMetaItemLayered` computed its effective layer as
14+
* overlay-wins, so for such a name it answered the stored body while its
15+
* docblock promised "what `getMetaItem` would return". It now decides the
16+
* effective layer with the same predicate, and keeps the row in `overlay`.
17+
*
18+
* The registry double is the one `protocol.flow-by-name-shipped-name.test.ts`
19+
* uses: the real `SchemaRegistry`'s key shape (`<package>:<name>` for a loader
20+
* entry, the bare name for a hydrated row), its `getItem` precedence (the bare
21+
* slot first) and its artifact lookup (package-scoped code-artifact entries
22+
* first). `@objectstack/objectql` cannot be imported here: it depends on this
23+
* package. The cold-boot proof over the real composition is
24+
* `flow-shipped-name-layered-read.dogfood.test.ts`.
25+
*
26+
* Controls: a flow name no managed package ships keeps its stored row as the
27+
* effective layer, a shipped name with no stored row is unchanged, an
28+
* organization-scoped flow row is out of the read's reach, and a type in the
29+
* overlay regime keeps overlay-wins.
30+
*/
31+
import { describe, expect, it } from 'vitest';
32+
import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core';
33+
import { ObjectStackProtocolImplementation } from './protocol.js';
34+
35+
const PACKAGE_ID = 'com.example.pkg';
36+
const SHIPPED = 'pkg_flow';
37+
const CUSTOMER = 'customer_flow';
38+
const ORG_ID = 'org_a';
39+
40+
const flowBody = (name: string, label: string, extra: Record<string, unknown> = {}) => ({
41+
name,
42+
label,
43+
type: 'autolaunched',
44+
nodes: [
45+
{ id: 'start', type: 'start', label: 'Start' },
46+
{ id: label === 'LOADER' ? 'end' : 'stored_end', type: 'end', label: 'End' },
47+
],
48+
edges: [{ id: 'e1', source: 'start', target: label === 'LOADER' ? 'end' : 'stored_end' }],
49+
...extra,
50+
});
51+
52+
interface StoredRow {
53+
id: string;
54+
type: string;
55+
name: string;
56+
organization_id: string | null;
57+
package_id: string | null;
58+
state: string;
59+
metadata: string;
60+
}
61+
62+
const storedRow = (type: string, name: string, body: unknown, extra: Partial<StoredRow> = {}): StoredRow => ({
63+
id: `r_${type}_${name}_${extra.organization_id ?? 'env'}`,
64+
type,
65+
name,
66+
organization_id: null,
67+
package_id: null,
68+
state: 'active',
69+
metadata: JSON.stringify(body),
70+
...extra,
71+
});
72+
73+
/**
74+
* A registry double with the real `SchemaRegistry`'s two key shapes, its
75+
* `getItem` precedence and its artifact lookup. `registerItem` stamps a package
76+
* entry the way `applyProtection` does and leaves a bare registration's
77+
* provenance alone.
78+
*/
79+
function registryDouble() {
80+
const byType = new Map<string, Map<string, Record<string, unknown>>>();
81+
const collection = (type: string) => {
82+
if (!byType.has(type)) byType.set(type, new Map());
83+
return byType.get(type)!;
84+
};
85+
return {
86+
registerItem(type: string, item: Record<string, unknown>, keyField = 'name', packageId?: string) {
87+
const name = String(item[keyField]);
88+
if (packageId) {
89+
if (item._packageId === undefined) item._packageId = packageId;
90+
if (item._provenance === undefined) item._provenance = 'package';
91+
collection(type).set(`${packageId}:${name}`, item);
92+
} else {
93+
collection(type).set(name, item);
94+
}
95+
},
96+
listItems(type: string, packageId?: string) {
97+
const all = [...(byType.get(type)?.values() ?? [])];
98+
return packageId ? all.filter((it) => it._packageId === packageId) : all;
99+
},
100+
/** The real precedence: the bare slot, then prefer-local, then the first composite. */
101+
getItem(type: string, name: string, packageId?: string) {
102+
const entries = byType.get(type);
103+
if (!entries) return undefined;
104+
const direct = entries.get(name);
105+
if (direct) return direct;
106+
if (packageId) {
107+
const local = entries.get(`${packageId}:${name}`);
108+
if (local) return local;
109+
}
110+
for (const [key, item] of entries) if (key.endsWith(`:${name}`)) return item;
111+
return undefined;
112+
},
113+
getArtifactItem(type: string, name: string, packageId?: string) {
114+
const entries = [...(byType.get(type)?.entries() ?? [])];
115+
const scoped = entries.filter(([key, it]) => key.endsWith(`:${name}`) && isCodeArtifactBody(it));
116+
const local = packageId ? scoped.find(([, it]) => it._packageId === packageId) : undefined;
117+
if (local) return local[1];
118+
if (scoped[0]) return scoped[0][1];
119+
const bare = byType.get(type)?.get(name);
120+
return bare && isCodeArtifactBody(bare) ? bare : undefined;
121+
},
122+
bare(type: string, name: string) {
123+
return byType.get(type)?.get(name);
124+
},
125+
getObject: () => undefined,
126+
registerObject: () => undefined,
127+
getPackage: () => undefined,
128+
isPackageDisabled: () => false,
129+
applyNavContributions: (app: unknown) => app,
130+
};
131+
}
132+
133+
/**
134+
* The engine double: `find` / `findOne` over `sys_metadata` rows, plus the
135+
* registry. ⛔ No `insert` / `update` / `delete` — the read paths under test
136+
* issue no write verb.
137+
*/
138+
function harness(rows: StoredRow[]) {
139+
const registry = registryDouble();
140+
// What the loader registered: one packaged flow and one packaged view.
141+
registry.registerItem('flow', flowBody(SHIPPED, 'LOADER'), 'name', PACKAGE_ID);
142+
registry.registerItem('view', { name: 'pkg_view', label: 'LOADER VIEW' }, 'name', PACKAGE_ID);
143+
const matching = (where: Record<string, unknown>) => {
144+
for (const k of Object.keys(where)) {
145+
if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`);
146+
}
147+
return rows.filter((r) =>
148+
Object.entries(where).every(([k, v]) => v === undefined || (r as unknown as Record<string, unknown>)[k] === v),
149+
);
150+
};
151+
const engine: any = {
152+
async find(table: string, opts?: { where?: Record<string, unknown>; limit?: number }) {
153+
if (table !== 'sys_metadata') return [];
154+
const matched = matching(opts?.where ?? {});
155+
// `check:objectql-double-limit` — the caller's bound, applied after the filter.
156+
return opts?.limit === undefined ? matched : matched.slice(0, opts.limit);
157+
},
158+
async findOne(table: string, opts?: { where?: Record<string, unknown> }) {
159+
// `check:engine-double-contract` — refuses what the real engine refuses.
160+
assertEngineFindOnePredicate(table, opts);
161+
if (table !== 'sys_metadata') return null;
162+
return matching(opts?.where ?? {})[0] ?? null;
163+
},
164+
registry,
165+
};
166+
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map());
167+
return { protocol, registry };
168+
}
169+
170+
type Layer = Record<string, unknown> & { label?: unknown; nodes?: unknown };
171+
interface LayeredAnswer {
172+
code: Layer | null;
173+
overlay: Layer | null;
174+
overlayScope: 'org' | 'env' | null;
175+
effective: Layer | null;
176+
packageId?: string;
177+
provenance?: string;
178+
}
179+
180+
const layered = async (protocol: ObjectStackProtocolImplementation, request: Record<string, unknown>) =>
181+
(await protocol.getMetaItemLayered(request as any)) as unknown as LayeredAnswer;
182+
const byName = async (protocol: ObjectStackProtocolImplementation, request: Record<string, unknown>) =>
183+
((await protocol.getMetaItem(request as any)) as { item: Layer }).item;
184+
const listed = async (protocol: ObjectStackProtocolImplementation, name: string) =>
185+
((await protocol.getMetaItems({ type: 'flow' })) as { items: Layer[] }).items
186+
.filter((it) => it.name === name);
187+
188+
describe('[#21002] the layered read of a shipped flow name reports the loader\'s body as the effective layer', () => {
189+
it('a shipped name with an environment-wide stored row: the effective layer is the loader\'s body, and the row is a shadowed layer', async () => {
190+
const { protocol } = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'))]);
191+
192+
const answer = await layered(protocol, { type: 'flow', name: SHIPPED });
193+
194+
expect(answer.effective?.label).toBe('LOADER');
195+
expect(answer.code?.label).toBe('LOADER');
196+
// The stored row is still reported, as stored, with its own scope.
197+
expect(answer.overlay?.label).toBe('STORED');
198+
expect(answer.overlayScope).toBe('env');
199+
// The package's flags describe the effective layer, which is the loader's.
200+
expect(answer.packageId).toBe(PACKAGE_ID);
201+
expect(answer.provenance).toBe('package');
202+
});
203+
204+
it('it does so after the row was hydrated — the registry\'s bare copy of the row stands in for neither layer', async () => {
205+
const { protocol, registry } = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'))]);
206+
await protocol.getMetaItemsForExecution({ type: 'flow' }); // hydrates the row under the bare key
207+
expect(registry.bare('flow', SHIPPED)?.label).toBe('STORED');
208+
209+
const answer = await layered(protocol, { type: 'flow', name: SHIPPED });
210+
211+
expect(answer.effective?.label).toBe('LOADER');
212+
expect(answer.code?.label).toBe('LOADER');
213+
expect(answer.overlay?.label).toBe('STORED');
214+
});
215+
216+
it('the layered read, the by-name read and the list answer one and the same body', async () => {
217+
const { protocol } = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'))]);
218+
219+
const answer = await layered(protocol, { type: 'flow', name: SHIPPED });
220+
const served = await byName(protocol, { type: 'flow', name: SHIPPED });
221+
const list = await listed(protocol, SHIPPED);
222+
223+
expect(list.map((it) => it.label)).toEqual(['LOADER']);
224+
expect(answer.effective?.label).toBe(served.label);
225+
expect(answer.effective?.nodes).toEqual(served.nodes);
226+
expect(answer.effective?.nodes).toEqual(list[0].nodes);
227+
});
228+
229+
it('the package-scoped read and the plural type spelling answer the same', async () => {
230+
for (const request of [
231+
{ type: 'flow', name: SHIPPED, packageId: PACKAGE_ID },
232+
{ type: 'flows', name: SHIPPED },
233+
]) {
234+
const { protocol } = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'))]);
235+
const answer = await layered(protocol, request);
236+
expect(answer.effective?.label).toBe('LOADER');
237+
expect(answer.overlay?.label).toBe('STORED');
238+
}
239+
});
240+
241+
it('a row bound to the shipping package, or one whose own body claims its provenance, is judged by name alone', async () => {
242+
for (const row of [
243+
storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'), { package_id: PACKAGE_ID }),
244+
storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED', { _packageId: PACKAGE_ID, _provenance: 'package' })),
245+
]) {
246+
for (const request of [
247+
{ type: 'flow', name: SHIPPED },
248+
{ type: 'flow', name: SHIPPED, packageId: PACKAGE_ID },
249+
]) {
250+
const { protocol } = harness([row]);
251+
const answer = await layered(protocol, request);
252+
expect(answer.effective?.label).toBe('LOADER');
253+
expect(answer.overlay?.label).toBe('STORED');
254+
}
255+
}
256+
});
257+
258+
it('control: a flow name no managed package ships keeps its stored row as the effective layer', async () => {
259+
const { protocol } = harness([storedRow('flow', CUSTOMER, flowBody(CUSTOMER, 'CUSTOMER'))]);
260+
261+
const answer = await layered(protocol, { type: 'flow', name: CUSTOMER });
262+
263+
expect(answer.effective?.label).toBe('CUSTOMER');
264+
expect(answer.overlay?.label).toBe('CUSTOMER');
265+
expect(answer.overlayScope).toBe('env');
266+
});
267+
268+
it('control: a shipped name with no stored row is unchanged', async () => {
269+
const { protocol } = harness([]);
270+
271+
const answer = await layered(protocol, { type: 'flow', name: SHIPPED });
272+
273+
expect(answer.effective?.label).toBe('LOADER');
274+
expect(answer.overlay).toBeNull();
275+
expect(answer.overlayScope).toBeNull();
276+
});
277+
278+
it('control: an organization-scoped flow row is out of the read\'s reach', async () => {
279+
const { protocol } = harness([
280+
storedRow('flow', SHIPPED, flowBody(SHIPPED, 'ORG ROW'), { organization_id: ORG_ID }),
281+
storedRow('flow', CUSTOMER, flowBody(CUSTOMER, 'ORG ROW'), { organization_id: ORG_ID }),
282+
]);
283+
284+
const shipped = await layered(protocol, { type: 'flow', name: SHIPPED, organizationId: ORG_ID });
285+
expect(shipped.effective?.label).toBe('LOADER');
286+
expect(shipped.overlay).toBeNull();
287+
expect(shipped.overlayScope).toBeNull();
288+
289+
const customer = await layered(protocol, { type: 'flow', name: CUSTOMER, organizationId: ORG_ID });
290+
expect(customer.overlay).toBeNull();
291+
expect(customer.effective).toBeNull();
292+
});
293+
294+
it('control: an overlay of a packaged type in the overlay regime is still the effective layer', async () => {
295+
const { protocol } = harness([storedRow('view', 'pkg_view', { name: 'pkg_view', label: 'OVERLAY VIEW' })]);
296+
297+
const answer = await layered(protocol, { type: 'view', name: 'pkg_view' });
298+
299+
expect(answer.effective?.label).toBe('OVERLAY VIEW');
300+
expect(answer.code?.label).toBe('LOADER VIEW');
301+
expect(answer.overlay?.label).toBe('OVERLAY VIEW');
302+
});
303+
});

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

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9035,7 +9035,10 @@ export class ObjectStackProtocolImplementation implements
90359035
* Phase 3a-layered-get: return the 3 layers of a metadata item
90369036
* separately — `code` (artifact-loaded baseline), `overlay` (per-org
90379037
* customisation row, if any), and `effective` (what `getMetaItem`
9038-
* would return, i.e. overlay-wins merge).
9038+
* would return, i.e. overlay-wins merge — except for a flow name the
9039+
* loader ships, where `getMetaItem` serves the loader's body, so
9040+
* `effective` is the code layer and a stored row of that name is
9041+
* reported in `overlay` as a shadowed layer, #21002).
90399042
*
90409043
* Drives the "Code default vs Overlay vs Effective" diff tab in the
90419044
* generic Metadata Resource Edit page. Admins can see exactly what
@@ -9369,7 +9372,31 @@ export class ObjectStackProtocolImplementation implements
93699372
// the tenant actually stored: a code-declared extension is not a tenant
93709373
// customisation, and #7556 drew that boundary deliberately (the same
93719374
// reason `governServedItem` is called here and never on `overlay`).
9372-
const effectiveBase: unknown | null = overlay !== null
9375+
//
9376+
// ── [#21002, #20761 ruling rule 1, ADR-0126 §2 / §3] A shipped FLOW name ──
9377+
//
9378+
// `flow` is Regime C: the packaged base is locked, "⛔ Never silent
9379+
// override, never an overlay read path". For a flow name the loader's
9380+
// set holds, the flattened view (#20913) and {@link getMetaItem}
9381+
// (#20946) serve the loader's body, and a stored row of that name is
9382+
// neither merged into the package's slot nor lets it stand in for it.
9383+
// `effective` is "what `getMetaItem` would return", so for such a name
9384+
// it is the CODE layer, decided by the SAME stored-row predicate,
9385+
// {@link isShippedFlowName}, judged by NAME — ⛔ no fourth precedence
9386+
// path of this method's own. Overlay-wins here served the stored body
9387+
// as the effective layer while the lock and provenance flags (resolved
9388+
// from `code` first, below) named the package.
9389+
//
9390+
// The stored row is still REPORTED: `overlay` / `overlayScope` keep the
9391+
// row as stored, a shadowed layer of its own scope beside the effective
9392+
// one, never the effective layer itself. The code layer needs no
9393+
// registry-half call ({@link isStoredFlowEntryOfShippedName}): it reads
9394+
// the loader's set (`lookupArtifactItem`, blind to tenant-authored rows)
9395+
// before the registry's bare slot, and a shipped name is by definition
9396+
// one that set holds. Every other type, and a flow name no managed
9397+
// package ships, keeps overlay-wins. What becomes of the stored rows
9398+
// themselves (keep, refuse, migrate) is not decided here.
9399+
const effectiveBase: unknown | null = overlay !== null && !this.isShippedFlowName(request.type, request.name)
93739400
? this.foldObjectExtendersFromRegistry(request.type, request.name, overlay)
93749401
: code;
93759402
const effective: unknown | null = this.governServedObject(request.type, effectiveBase);

0 commit comments

Comments
 (0)