Skip to content

Commit d690943

Browse files
committed
fix(metadata-protocol): the layered read of a shipped flow name reports the loader's body as the effective layer
For a flow name the loader ships, getMetaItemLayered now decides its effective layer with the stored-row predicate the list and the by-name read already call (isShippedFlowName): the code layer is effective, and a stored row of that name stays in the overlay layer as a shadowed layer of its own scope. Every other type, and a flow name no package ships, keeps overlay-wins. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2f2fa11 commit d690943

2 files changed

Lines changed: 332 additions & 2 deletions

File tree

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

0 commit comments

Comments
 (0)