Skip to content

Commit e2431ed

Browse files
os-zhuangclaude
andcommitted
fix(objectql): artifact lock envelope survives overlay hydration and reset (ADR-0010 §3.3)
On a control-plane kernel, GET /meta/:type hydrates sys_metadata overlay rows into the SchemaRegistry under the PLAIN key — shadowing the packaged artifact registered under `<packageId>:<name>`. Every envelope reader (lookupArtifactItem / getEffectiveLock / isArtifactBacked) resolved the shadow instead of the artifact, so a `_lock: full` app read back as unlocked after PUT+GET, and DELETE (reset) only removed the DB row — the polluted shadow survived until restart. Fix, three layers: - SchemaRegistry.getArtifactItem(): shadow-immune artifact lookup (composite keys first, requires a real `_packageId`, excludes the 'sys_metadata' rehydration sentinel). All protocol envelope readers and getMetaItemLayered's code layer now use it. - Hydration (getMetaItems / loadMetaFromDb) grafts the artifact's `_lock`/`_packageId`/`_provenance` onto the overlay body before registering, so registry-direct readers keep the envelope: overlay content wins, artifact protection wins. - SchemaRegistry.removeRuntimeShadow() + restoreArtifactRegistryView(): reset drops the plain-key shadow so the artifact view is restored without a restart; a no-op DELETE self-heals pre-existing pollution. Draft discards keep the shadow (the active overlay may still exist). Regression suite: protocol-registry-shadow.test.ts pins the PUT→GET→DELETE envelope round-trip, reset healing, and shadow-immune 403 item_locked enforcement on scoped kernels. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent cbeef9e commit e2431ed

3 files changed

Lines changed: 509 additions & 59 deletions

File tree

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0010 §3.3 — artifact protection envelope vs. registry shadows.
5+
*
6+
* Regression suite for the "registry pollution" bug: on a control-plane
7+
* kernel (`environmentId === undefined`), PUT /meta/app/<name> on a
8+
* `_lock: full` artifact-backed app succeeded (the L3 gate is
9+
* intentionally bypassed there), and the next GET list hydrated the
10+
* overlay body into the SchemaRegistry under the PLAIN key — shadowing
11+
* the packaged artifact registered under `<packageId>:<name>`. Every
12+
* envelope reader (`lookupArtifactItem` / `getEffectiveLock` /
13+
* `isArtifactBacked`) resolved the shadow instead of the artifact, so
14+
* `_lock`/`_packageId`/`_provenance` read back as undefined. A
15+
* subsequent DELETE (reset) removed the sys_metadata row but left the
16+
* shadow in place — the lock stayed lost until restart.
17+
*
18+
* Pinned here:
19+
* 1. The hydrated shadow carries the artifact's protection envelope.
20+
* 2. The list/get surfaces keep `_lock` through PUT → GET → DELETE.
21+
* 3. Reset heals the registry: the artifact value is visible again.
22+
* 4. Lock enforcement on scoped kernels is shadow-immune.
23+
*/
24+
25+
import { describe, it, expect, beforeEach } from 'vitest';
26+
import { ObjectStackProtocolImplementation } from './protocol.js';
27+
import { ObjectQL } from './engine.js';
28+
import { SchemaRegistry } from './registry.js';
29+
30+
const PKG = 'com.objectstack.test-pkg';
31+
32+
const sysMetadataObject = {
33+
name: 'sys_metadata',
34+
label: 'System Metadata',
35+
fields: {
36+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
37+
type: { name: 'type', label: 'Type', type: 'text' as const, required: true },
38+
name: { name: 'name', label: 'Name', type: 'text' as const, required: true },
39+
organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const },
40+
metadata: { name: 'metadata', label: 'Body', type: 'longtext' as const },
41+
checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 },
42+
state: { name: 'state', label: 'State', type: 'text' as const },
43+
version: { name: 'version', label: 'Version', type: 'number' as const },
44+
created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const },
45+
updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const },
46+
},
47+
};
48+
49+
const sysMetadataHistoryObject = {
50+
name: 'sys_metadata_history',
51+
label: 'Metadata History',
52+
fields: {
53+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
54+
event_seq: { name: 'event_seq', label: 'Seq', type: 'number' as const, required: true },
55+
type: { name: 'type', label: 'Type', type: 'text' as const, required: true },
56+
name: { name: 'name', label: 'Name', type: 'text' as const, required: true },
57+
version: { name: 'version', label: 'Version', type: 'number' as const, required: true },
58+
operation_type: { name: 'operation_type', label: 'Op', type: 'text' as const, required: true },
59+
metadata: { name: 'metadata', label: 'Body', type: 'longtext' as const },
60+
checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 },
61+
previous_checksum: { name: 'previous_checksum', label: 'Prev', type: 'text' as const, maxLength: 71 },
62+
change_note: { name: 'change_note', label: 'Note', type: 'longtext' as const },
63+
source: { name: 'source', label: 'Source', type: 'text' as const },
64+
organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const },
65+
recorded_by: { name: 'recorded_by', label: 'By', type: 'text' as const },
66+
recorded_at: { name: 'recorded_at', label: 'At', type: 'datetime' as const, required: true },
67+
},
68+
};
69+
70+
/** Equality-only in-memory driver — same shape as the PR-10d.4 suite. */
71+
function makeMemoryDriver() {
72+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
73+
const storeFor = (obj: string) => {
74+
let s = stores.get(obj);
75+
if (!s) { s = new Map(); stores.set(obj, s); }
76+
return s;
77+
};
78+
let nextId = 0;
79+
80+
const matchesWhere = (row: Record<string, unknown>, where: any): boolean => {
81+
if (!where || typeof where !== 'object') return true;
82+
if (Array.isArray(where.$and)) return where.$and.every((w: any) => matchesWhere(row, w));
83+
if (Array.isArray(where.$or)) return where.$or.some((w: any) => matchesWhere(row, w));
84+
for (const [k, v] of Object.entries(where)) {
85+
if (k.startsWith('$')) continue;
86+
const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
87+
const a = row[k] === undefined ? null : row[k];
88+
const b = expected === undefined ? null : expected;
89+
if (a !== b) return false;
90+
}
91+
return true;
92+
};
93+
94+
const driver: any = {
95+
name: 'memory',
96+
version: '0.0.0',
97+
supports: {} as any,
98+
async connect() {},
99+
async disconnect() {},
100+
async checkHealth() { return true; },
101+
async execute() { return null; },
102+
async find(object: string, ast: any) {
103+
return Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where));
104+
},
105+
findStream() { throw new Error('not implemented'); },
106+
async findOne(object: string, ast: any) {
107+
for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r;
108+
return null;
109+
},
110+
async create(object: string, data: Record<string, unknown>) {
111+
nextId += 1;
112+
const id = (data.id as string) ?? `r_${nextId}`;
113+
const row = { ...data, id };
114+
storeFor(object).set(id, row);
115+
return row;
116+
},
117+
async update(object: string, id: string, data: Record<string, unknown>) {
118+
const s = storeFor(object);
119+
const cur = s.get(id);
120+
if (!cur) throw new Error(`not found: ${object}/${id}`);
121+
const updated = { ...cur, ...data, id };
122+
s.set(id, updated);
123+
return updated;
124+
},
125+
async upsert(object: string, data: Record<string, unknown>) {
126+
const id = data.id as string | undefined;
127+
if (id && storeFor(object).has(id)) return this.update(object, id, data);
128+
return this.create(object, data);
129+
},
130+
async delete(object: string, id: string) {
131+
return storeFor(object).delete(id);
132+
},
133+
async count(object: string, ast: any) {
134+
return (await this.find(object, ast)).length;
135+
},
136+
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
137+
return Promise.all(rows.map((r) => this.create(object, r)));
138+
},
139+
async bulkUpdate() { return []; },
140+
async bulkDelete() {},
141+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
142+
async commit() {},
143+
async rollback() {},
144+
};
145+
return { driver, stores };
146+
}
147+
148+
/** Artifact app shipped by a code package with a hard lock. */
149+
function artifactApp() {
150+
return {
151+
name: 'setup',
152+
label: 'Setup',
153+
navigation: [],
154+
_packageId: PKG,
155+
_packageVersion: '1.0.0',
156+
_provenance: 'package',
157+
_lock: 'full',
158+
_lockReason: 'Core admin UI shipped by the platform package.',
159+
};
160+
}
161+
162+
const overlayBody = { name: 'setup', label: 'Setup HACKED', navigation: [] };
163+
164+
function findByName(items: any[], name: string): any {
165+
return (items as any[]).find((it) => it?.name === name);
166+
}
167+
168+
describe('registry shadow — control-plane PUT → GET → DELETE keeps the artifact envelope', () => {
169+
let engine: ObjectQL;
170+
let protocol: ObjectStackProtocolImplementation;
171+
172+
beforeEach(async () => {
173+
engine = new ObjectQL();
174+
const { driver } = makeMemoryDriver();
175+
engine.registerDriver(driver, true);
176+
await engine.init();
177+
engine.registry.registerObject(sysMetadataObject as any);
178+
engine.registry.registerObject(sysMetadataHistoryObject as any);
179+
engine.registry.registerItem('app', artifactApp(), 'name', PKG);
180+
// No environmentId — single-kernel / control-plane mode, where the
181+
// L3 lock gate is bypassed and the GET list hydrates overlay rows
182+
// into the process-wide registry.
183+
protocol = new ObjectStackProtocolImplementation(engine);
184+
});
185+
186+
it('GET list while the overlay row exists: overlay content wins, artifact envelope wins', async () => {
187+
await protocol.saveMetaItem({ type: 'app', name: 'setup', item: { ...overlayBody } });
188+
189+
const res = await protocol.getMetaItems({ type: 'app' });
190+
const setup = findByName((res as any).items, 'setup');
191+
expect(setup).toBeDefined();
192+
expect(setup.label).toBe('Setup HACKED'); // overlay content
193+
expect(setup._lock).toBe('full'); // artifact envelope (ADR-0010 §3.3)
194+
expect(setup._packageId).toBe(PKG);
195+
expect(setup._provenance).toBe('package');
196+
});
197+
198+
it('the hydrated plain-key shadow itself carries the artifact envelope', async () => {
199+
await protocol.saveMetaItem({ type: 'app', name: 'setup', item: { ...overlayBody } });
200+
await protocol.getMetaItems({ type: 'app' }); // triggers hydration
201+
202+
// Registry-direct read (what nav/UI code does) must not see a
203+
// stripped envelope even though the overlay body shadows the
204+
// artifact on the plain key.
205+
const direct: any = engine.registry.getItem('app', 'setup');
206+
expect(direct.label).toBe('Setup HACKED');
207+
expect(direct._lock).toBe('full');
208+
expect(direct._packageId).toBe(PKG);
209+
});
210+
211+
it('DELETE (reset) heals the registry: artifact value and lock are back without a restart', async () => {
212+
await protocol.saveMetaItem({ type: 'app', name: 'setup', item: { ...overlayBody } });
213+
await protocol.getMetaItems({ type: 'app' }); // pollute via hydration
214+
215+
const del = await protocol.deleteMetaItem({ type: 'app', name: 'setup' });
216+
expect(del.success).toBe(true);
217+
expect(del.reset).toBe(true);
218+
219+
// Registry-direct read resolves the packaged artifact again.
220+
const direct: any = engine.registry.getItem('app', 'setup');
221+
expect(direct.label).toBe('Setup');
222+
expect(direct._lock).toBe('full');
223+
expect(direct._packageId).toBe(PKG);
224+
225+
// And the protocol list surface agrees.
226+
const res = await protocol.getMetaItems({ type: 'app' });
227+
const setup = findByName((res as any).items, 'setup');
228+
expect(setup.label).toBe('Setup');
229+
expect(setup._lock).toBe('full');
230+
expect(setup._packageId).toBe(PKG);
231+
});
232+
233+
it('a second DELETE self-heals pre-existing pollution even with no overlay row', async () => {
234+
// Simulate the pre-fix world: overlay body sits on the plain key
235+
// with a stripped envelope, and the sys_metadata row is gone.
236+
engine.registry.registerItem('app', { ...overlayBody }, 'name');
237+
238+
const del = await protocol.deleteMetaItem({ type: 'app', name: 'setup' });
239+
expect(del.success).toBe(true);
240+
expect(del.reset).toBe(false); // no row to delete…
241+
242+
// …but the registry shadow is healed anyway.
243+
const direct: any = engine.registry.getItem('app', 'setup');
244+
expect(direct.label).toBe('Setup');
245+
expect(direct._lock).toBe('full');
246+
});
247+
});
248+
249+
describe('registry shadow — scoped-kernel lock enforcement is shadow-immune', () => {
250+
it('saveMetaItem still 403s on a full-locked artifact when a stripped shadow exists', async () => {
251+
const registry = new SchemaRegistry({ multiTenant: false });
252+
registry.registerItem('app', artifactApp(), 'name', PKG);
253+
// Pre-fix pollution: plain-key shadow without the lock envelope.
254+
registry.registerItem('app', { ...overlayBody }, 'name');
255+
256+
const mockEngine: any = {
257+
registry,
258+
find: async () => [],
259+
findOne: async () => null,
260+
insert: async () => ({ id: 'x' }),
261+
update: async () => ({ id: 'x' }),
262+
delete: async () => ({ deleted: 1 }),
263+
};
264+
const protocol = new ObjectStackProtocolImplementation(
265+
mockEngine, undefined, undefined, 'env_prod',
266+
);
267+
268+
await expect(protocol.saveMetaItem({
269+
type: 'app', name: 'setup', organizationId: 'org_a',
270+
item: { ...overlayBody },
271+
})).rejects.toMatchObject({ code: 'item_locked', status: 403 });
272+
273+
await expect(protocol.deleteMetaItem({
274+
type: 'app', name: 'setup', organizationId: 'org_a',
275+
})).rejects.toMatchObject({ code: 'item_locked', status: 403 });
276+
});
277+
});
278+
279+
describe('SchemaRegistry.getArtifactItem / removeRuntimeShadow', () => {
280+
it('getArtifactItem prefers the composite-key artifact over a plain-key shadow', () => {
281+
const registry = new SchemaRegistry({ multiTenant: false });
282+
registry.registerItem('app', artifactApp(), 'name', PKG);
283+
registry.registerItem('app', { ...overlayBody }, 'name');
284+
285+
expect((registry.getItem('app', 'setup') as any).label).toBe('Setup HACKED');
286+
const artifact: any = registry.getArtifactItem('app', 'setup');
287+
expect(artifact.label).toBe('Setup');
288+
expect(artifact._lock).toBe('full');
289+
});
290+
291+
it('getArtifactItem returns undefined for runtime-only and sys_metadata-sentinel items', () => {
292+
const registry = new SchemaRegistry({ multiTenant: false });
293+
registry.registerItem('app', { name: 'mine', label: 'Mine' }, 'name');
294+
registry.registerItem(
295+
'app',
296+
{ name: 'hydrated', label: 'Hydrated', _packageId: 'sys_metadata' },
297+
'name',
298+
);
299+
expect(registry.getArtifactItem('app', 'mine')).toBeUndefined();
300+
expect(registry.getArtifactItem('app', 'hydrated')).toBeUndefined();
301+
});
302+
303+
it('removeRuntimeShadow deletes the plain key only when a packaged artifact remains', () => {
304+
const registry = new SchemaRegistry({ multiTenant: false });
305+
registry.registerItem('app', artifactApp(), 'name', PKG);
306+
registry.registerItem('app', { ...overlayBody }, 'name');
307+
308+
expect(registry.removeRuntimeShadow('app', 'setup')).toBe(true);
309+
expect((registry.getItem('app', 'setup') as any).label).toBe('Setup');
310+
311+
// Runtime-only item: never removed.
312+
registry.registerItem('app', { name: 'mine', label: 'Mine' }, 'name');
313+
expect(registry.removeRuntimeShadow('app', 'mine')).toBe(false);
314+
expect((registry.getItem('app', 'mine') as any)?.label).toBe('Mine');
315+
});
316+
});

0 commit comments

Comments
 (0)