Skip to content

Commit d34aa58

Browse files
fix(metadata-protocol): the layered read's code layer is null for a name no package ships, before and after hydration (#21059) (#21168)
Fixes #21059 Clause-②: no ## What this changes The layered door (`GET /api/v1/meta/:type/:name/layers`) reports three layers: the code layer, the stored layer and the effective layer. The spec's layer 1 says the code layer is null "when no artifact ships this item (it exists only as an overlay)". The method's own #5707 / #5840 rule says a layer is asserted only from a read that happened. For a name no package ships, the code layer was null only until a hydration had put the stored row into the registry's bare slot. After that, the code layer was the stored body, and the lock and provenance flags were derived from it. Below the artifact lookup, the code layer's fallback reads the bare slot. The fallback is there for items registered at runtime with no package, and hydrated rows land in the same slot. `getMetaItemLayered`'s code-layer fallback now ignores a registry answer that carries the tenant mark. The hydrator already writes that mark on every stored row it registers (ADR-0010 tenant provenance, read through `isTenantAuthored` from `@objectstack/metadata-core`). No new mark is added. The same rule covers the alias retry. - **Unshipped name:** the code layer is null before and after hydration. - **Stored body with package-provenance stamps under an unshipped name:** this follows triage's ruling, which the maintainer can overturn. A stamp is not an artifact read, so the code layer is null. The lock-state resolver gets no code layer for the item, and its flags are the same before and after hydration. - **Shipped name:** unchanged. The artifact lookup answers before the fallback is reached. - **Item registered at runtime with no package:** unchanged. It carries no tenant mark and keeps its code layer. The effective-layer binding from #21002 and `isShippedFlowName` are not edited. The stored rows are not changed. ## Measured: the layered door, showcase composition on a database file The first boot reads the door with the rows stored and none of them hydrated. The second boot is cold, on the same file, after the boot hydration. Every reading answered 200. | Name class | Before the fix: before hydration | Before the fix: after hydration | After the fix: before hydration | After the fix: after hydration | |:--|:--|:--|:--|:--| | Unshipped, stored row | code layer null; no provenance flag | **code layer = stored body**; provenance flag `org` | code layer null; no provenance flag | **code layer null**; no provenance flag | | Unshipped, stored body with package-provenance stamps | code layer null; provenance flag `package`, package flag = showcase | **code layer = stored body**; provenance flag `org`, package flag = showcase | code layer null; provenance flag `package`, package flag = showcase | **code layer null**; provenance flag `package`, package flag = showcase | | Shipped, with a stored row (control) | code layer = loader body; `package` | code layer = loader body; `package` | same | same | | Registered at runtime, no package (control) | code layer = registered body | code layer = registered body | same | same | | Unshipped, on the deprecated layers flag of the by-name door | not read | code layer = stored body | not read | code layer null | For every unshipped and runtime reading, lock is `none`, the item is editable and deletable, and it is not resettable. The shipped name is resettable. The table was taken before the fix on a build of `origin/main` `e952cff578`, where the dist carried no fix marker. It was taken after the fix at `4a5639294d`. It was taken again at the merged head `317eca662e`, where all 9 readings were identical to the post-fix run. ## Tests - `packages/metadata-protocol/src/protocol.layered-code-unshipped-name.test.ts` (new, 7 cases). It covers the boot hydration and the read-side hydration, the package-scoped read, the plural spelling, the stamped corner (with and without a package binding), a non-flow type (view), and both controls. - `packages/qa/dogfood/test/flow-unshipped-name-layered-code.dogfood.test.ts` (new, 6 cases). It boots the showcase twice on one database file. It covers the layered door and the deprecated layers flag, the stamped corner's flags across hydration, and both controls. - `@objectstack/metadata-protocol` full suite: 198 files passed and 3 skipped (201); 2962 tests passed and 19 skipped. Typecheck exit 0, and the new pin is in the program (`--listFiles` count 1). Dogfood `tsc --noEmit` exit 0, with the new pin in its program. - Consumers of the layered read, which import this package (a downstream direction), all green: objectql 3 files, 29 tests; rest 11 files, 173 tests; runtime 1 file, 10 tests; plugin-security 5 files, 134 tests. Dogfood, 9 files, 53 tests, at the merged head: the new pin, the #21002 layered and published-door pins, permission projection, two doors, multi-package artifact, and the two object-extension meta reads. **Ablation.** The fix was committed first. `scripts/ablation-replace.mjs` replaced the tenant-mark test with an always-false call; the anchor went from 1 hit to 0 and the replacement from 0 hits to 1. - Unit pin, which resolves source: 5 failed (every subject case) and 2 passed (both controls). The restore left the blob equal to `HEAD` and `git diff HEAD` empty. - Dogfood pin, which resolves `dist/`: the package was rebuilt, and `ablation-dist-preflight --absent` found the guard absent from all 24 built files. 3 failed (the subject cases) and 3 passed (the hydration check and both controls). After the restore leg, the blob equals `HEAD`. The package was rebuilt, the preflight found the guard present, and the tree was clean. - Two attempts were not measurements. A first unit attempt used a replacement that was a substring of the anchor, so the tool refused it and restored before any test ran. A first dogfood attempt used a mutation the declaration build rejects. Its JS reached `dist/`, so `dist/` was rebuilt from the restored source before the next run. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `317eca662e` derived 74 commands, and all 74 were run with their exit codes recorded before any pipe. `--ran` reports 74 derived, 74 run, 0 NOT-MEASURED and 0 UNRUN. Two first-pass results were reruns, not failures. `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: 8 packages outside this diff had no `dist/`); it exited 0 after they were built. `check:query-options-erasure` crashed reading a temporary measurement file that was removed while it ran; it exited 0 on the rerun. `pnpm lint` (the whole repository, `eslint . --no-inline-config`) exited 0 with no findings at `317eca662e`. ## Acceptance notes - **The stamped corner's flags now come from the stored layer, on both sides of the hydration.** The lock source line, code layer first and then the stored layer, is unchanged and outside this card's surface. Whether a stored body's own provenance stamps should decide the flags for a name no package ships is left to triage. - **The rule applies to every type, not only `flow`.** The boot `object` limb writes the same tenant mark, so an object no package owns also has a null code layer after hydration. The unit pin covers flow and view. Objects are not pinned separately; the objectql and rest layered-read suites above are green. - **A sibling with the same lookup shape is not edited.** `readCodeLayerForCarryForward` uses the artifact lookup, then the registry's bare slot. It is called only when no stored row exists for the save's scope. This is a source reading, not measured. - `scripts/engine-double-contract.pinned.json` gains one generated row (`--write`) for the new unit pin's `findOne` double. That double is copied from the pinned one in `protocol.flow-layered-shipped-name.test.ts`. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 327391c commit d34aa58

5 files changed

Lines changed: 660 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's code layer is empty for an item no package ships, whether or not a stored copy of it has been loaded into the registry (#21059)
6+
7+
Clause-②: no
8+
9+
The layered read, `GET /api/v1/meta/:type/:name/layers`, reports an item's code layer as the packaged definition, and as empty when no package ships the item and it exists only as a stored customization. For an item no package ships, it answered that correctly only until the stored copy had been loaded into the in-memory registry, for example by a restart's startup load or by a list read. After that, the code layer answered the stored copy, and the lock and provenance flags were derived from it. So the same read of the same item gave two answers, depending on what had been loaded.
10+
11+
The code layer now skips a stored copy the registry holds, using the tenant-authorship mark the startup load already puts on every stored copy it registers. An item no package ships has an empty code layer before and after the load, and its flags come from the stored layer both times. This includes a stored copy whose own content claims a package's provenance: a claim is not a packaged definition. The deprecated layers flag on the by-name read, `GET /api/v1/meta/:type/:name`, answers the same.
12+
13+
Packaged items keep their packaged code layer. An item registered at runtime with no package keeps its code layer, as before. The stored rows are not changed.
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21059] The layered read's code layer is the packaged artifact, or an item
5+
* registered at runtime with no package — never a stored row that a hydration
6+
* put in the registry's plain slot. For a name no package ships, the read
7+
* answers a null code layer before a hydration and after it alike.
8+
*
9+
* The spec's layer 1 reads "`null` when no artifact ships this item (it exists
10+
* only as an overlay)", and the method's own #5707 / #5840 rule allows a layer
11+
* only from a read that happened. The fallback below the artifact lookup reads
12+
* the registry's plain slot, which it does for items registered at runtime
13+
* without a package; a hydrated stored row lands in that same slot. So once a
14+
* hydration had run, the code layer was the stored body and the lock flags were
15+
* resolved from it, while the same read before the hydration answered a null
16+
* code layer. The discriminator is the tenant marker the hydrator already
17+
* writes on every row it registers; nothing new is stamped.
18+
*
19+
* Triage's ruling on the open corner is pinned as well: a stored body that
20+
* carries package-provenance stamps under a name no package ships is not a code
21+
* layer either, so the lock-state resolver sees no code layer for it and its
22+
* flags are the same before and after a hydration.
23+
*
24+
* The registry double is the one `protocol.flow-layered-shipped-name.test.ts`
25+
* uses: the real `SchemaRegistry`'s key shapes (`<package>:<name>` for a loader
26+
* entry, the bare name for everything else), its `getItem` precedence (the bare
27+
* slot first) and its artifact lookup. `registerItem` stamps a package entry the
28+
* way `applyProtection` does and leaves a bare registration alone.
29+
* `@objectstack/objectql` cannot be imported here: it depends on this package.
30+
* The cold-boot proof over the real composition is
31+
* `packages/qa/dogfood/test/flow-unshipped-name-layered-code.dogfood.test.ts`.
32+
*
33+
* Controls: a shipped name keeps the loader's body as its code layer on both
34+
* sides of a hydration, and an item registered at runtime with no package keeps
35+
* its code layer — the fallback's stated purpose.
36+
*/
37+
import { describe, expect, it } from 'vitest';
38+
import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core';
39+
import { ObjectStackProtocolImplementation } from './protocol.js';
40+
41+
const PACKAGE_ID = 'com.example.pkg';
42+
const SHIPPED = 'pkg_flow';
43+
const UNSHIPPED = 'customer_flow';
44+
const STAMPED = 'stamped_flow';
45+
const RUNTIME = 'runtime_flow';
46+
const UNSHIPPED_VIEW = 'customer_view';
47+
48+
const flowBody = (name: string, label: string, extra: Record<string, unknown> = {}) => ({
49+
name,
50+
label,
51+
type: 'autolaunched',
52+
nodes: [
53+
{ id: 'start', type: 'start', label: 'Start' },
54+
{ id: 'end', type: 'end', label: 'End' },
55+
],
56+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
57+
...extra,
58+
});
59+
60+
/** A body that claims a package's provenance under a name that package does not ship. */
61+
const stampedBody = (name: string) => flowBody(name, 'STAMPED', {
62+
_packageId: PACKAGE_ID,
63+
_packageVersion: '1.0.0',
64+
_provenance: 'package',
65+
});
66+
67+
interface StoredRow {
68+
id: string;
69+
type: string;
70+
name: string;
71+
organization_id: string | null;
72+
package_id: string | null;
73+
state: string;
74+
metadata: string;
75+
}
76+
77+
const storedRow = (type: string, name: string, body: unknown, extra: Partial<StoredRow> = {}): StoredRow => ({
78+
id: `r_${type}_${name}_${extra.organization_id ?? 'env'}`,
79+
type,
80+
name,
81+
organization_id: null,
82+
package_id: null,
83+
state: 'active',
84+
metadata: JSON.stringify(body),
85+
...extra,
86+
});
87+
88+
/** The registry double — see the header. */
89+
function registryDouble() {
90+
const byType = new Map<string, Map<string, Record<string, unknown>>>();
91+
const collection = (type: string) => {
92+
if (!byType.has(type)) byType.set(type, new Map());
93+
return byType.get(type)!;
94+
};
95+
return {
96+
registerItem(type: string, item: Record<string, unknown>, keyField = 'name', packageId?: string) {
97+
const name = String(item[keyField]);
98+
if (packageId) {
99+
if (item._packageId === undefined) item._packageId = packageId;
100+
if (item._provenance === undefined) item._provenance = 'package';
101+
collection(type).set(`${packageId}:${name}`, item);
102+
} else {
103+
collection(type).set(name, item);
104+
}
105+
},
106+
listItems(type: string, packageId?: string) {
107+
const all = [...(byType.get(type)?.values() ?? [])];
108+
return packageId ? all.filter((it) => it._packageId === packageId) : all;
109+
},
110+
/** The real precedence: the bare slot, then prefer-local, then the first composite. */
111+
getItem(type: string, name: string, packageId?: string) {
112+
const entries = byType.get(type);
113+
if (!entries) return undefined;
114+
const direct = entries.get(name);
115+
if (direct) return direct;
116+
if (packageId) {
117+
const local = entries.get(`${packageId}:${name}`);
118+
if (local) return local;
119+
}
120+
for (const [key, item] of entries) if (key.endsWith(`:${name}`)) return item;
121+
return undefined;
122+
},
123+
getArtifactItem(type: string, name: string, packageId?: string) {
124+
const entries = [...(byType.get(type)?.entries() ?? [])];
125+
const scoped = entries.filter(([key, it]) => key.endsWith(`:${name}`) && isCodeArtifactBody(it));
126+
const local = packageId ? scoped.find(([, it]) => it._packageId === packageId) : undefined;
127+
if (local) return local[1];
128+
if (scoped[0]) return scoped[0][1];
129+
const bare = byType.get(type)?.get(name);
130+
return bare && isCodeArtifactBody(bare) && (!packageId || bare._packageId === packageId) ? bare : undefined;
131+
},
132+
bare(type: string, name: string) {
133+
return byType.get(type)?.get(name);
134+
},
135+
getObject: () => undefined,
136+
registerObject: () => undefined,
137+
getPackage: () => undefined,
138+
isPackageDisabled: () => false,
139+
applyNavContributions: (app: unknown) => app,
140+
};
141+
}
142+
143+
/**
144+
* The engine double: `find` / `findOne` over `sys_metadata` rows, plus the
145+
* registry. ⛔ No `insert` / `update` / `delete` — the paths under test (the
146+
* layered read and the two hydrations) issue no write verb.
147+
*/
148+
function harness(rows: StoredRow[]) {
149+
const registry = registryDouble();
150+
// What the loader registered: one packaged flow.
151+
registry.registerItem('flow', flowBody(SHIPPED, 'LOADER'), 'name', PACKAGE_ID);
152+
const matching = (where: Record<string, unknown>) => {
153+
for (const k of Object.keys(where)) {
154+
if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`);
155+
}
156+
return rows.filter((r) =>
157+
Object.entries(where).every(([k, v]) => v === undefined || (r as unknown as Record<string, unknown>)[k] === v),
158+
);
159+
};
160+
const engine: any = {
161+
async find(table: string, opts?: { where?: Record<string, unknown>; limit?: number }) {
162+
if (table !== 'sys_metadata') return [];
163+
const matched = matching(opts?.where ?? {});
164+
// `check:objectql-double-limit` — the caller's bound, applied after the filter.
165+
return opts?.limit === undefined ? matched : matched.slice(0, opts.limit);
166+
},
167+
async findOne(table: string, opts?: { where?: Record<string, unknown> }) {
168+
// `check:engine-double-contract` — refuses what the real engine refuses.
169+
assertEngineFindOnePredicate(table, opts);
170+
if (table !== 'sys_metadata') return null;
171+
return matching(opts?.where ?? {})[0] ?? null;
172+
},
173+
registry,
174+
};
175+
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map());
176+
return { protocol, registry };
177+
}
178+
179+
type Layer = Record<string, unknown> & { label?: unknown };
180+
interface LayeredAnswer {
181+
code: Layer | null;
182+
overlay: Layer | null;
183+
overlayScope: 'org' | 'env' | null;
184+
effective: Layer | null;
185+
lock: string;
186+
lockSource?: string;
187+
provenance?: string;
188+
packageId?: string;
189+
packageVersion?: string;
190+
editable: boolean;
191+
deletable: boolean;
192+
resettable: boolean;
193+
}
194+
195+
const layered = async (protocol: ObjectStackProtocolImplementation, request: Record<string, unknown>) =>
196+
(await protocol.getMetaItemLayered(request as any)) as unknown as LayeredAnswer;
197+
198+
/** The ADR-0010 protection flags the response derives from its layers. */
199+
const flagsOf = (answer: LayeredAnswer) => ({
200+
lock: answer.lock,
201+
lockSource: answer.lockSource,
202+
provenance: answer.provenance,
203+
packageId: answer.packageId,
204+
packageVersion: answer.packageVersion,
205+
editable: answer.editable,
206+
deletable: answer.deletable,
207+
resettable: answer.resettable,
208+
});
209+
210+
/** Boot hydration: the stored rows go into the registry, as on a cold boot. */
211+
async function bootHydrate(protocol: ObjectStackProtocolImplementation) {
212+
const receipt = await protocol.loadMetaFromDb();
213+
expect(receipt.storeUnavailable).toBe(false);
214+
expect(receipt.errors).toBe(0);
215+
return receipt;
216+
}
217+
218+
describe('[#21059] the layered read answers a null code layer for a name no package ships, before and after hydration', () => {
219+
it('an unshipped flow name: null before boot hydration and null after it, with the stored row reported as the stored layer', async () => {
220+
const { protocol, registry } = harness([storedRow('flow', UNSHIPPED, flowBody(UNSHIPPED, 'STORED'))]);
221+
222+
const before = await layered(protocol, { type: 'flow', name: UNSHIPPED });
223+
expect(registry.bare('flow', UNSHIPPED)).toBeUndefined();
224+
expect(before.code).toBeNull();
225+
expect(before.overlay?.label).toBe('STORED');
226+
227+
await bootHydrate(protocol);
228+
// The hydration really ran: the row now sits in the registry's plain slot.
229+
expect(registry.bare('flow', UNSHIPPED)?.label).toBe('STORED');
230+
231+
const after = await layered(protocol, { type: 'flow', name: UNSHIPPED });
232+
expect(after.code).toBeNull();
233+
expect(after.overlay?.label).toBe('STORED');
234+
expect(after.overlayScope).toBe('env');
235+
expect(after.effective?.label).toBe('STORED');
236+
expect(flagsOf(after)).toEqual(flagsOf(before));
237+
});
238+
239+
it('the read-side hydration is held the same way', async () => {
240+
const { protocol, registry } = harness([storedRow('flow', UNSHIPPED, flowBody(UNSHIPPED, 'STORED'))]);
241+
await protocol.getMetaItemsForExecution({ type: 'flow' });
242+
expect(registry.bare('flow', UNSHIPPED)?.label).toBe('STORED');
243+
244+
const after = await layered(protocol, { type: 'flow', name: UNSHIPPED });
245+
246+
expect(after.code).toBeNull();
247+
expect(after.effective?.label).toBe('STORED');
248+
});
249+
250+
it('the package-scoped read and the plural type spelling answer the same', async () => {
251+
for (const request of [
252+
{ type: 'flow', name: UNSHIPPED, packageId: PACKAGE_ID },
253+
{ type: 'flows', name: UNSHIPPED },
254+
]) {
255+
const { protocol } = harness([storedRow('flow', UNSHIPPED, flowBody(UNSHIPPED, 'STORED'))]);
256+
await bootHydrate(protocol);
257+
const after = await layered(protocol, request);
258+
expect(after.code).toBeNull();
259+
expect(after.overlay?.label).toBe('STORED');
260+
}
261+
});
262+
263+
it('a stored body carrying package-provenance stamps under an unshipped name is no code layer, and the lock-state flags do not move across hydration', async () => {
264+
for (const row of [
265+
storedRow('flow', STAMPED, stampedBody(STAMPED)),
266+
storedRow('flow', STAMPED, stampedBody(STAMPED), { package_id: PACKAGE_ID }),
267+
]) {
268+
const { protocol, registry } = harness([row]);
269+
270+
const before = await layered(protocol, { type: 'flow', name: STAMPED });
271+
expect(before.code).toBeNull();
272+
273+
await bootHydrate(protocol);
274+
expect(registry.bare('flow', STAMPED)?.label).toBe('STAMPED');
275+
276+
for (const request of [
277+
{ type: 'flow', name: STAMPED },
278+
{ type: 'flow', name: STAMPED, packageId: PACKAGE_ID },
279+
]) {
280+
const after = await layered(protocol, request);
281+
expect(after.code).toBeNull();
282+
expect(after.overlay?.label).toBe('STAMPED');
283+
expect(flagsOf(after)).toEqual(flagsOf(before));
284+
}
285+
}
286+
});
287+
288+
it('not a flow-only rule: an unshipped view\'s hydrated row is no code layer either', async () => {
289+
const { protocol, registry } = harness([
290+
storedRow('view', UNSHIPPED_VIEW, { name: UNSHIPPED_VIEW, label: 'STORED VIEW' }),
291+
]);
292+
293+
const before = await layered(protocol, { type: 'view', name: UNSHIPPED_VIEW });
294+
expect(before.code).toBeNull();
295+
296+
await bootHydrate(protocol);
297+
expect(registry.bare('view', UNSHIPPED_VIEW)?.label).toBe('STORED VIEW');
298+
299+
const after = await layered(protocol, { type: 'view', name: UNSHIPPED_VIEW });
300+
expect(after.code).toBeNull();
301+
expect(after.effective?.label).toBe('STORED VIEW');
302+
expect(flagsOf(after)).toEqual(flagsOf(before));
303+
});
304+
305+
it('control: a shipped name keeps the loader\'s body as its code layer, before and after hydration', async () => {
306+
const { protocol } = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'))]);
307+
308+
const before = await layered(protocol, { type: 'flow', name: SHIPPED });
309+
expect(before.code?.label).toBe('LOADER');
310+
311+
await bootHydrate(protocol);
312+
313+
const after = await layered(protocol, { type: 'flow', name: SHIPPED });
314+
expect(after.code?.label).toBe('LOADER');
315+
expect(after.overlay?.label).toBe('STORED');
316+
expect(after.packageId).toBe(PACKAGE_ID);
317+
expect(after.provenance).toBe('package');
318+
expect(flagsOf(after)).toEqual(flagsOf(before));
319+
});
320+
321+
it('control: an item registered at runtime with no package keeps its code layer', async () => {
322+
const { protocol, registry } = harness([]);
323+
registry.registerItem('flow', flowBody(RUNTIME, 'RUNTIME'));
324+
325+
const before = await layered(protocol, { type: 'flow', name: RUNTIME });
326+
expect(before.code?.label).toBe('RUNTIME');
327+
expect(before.overlay).toBeNull();
328+
expect(before.effective?.label).toBe('RUNTIME');
329+
330+
await bootHydrate(protocol);
331+
332+
const after = await layered(protocol, { type: 'flow', name: RUNTIME });
333+
expect(after.code?.label).toBe('RUNTIME');
334+
expect(after.effective?.label).toBe('RUNTIME');
335+
});
336+
});

0 commit comments

Comments
 (0)