Skip to content

Commit 9bb9370

Browse files
fix(metadata-protocol): an orphan adoption rebinds a draft view in storage only, never registering it live (#23724)
Fixes #23690 Clause-②: no Dev session `session_01SVE8bSorrxjRHx7edWAdKm`, dispatched by `domain:engine` seat 1 (`session_01GP3comALHRcRBixS2TEnLF`), claim 6200629773. ## What this does `reassignOrphanedMetadata` (the method `POST /api/v1/packages/:id/adopt-orphans` calls, ADR-0070 D5) sends each rebound view row through the registry write-through (#23198). The view limb admitted a row by its type alone, so adopting a package-less **draft** view registered its draft body as live, stamped `_packageId` = the target package, until the next boot (which registers active rows only, `loadMetaFromDb`). `applyRegistryWriteThrough`'s own header says drafts "are a staging buffer and must never leak into the runtime" (ADR-0048). - **The fix, at the producer:** one `row.state !== 'active'` gate now runs before both limbs (view and object). It replaces the object limb's own copy of the same check (PR #23688), so the object limb's behaviour is byte-for-byte the same (its pins green, below). An adopted draft view is rebound in storage only. - **Unchanged:** which rows are rebound, each row's stored `state`, `reassignedCount` / `reassigned`, and an adopted ACTIVE view going live at its new binding. - **A second face of the same defect, now pinned:** a draft adopted beside an active row of the same view name *replaced* the published body the registry served (measured red on `main`, below). - Accept sets: none moves. No door's accepted input changes; only what the registry serves for an unpublished view, which is the implementation brought back to its declared contract. - ADRs: `docs/adr/` grepped for the surface (ADR-0070 D5 rules on the storage rebind only; ADR-0048 on draft / active). No ADR is reversed. ## Verification (final head `f60a676d6`, a merge commit of `origin/main` `9e0945cf4` that brought in PR #23706) - **Negative control on `main` (`4c21e57169`), before the fix**, the new pins on a real `ObjectKernel` + `ObjectQLPlugin`: `Tests 2 failed | 1 passed (3)`. The draft case: `registerItem` was called with `['view', {…}, 'name']`. The draft-beside-active case: served `{ label: 'Draft gadgets', … }` where `'Published gadgets'` was expected. The CONTROL (active view adopted) passed. - **Pins after the fix** (`packages/objectql/src/protocol-adopt-orphans-draft-view-registry.test.ts`, beside the object-registry pin): - an adopted DRAFT view: the registry serves nothing before and after the adoption, no `registerItem('view', …)` call, the stored row is Q's draft, `reassignedCount: 1`, and a boot afterwards serves nothing; - a draft adopted beside an active row of the same view: `reassignedCount: 2`, the active body is served at Q, and a boot agrees; - CONTROL: an adopted active view is served at Q at once, and a boot agrees. - With the object-registry pin and `protocol-package-lifecycle.test.ts`: `Test Files 3 passed (3)`, `Tests 19 passed (19)` (before and after the main merge). - **Ablation** (`scripts/ablation-replace.mjs`, the gate narrowed to objects only, `metadata-protocol` rebuilt, `ablation-dist-preflight` marker present in `dist/index.cjs` and `dist/index.js`): `Tests 2 failed | 7 passed (9)`, the two draft-view pins red; the CONTROL and all five object pins green. Restored: blob == HEAD `07e2e03b650b`, `git diff HEAD` empty; rebuilt, `--absent` clean across 24 built files, pins 3/3. - **HTTP door, walked once** on `pnpm dev:crm -- --fresh -p 38690` (better-sqlite3): package `com.qa.q` created (`201`), a package-less draft view and a package-less active view seeded into `sys_metadata` (no door writes package-less rows today). Before: `GET /api/v1/meta/view/qa_draft_grid` `404`; the active one `200`. `POST /api/v1/packages/com.qa.q/adopt-orphans` `200`, `reassignedCount: 2`. After: draft `404 RESOURCE_NOT_FOUND`; active `200`, `label: 'Published gadgets'`, `_packageId: 'com.qa.q'`; both stored rows bound to `com.qa.q`, states unchanged. - **HTTP negative control** (the same ablation, one-off, second server on port 38691): the same sequence answered `GET /api/v1/meta/view/qa_draft_grid` `200` with `"label":"Draft gadgets","_packageId":"com.qa.q"` after the adoption. So the leak was reachable at the public door, and the fix closes it there. - **Suites:** `@objectstack/metadata-protocol` full suite `Test Files 236 passed | 3 skipped (239)`, `Tests 20566 passed | 19 skipped` on `f60a676d6`. PR #23706's REST pin `meta-draft-head-package-inheritance.test.ts` re-run after the merge: 21/21. - **Typecheck:** `@objectstack/metadata-protocol` and `@objectstack/objectql` exit 0 (`objectql`'s includes `check:test-typecheck`: OK, debt held). - **Derived gates** on `f60a676d6`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (3 paths, 67 commands), all run; reconciled with `--ran`: `67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN` (a derived zero, every line carrying its exit code). Two families first refused on the shallow clone (`check-engine-split-ratio.mjs --days 90` exit 2, `check-plugin-teardown-shape.mjs --self-test` exit 3); after `git fetch --shallow-since=2026-07-10` both re-ran at exit 0. ## Neighbours Open PRs touching `packages/metadata-protocol/src/protocol.ts`: PR #23704 (#23514, the D9.9 / `saveMetaItem` region) only; `git merge-tree` of this head with its branch is clean. PR #23418 touches a `metadata-protocol` test file, not `protocol.ts`. PR #23706 (#23657) landed on `main` while this was in flight and is merged in with a merge commit (no conflict); its pins above. ## Acceptance notes - #23646 and PR #23688 are referenced as context only. - None found outside the card's scope. --- _Generated by [Claude Code](https://claude.ai/code/session_01SVE8bSorrxjRHx7edWAdKm)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent fdbcec0 commit 9bb9370

3 files changed

Lines changed: 264 additions & 5 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): an orphan adoption no longer makes a draft view live; an adopted draft view is rebound in storage only (ADR-0070 D5, ADR-0048)
6+
7+
Clause-②: no
8+
9+
`reassignOrphanedMetadata`, the method `POST /api/v1/packages/:id/adopt-orphans` calls, sends each rebound view row through the registry write-through so the registry follows it at once. It admitted a view row whatever its `state`. So adopting a package-less DRAFT view registered its draft body as live, stamped with the target package, until the next restart. The boot registers active rows only, so it dropped the view again. A draft adopted beside a published row of the same view replaced the published body the runtime served.
10+
11+
Only an active row now reaches the registry, for a view as for an object. An adopted draft view is rebound in storage only: the registry serves nothing for it until it is published, and the next boot agrees.
12+
13+
What stays as before:
14+
15+
- Which rows the adoption rebinds, the stored `state` of each, and the response (`reassignedCount`, `reassigned`).
16+
- An adopted active view is served at its new binding at once, and an adopted active object moves its registry owner, as before.

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27627,6 +27627,14 @@ export class ObjectStackProtocolImplementation implements
2762727627
* the rows the boot registers: an adopted draft is rebound in storage
2762827628
* only, because a draft never reaches the runtime. Rows of other types are
2762927629
* rebound in storage only, as before.
27630+
*
27631+
* ## [#23690] The active-only rule holds for a view too
27632+
*
27633+
* The view limb admitted a row by its type alone, so an adopted
27634+
* package-less DRAFT view was registered as live (its draft body, at the
27635+
* new binding) until the next boot dropped it again, against the
27636+
* write-through's own rule that a draft never leaks into the runtime
27637+
* (ADR-0048). One gate before both limbs now admits active rows only.
2763027638
*/
2763127639
async reassignOrphanedMetadata(request: {
2763227640
targetPackageId: string;
@@ -27702,6 +27710,11 @@ export class ObjectStackProtocolImplementation implements
2770227710
// next restart; see the header. [#23646] A view, and an object.
2770327711
const type = canonicalMetaType(String(row.type));
2770427712
if (type !== 'view' && type !== 'object') continue;
27713+
// [#23646, #23690] Only an ACTIVE row reaches the registry: the
27714+
// boot registers no other ({@link loadMetaFromDb}), and a draft
27715+
// never reaches the runtime. An adopted draft is rebound in
27716+
// storage only.
27717+
if (row.state !== 'active') continue;
2770527718
let rebound: { name: string; data: unknown };
2770627719
try {
2770727720
// The row as the list read hydrates it, at its new binding.
@@ -27712,13 +27725,10 @@ export class ObjectStackProtocolImplementation implements
2771227725
console.warn(`[Protocol] registry write-through failed for ${type}/${row.name}: ${err?.message ?? err}`);
2771327726
continue;
2771427727
}
27715-
// [#23646] An object's one registry owner moves with its row. Only
27716-
// an active row's: the boot registers no other ({@link
27717-
// loadMetaFromDb}), and a draft never reaches the runtime.
27728+
// [#23646] An object's one registry owner moves with its row.
2771827729
if (
2771927730
type === 'object'
27720-
&& (row.state !== 'active'
27721-
|| !this.releaseAdoptedObjectOwner(rebound.name, rebound.data, request.targetPackageId))
27731+
&& !this.releaseAdoptedObjectOwner(rebound.name, rebound.data, request.targetPackageId)
2772227732
) {
2772327733
continue;
2772427734
}
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #23690, ADR-0070 D5 / ADR-0048 — an orphan adoption never makes a DRAFT view
5+
* live: an adopted draft view is rebound in storage only, as the boot treats it.
6+
*
7+
* ## The defect
8+
*
9+
* `reassignOrphanedMetadata` (the method `POST /api/v1/packages/:id/adopt-orphans`
10+
* calls) sends each rebound view row through the registry write-through
11+
* (#23198). The view limb admitted a row by its type alone, so a package-less
12+
* draft view adopted into Q was registered as live (its draft body, stamped
13+
* `_packageId` Q) until the next boot, which registers active rows only
14+
* (`loadMetaFromDb`). `applyRegistryWriteThrough`'s own header: drafts "are a
15+
* staging buffer and must never leak into the runtime". Measured on
16+
* `origin/main` `4c21e57169` before the fix.
17+
*
18+
* ## The pins, on a real kernel (another kernel over the same store is a boot)
19+
*
20+
* - an adopted draft view: before and after the adoption the registry serves
21+
* nothing for it, the stored row is Q's draft, and a boot afterwards agrees;
22+
* - an adopted draft beside an adopted active row of the same view: the
23+
* active body is served at Q, never the draft's;
24+
* - CONTROL: an adopted active view is served at Q at once (#23198), and a
25+
* boot afterwards agrees.
26+
*
27+
* The negative control (the gate removed, the draft pins red) is a one-off
28+
* ablation recorded in the PR body, not a test.
29+
*/
30+
31+
import { randomUUID } from 'node:crypto';
32+
import { afterEach, describe, expect, it, vi } from 'vitest';
33+
import { ObjectKernel } from '@objectstack/core';
34+
import { ObjectQL } from './engine.js';
35+
import { ObjectQLPlugin } from './plugin.js';
36+
37+
type Stores = Map<string, Map<string, Record<string, unknown>>>;
38+
39+
/**
40+
* In-memory driver, copied from `protocol-adopt-orphans-object-registry.test.ts`;
41+
* equality-only WHERE. `dropTable` records the tables a run drops, so a pin can
42+
* say none was.
43+
*/
44+
function makeMemoryDriver(stores: Stores, drops: string[]) {
45+
const storeFor = (obj: string) => {
46+
let s = stores.get(obj);
47+
if (!s) { s = new Map(); stores.set(obj, s); }
48+
return s;
49+
};
50+
// `$and` / `$or` are conjoined WITH their sibling keys, the way a real
51+
// driver ANDs them.
52+
const matchesWhere = (row: Record<string, unknown>, where: any): boolean => {
53+
if (!where || typeof where !== 'object') return true;
54+
for (const [k, v] of Object.entries(where)) {
55+
if (k === '$and' && Array.isArray(v)) {
56+
if (!v.every((w: any) => matchesWhere(row, w))) return false;
57+
continue;
58+
}
59+
if (k === '$or' && Array.isArray(v)) {
60+
if (!v.some((w: any) => matchesWhere(row, w))) return false;
61+
continue;
62+
}
63+
if (k.startsWith('$')) continue;
64+
const rowVal = row[k];
65+
const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
66+
const a = rowVal === undefined ? null : rowVal;
67+
const b = expected === undefined ? null : expected;
68+
if (a !== b) return false;
69+
}
70+
return true;
71+
};
72+
const driver: any = {
73+
name: 'memory', version: '0.0.0', supports: {} as any,
74+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
75+
async execute() { return null; },
76+
async find(object: string, ast: any) {
77+
const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where));
78+
// The caller's bound, after the filter, by presence (`check:objectql-double-limit`).
79+
return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
80+
},
81+
async findOne(object: string, ast: any) {
82+
for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r;
83+
return null;
84+
},
85+
async create(object: string, data: Record<string, unknown>) {
86+
const id = (data.id as string) ?? `r_${randomUUID()}`;
87+
const row = { ...data, id };
88+
storeFor(object).set(id, row);
89+
return row;
90+
},
91+
async update(object: string, id: string, data: Record<string, unknown>) {
92+
const s = storeFor(object);
93+
const cur = s.get(id);
94+
if (!cur) throw new Error(`not found: ${object}/${id}`);
95+
const updated = { ...cur, ...data, id };
96+
s.set(id, updated);
97+
return updated;
98+
},
99+
async upsert(object: string, data: Record<string, unknown>) {
100+
const id = data.id as string | undefined;
101+
if (id && storeFor(object).has(id)) return this.update(object, id, data);
102+
return this.create(object, data);
103+
},
104+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
105+
async count(object: string, ast: any) { return (await this.find(object, ast)).length; },
106+
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
107+
return Promise.all(rows.map((r) => this.create(object, r)));
108+
},
109+
async bulkUpdate() { return []; }, async bulkDelete() {},
110+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
111+
async commit() {}, async rollback() {},
112+
async dropTable(object: string) { drops.push(object); stores.delete(object); },
113+
};
114+
return driver;
115+
}
116+
117+
/** The data plane read, as the system reads it: typed, never erased (`check:query-options-erasure`). */
118+
119+
const Q = 'com.qa.q';
120+
const VIEW = 'qa_probe_gadget_grid';
121+
122+
/** A view body; `label` tells the rows apart. */
123+
const viewBody = (label: string) => ({
124+
name: VIEW, label, type: 'grid', data: { provider: 'object', object: 'qa_probe_gadget' },
125+
});
126+
127+
interface Booted {
128+
kernel: ObjectKernel;
129+
engine: ObjectQL;
130+
protocol: any;
131+
}
132+
133+
describe('#23690 — an orphan adoption rebinds a draft view in storage only', () => {
134+
const live: ObjectKernel[] = [];
135+
afterEach(async () => {
136+
vi.restoreAllMocks();
137+
while (live.length) {
138+
const kernel = live.pop()!;
139+
if (kernel.getState() === 'running') await kernel.shutdown();
140+
}
141+
});
142+
143+
/** One boot over `stores`: a kernel, a fresh engine, and `loadMetaFromDb` run by the plugin's start. */
144+
async function boot(stores: Stores, drops: string[]): Promise<Booted> {
145+
// gracefulShutdown:false keeps process signal handlers out of vitest's fork workers (#4250).
146+
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
147+
live.push(kernel);
148+
const engine = new ObjectQL();
149+
engine.registerDriver(makeMemoryDriver(stores, drops), true);
150+
await kernel.use(new ObjectQLPlugin({ ql: engine }));
151+
await kernel.bootstrap();
152+
return { kernel, engine, protocol: kernel.getService('protocol') };
153+
}
154+
155+
/** A package-less view row, as a pre-package-first stopgap left it. */
156+
const insertOrphan = (k: Booted, state: 'active' | 'draft', label: string) =>
157+
k.engine.insert('sys_metadata', {
158+
type: 'view', name: VIEW, state, package_id: null, metadata: JSON.stringify(viewBody(label)),
159+
}, { context: { isSystem: true } } as any);
160+
161+
/** The view's stored rows, as the driver holds them. */
162+
const storedRows = (stores: Stores) =>
163+
Array.from(stores.get('sys_metadata')?.values() ?? [])
164+
.filter((r) => r.type === 'view' && r.name === VIEW)
165+
.map((r) => ({ state: r.state, packageId: r.package_id ?? null }));
166+
167+
/** What the registry serves for the view: nothing, or its label and package stamp. */
168+
const served = (k: Booted) => {
169+
const view = k.engine.registry.getItem('view', VIEW) as { label?: string; _packageId?: string } | undefined;
170+
return view === undefined ? undefined : { label: view.label, packageId: view._packageId };
171+
};
172+
173+
it('an adopted DRAFT view: the registry serves nothing before, after, and at the next boot', async () => {
174+
const stores: Stores = new Map();
175+
const drops: string[] = [];
176+
const k = await boot(stores, drops);
177+
await insertOrphan(k, 'draft', 'Draft gadgets');
178+
expect(served(k)).toBeUndefined();
179+
const registerItem = vi.spyOn(k.engine.registry, 'registerItem');
180+
181+
const adopted = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q });
182+
183+
// The storage rebind and its answer are unchanged.
184+
expect(adopted).toMatchObject({ success: true, reassignedCount: 1, reassigned: [{ type: 'view', name: VIEW }] });
185+
expect(storedRows(stores)).toEqual([{ state: 'draft', packageId: Q }]);
186+
// Nothing of the draft reaches the registry.
187+
expect(registerItem.mock.calls.filter(([type]) => type === 'view')).toEqual([]);
188+
expect(served(k)).toBeUndefined();
189+
190+
// A boot afterwards agrees.
191+
await k.kernel.shutdown();
192+
const k2 = await boot(stores, drops);
193+
expect(served(k2)).toBeUndefined();
194+
expect(storedRows(stores)).toEqual([{ state: 'draft', packageId: Q }]);
195+
expect(drops).toEqual([]);
196+
});
197+
198+
it('an adopted draft beside an adopted active row of the same view: the active body is served at Q, never the draft', async () => {
199+
const stores: Stores = new Map();
200+
const drops: string[] = [];
201+
const k = await boot(stores, drops);
202+
await insertOrphan(k, 'active', 'Published gadgets');
203+
await insertOrphan(k, 'draft', 'Draft gadgets');
204+
205+
const adopted = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q });
206+
207+
expect(adopted).toMatchObject({ success: true, reassignedCount: 2 });
208+
expect(storedRows(stores)).toEqual([{ state: 'active', packageId: Q }, { state: 'draft', packageId: Q }]);
209+
expect(served(k)).toEqual({ label: 'Published gadgets', packageId: Q });
210+
211+
await k.kernel.shutdown();
212+
const k2 = await boot(stores, drops);
213+
expect(served(k2)).toEqual({ label: 'Published gadgets', packageId: Q });
214+
});
215+
216+
it('CONTROL: an adopted ACTIVE view is served at Q at once (#23198), and a boot afterwards agrees', async () => {
217+
const stores: Stores = new Map();
218+
const drops: string[] = [];
219+
const k = await boot(stores, drops);
220+
await insertOrphan(k, 'active', 'Published gadgets');
221+
222+
const adopted = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q });
223+
224+
expect(adopted).toMatchObject({ success: true, reassignedCount: 1, reassigned: [{ type: 'view', name: VIEW }] });
225+
expect(storedRows(stores)).toEqual([{ state: 'active', packageId: Q }]);
226+
expect(served(k)).toEqual({ label: 'Published gadgets', packageId: Q });
227+
228+
await k.kernel.shutdown();
229+
const k2 = await boot(stores, drops);
230+
expect(served(k2)).toEqual({ label: 'Published gadgets', packageId: Q });
231+
expect(drops).toEqual([]);
232+
});
233+
});

0 commit comments

Comments
 (0)