Skip to content

Commit ab47f69

Browse files
os-warrenclaude
andauthored
fix(service-package): refuse get() / list() over a seam that never answered (#11064)
* fix(service-package): refuse reads over a seam that never answered `get()` returned `null` and `list()` returned `[]` over a storage seam that accepted the query and ran nothing. `InMemoryDriver.execute()` logs `Raw execution not supported in InMemory driver` and returns `null`; the local `normalizeRows` maps that to `[]`, which is also what a working driver returns for a SELECT that matched nothing. Both read paths reported that emptiness as a product answer callers act on, and `start()`'s own `sys_packages` rehydration skipped in silence because of it. Reads now establish the seam ANSWERED before reading emptiness as a fact: a result that is not a result set is refused with a declared ADR-0112 envelope (`SERVICE_UNAVAILABLE` / 503), and the skipped boot rehydration is logged at warn. A seam that answers with genuinely zero rows is unchanged. Third instance of one class (#10677 / PR #10788, #10789 / PR #10964): a seam that cannot answer is absent, not empty. The predicate is a local copy — `metadata-protocol` deliberately does not publish its own, and this package does not depend on it. Part of #10965 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx * test(runtime): declare the objectql/package slot contracts instead of `any` `check:slot-lookup` flagged the new null-seam pin: `getService<any>('objectql')` is a service-lookup erasure, and the baseline never grows (#4251). The slot's shape is now spelled out — the raw-SQL seam plus the registry half whose presence is what lets `start()`'s hydration loop run. Part of #10965 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx * test(runtime): migrate the null-seam pin off the frozen driver-memory package `check:driver-memory-census` refused this file as a THIRD consumer of a package whose investment is frozen (#5499), arriving after #5704 migrated the test backends and #6664 replaced the prose census with a ledger. Disposition taken: MIGRATE, not ledger. The two ruled-permanent consumers are kept because nothing can stand in for them — one needs the schemaless arm of a divergence pin, the other a driver whose `supports = {}` hands autonumber seeding back to the engine. This file needs neither: it needs a seam whose `execute()` returns without answering, which is one return value rather than a capability profile, and `protocol-recorded-by-null.test.ts` already models exactly that with a local `makeStubDriver` — the convention #5704/#5784 established so a grep for the driver lands on real consumers only. The kernel, the ObjectQL engine and registry, and PackageServicePlugin.start() all stay real; only the seam's non-answer is doubled. What the double does NOT model is stated in the file: it is not evidence about driver-memory, whose null-return stays pinned on a real boot by the CLI sibling (#10677). Ledger untouched; ruled set still 2. Part of #10965 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9ac93cf commit ab47f69

4 files changed

Lines changed: 770 additions & 1 deletion

File tree

.changeset/olive-pandas-repeat.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/service-package": patch
3+
---
4+
5+
`get()` and `list()` no longer report "not installed" / "nothing installed" over a storage seam they never queried.
6+
7+
A driver that cannot run raw SQL returns no result set rather than throwing (`InMemoryDriver.execute()` logs `Raw execution not supported in InMemory driver` and returns `null`), and the service's row flattener mapped that to `[]` — the same value a working driver returns when a package genuinely is not installed. Both read paths then handed that back as a product answer, and the boot-time `sys_packages` rehydration skipped silently because of it.
8+
9+
Reads now establish that the seam ANSWERED before reading emptiness as a fact. A seam that returns no result set is refused with `SERVICE_UNAVAILABLE` / 503 and a message saying the answer is unknown; boot logs the skipped rehydration at `warn` instead of passing over it. A seam that answers with genuinely zero rows is unchanged: `get()` still returns `null` and `list()` still returns `[]`.
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #10965 — the `service-package` seam guard, on a REAL booted driver.
5+
*
6+
* The card established the conflation by reading and named the boot path as
7+
* unverified: *"Whether the DevPlugin zero-install stack (a real
8+
* `InMemoryDriver`) reaches `get()`/`list()` at boot is unverified."* This file
9+
* is that measurement, kept as a pin.
10+
*
11+
* It lives in `packages/runtime` because that is where the pieces already are:
12+
* `@objectstack/service-package` is a devDependency and this package's
13+
* `vitest.config.ts` already aliases it to `src/` for exactly this reason (the
14+
* `#5047` rehydration pin next door). `service-package` itself depends on
15+
* neither the engine nor a driver, so the same boot cannot be written inside it.
16+
*
17+
* ## Why the driver here is a LOCAL DOUBLE, not `@objectstack/driver-memory`
18+
*
19+
* The first version of this file booted the real `InMemoryDriver`. That made it
20+
* a THIRD consumer of a package whose investment is frozen (#5499), arriving
21+
* after #5704 migrated the test backends and #6664 replaced the prose census
22+
* with a ledger — and `check:driver-memory-census` refused it, correctly.
23+
*
24+
* The disposition taken was MIGRATE, not ledger. The two consumers a maintainer
25+
* ruled permanent are both kept because nothing can stand in for them: one needs
26+
* the SCHEMALESS arm of a divergence pin, the other needs a driver whose
27+
* `supports = {}` hands autonumber seeding back to the engine. Neither shape is
28+
* what this file needs. What it needs is a seam whose `execute()` RETURNS
29+
* without answering — one return value, not a capability profile — and
30+
* `packages/objectql/src/protocol-recorded-by-null.test.ts` already models
31+
* exactly that with a local `makeStubDriver` carrying `async execute() { return
32+
* null; }`. `makeStubDriver` is itself the convention #5704/#5784 established so
33+
* that grepping for the driver lands on real consumers only.
34+
*
35+
* ⚠️ **What the double does NOT model.** It is not evidence about
36+
* `@objectstack/driver-memory`'s behaviour. That the real driver logs
37+
* `Raw execution not supported in InMemory driver` and returns `null` was
38+
* measured on a real boot while triaging #10965, and it stays pinned on a real
39+
* booted driver by `packages/cli/src/commands/migrate/duplicates.null-seam.test.ts`
40+
* (#10677), which reaches it through the datasource factory rather than by
41+
* importing it. Nothing about that fact is re-asserted here, and this file would
42+
* not notice if that driver changed. What it models is the SHAPE — a seam that
43+
* accepts a statement and returns no result set — which is the only property the
44+
* guard under test keys on, and which the implementation deliberately judges by
45+
* return value rather than by driver identity.
46+
*
47+
* ## Measured on a real booted `InMemoryDriver`, before the fix (framework
48+
* `2866d5f97`) — the triage measurement this file is the regression pin for
49+
*
50+
* typeof objectql.execute -> 'function' (the shape test passes)
51+
* objectql.registry.installPackage -> 'function' (so hydration RUNS)
52+
* start() issued three statements, each returning null:
53+
* CREATE TABLE IF NOT EXISTS sys_packages … -> null
54+
* CREATE INDEX IF NOT EXISTS idx_packages_latest … -> null
55+
* SELECT * FROM sys_packages … (the hydration list) -> null
56+
* list() -> [] ⇒ "no packages are installed"
57+
* get() -> null ⇒ "this package is not installed"
58+
*
59+
* ⭐ What this pins is NOT "some named driver is refused". It is the separation
60+
* the guard keys on: **a seam that cannot ANSWER is absent, not empty.** No
61+
* driver is named by the implementation — the seam is judged by what it
62+
* returns — so the double below is judged by the same rule any real host is.
63+
*
64+
* No real driver is asserted anywhere in this file, by construction: every
65+
* driver-specific claim would be a false pin over a backend this suite does not
66+
* load.
67+
*/
68+
69+
import { describe, it, expect } from 'vitest';
70+
import { LiteKernel, type PluginContext } from '@objectstack/core';
71+
import { ObjectQLPlugin } from '@objectstack/objectql';
72+
import {
73+
PackageServicePlugin,
74+
PACKAGE_SEAM_UNREADABLE_MESSAGE,
75+
} from '@objectstack/service-package';
76+
77+
import { DriverPlugin } from './driver-plugin.js';
78+
79+
/**
80+
* The `objectql` slot, as this file reads it: the raw-SQL seam under test, plus
81+
* the registry half whose presence is what lets `start()`'s hydration loop run
82+
* at all. Spelled out rather than `any` so the slot's shape is a declaration
83+
* (#4251 / `check:slot-lookup`).
84+
*/
85+
interface ObjectQlSlot {
86+
execute: (query: { sql: string; args?: unknown[] }) => Promise<unknown>;
87+
registry?: {
88+
installPackage?: (manifest: unknown) => unknown;
89+
getPackage?: (id: string) => unknown;
90+
};
91+
}
92+
93+
/** The two read methods this card is about, off the registered `package` slot. */
94+
interface PackageSlot {
95+
get: (packageId: string, version?: string) => Promise<unknown>;
96+
list: () => Promise<unknown[]>;
97+
}
98+
99+
/**
100+
* A seam that ACCEPTS a statement and returns no result set.
101+
*
102+
* The whole double, and deliberately the smallest thing that can be one: the
103+
* engine's `execute` delegates straight to `driver.execute(...)` after checking
104+
* only that the method EXISTS (`packages/objectql/src/engine.ts:11687`), which
105+
* is the half of the defect that made the conflation invisible — the shape test
106+
* passes and the answer never arrives.
107+
*
108+
* `execute` returning `null` is the measured `InMemoryDriver` return, modelled
109+
* here the way `protocol-recorded-by-null.test.ts`'s own `makeStubDriver` models
110+
* it. The rest of the surface exists so `kernel.bootstrap()` completes; nothing
111+
* below `execute` is asserted on.
112+
*/
113+
function makeNonAnsweringDriver() {
114+
const driver = {
115+
name: 'stub-non-answering',
116+
version: '0.0.0',
117+
supports: {},
118+
async connect() {},
119+
async disconnect() {},
120+
async checkHealth() { return true; },
121+
/** ⭐ The one behaviour under test: it RETURNS, and it does not answer. */
122+
async execute() { return null; },
123+
async find() { return []; },
124+
async findOne() { return null; },
125+
async count() { return 0; },
126+
async create(_object: string, data: Record<string, unknown>) { return data; },
127+
async update(_object: string, _id: unknown, data: Record<string, unknown>) { return data; },
128+
async delete() { return true; },
129+
};
130+
return driver;
131+
}
132+
133+
interface BootedStack {
134+
kernel: LiteKernel;
135+
engine: ObjectQlSlot;
136+
statements: string[];
137+
results: unknown[];
138+
warnLogs: string[];
139+
service: PackageSlot;
140+
}
141+
142+
/**
143+
* The zero-install stack: REAL kernel, REAL ObjectQL engine and registry, REAL
144+
* `PackageServicePlugin.start()` — with the non-answering seam supplied by the
145+
* double above. Everything the guard is judged against is real except the one
146+
* property being modelled.
147+
*/
148+
async function bootNonAnsweringStack(): Promise<BootedStack> {
149+
const kernel = new LiteKernel({ logger: { level: 'error' } });
150+
kernel.use(new ObjectQLPlugin({}));
151+
kernel.use(new DriverPlugin(makeNonAnsweringDriver()));
152+
await kernel.bootstrap();
153+
154+
const engine = kernel.getService<ObjectQlSlot>('objectql');
155+
156+
// Record what `start()` actually asks the seam, and what the seam answers.
157+
const statements: string[] = [];
158+
const results: unknown[] = [];
159+
const realExecute = engine.execute.bind(engine);
160+
engine.execute = async (q: { sql: string; args?: unknown[] }) => {
161+
const result = await realExecute(q);
162+
statements.push(String(q.sql).replace(/\s+/g, ' ').trim());
163+
results.push(result);
164+
return result;
165+
};
166+
167+
const warnLogs: string[] = [];
168+
let service: PackageSlot | undefined;
169+
const services = new Map<string, unknown>([['objectql', engine]]);
170+
const ctx = {
171+
logger: {
172+
debug: () => {}, info: () => {},
173+
warn: (msg: string) => warnLogs.push(String(msg)),
174+
error: () => {},
175+
},
176+
getService: (n: string) => services.get(n),
177+
registerService: (n: string, s: unknown) => {
178+
services.set(n, s);
179+
if (n === 'package') service = s as PackageSlot;
180+
},
181+
} as unknown as PluginContext;
182+
183+
await new PackageServicePlugin().start(ctx);
184+
return { kernel, engine, statements, results, warnLogs, service: service! };
185+
}
186+
187+
describe('#10965 service-package over a booted engine whose seam never answers', () => {
188+
it('the seam has the SHAPE of a seam and answers nothing — the two the guard separates', async () => {
189+
const stack = await bootNonAnsweringStack();
190+
try {
191+
// The half that made the conflation invisible: `start()`'s own gate asks
192+
// whether `execute` is callable, and on this driver it is.
193+
expect(stack.engine.execute, 'the shape test still passes — that is the defect').toBeTypeOf('function');
194+
195+
// The half the guard now asks about: the driver accepts the statement
196+
// and hands back no result set at all.
197+
await expect(stack.engine.execute({ sql: 'select 1 as os_seam_probe' })).resolves.toBeNull();
198+
199+
// And the hydration gate it had to get past is genuinely open, so the
200+
// boot loop below really does run.
201+
expect(typeof stack.engine.registry?.installPackage).toBe('function');
202+
expect(typeof stack.engine.registry?.getPackage).toBe('function');
203+
} finally {
204+
await stack.kernel.shutdown();
205+
}
206+
}, 120_000);
207+
208+
it('boot REACHES the list read — the card’s unverified half, measured', async () => {
209+
const stack = await bootNonAnsweringStack();
210+
try {
211+
const listStatement = stack.statements.find((s) => /^SELECT \* FROM sys_packages WHERE \(id, created_at\) IN/.test(s));
212+
expect(listStatement, 'start() issues the latest-per-id SELECT that backs list()').toBeDefined();
213+
214+
// …and the driver answered every one of them with no result set, which
215+
// is what `normalizeRows` used to flatten to "no packages installed".
216+
expect(stack.results.length).toBeGreaterThan(0);
217+
expect(stack.results.every((r) => r === null)).toBe(true);
218+
} finally {
219+
await stack.kernel.shutdown();
220+
}
221+
}, 120_000);
222+
223+
it('boot does not brick, and says the durable packages could not be read', async () => {
224+
const stack = await bootNonAnsweringStack();
225+
try {
226+
const skip = stack.warnLogs.find((l) => /hydration from sys_packages SKIPPED/i.test(l));
227+
expect(skip, 'the silent skip is now audible').toBeDefined();
228+
expect(skip).toMatch(/not "no packages installed"/);
229+
} finally {
230+
await stack.kernel.shutdown();
231+
}
232+
}, 120_000);
233+
234+
it('get() and list() REFUSE with the ADR-0112 envelope instead of answering an absence', async () => {
235+
const stack = await bootNonAnsweringStack();
236+
try {
237+
for (const call of [
238+
() => stack.service.get('com.acme.crm', 'latest'),
239+
() => stack.service.get('com.acme.crm', '1.0.0'),
240+
() => stack.service.list(),
241+
]) {
242+
let thrown: (Error & { code?: string; status?: number }) | undefined;
243+
try {
244+
await call();
245+
throw new Error('expected a refusal, but the call returned');
246+
} catch (e) {
247+
thrown = e as Error & { code?: string; status?: number };
248+
}
249+
// code AND status — never status alone, and never a bare toThrow().
250+
expect(thrown!.code).toBe('SERVICE_UNAVAILABLE');
251+
expect(thrown!.status).toBe(503);
252+
expect(thrown!.message).toBe(PACKAGE_SEAM_UNREADABLE_MESSAGE);
253+
}
254+
} finally {
255+
await stack.kernel.shutdown();
256+
}
257+
}, 120_000);
258+
});

0 commit comments

Comments
 (0)