Skip to content

Commit dfb2af2

Browse files
committed
fix(objectql): lifecycle tenant scan asks the registry before reading sys_organization
The engine's in-process verbs refuse an object name the registry does not resolve with OBJECT_NOT_FOUND before any driver is asked. In a composition that registers no sys_organization, LifecycleService.loadGovernance's tenant scan received that refusal, which is not the missing-table cause its catch accepts, so every lifecycle sweep aborted before applying a policy. The scan now asks engine.registry.getObject('sys_organization') first, the shape ObjectQL.probeInstallOrganizations takes, and answers an unregistered object with no tenant overrides. A registered object is read as before: a missing table stays the one benign cause, and every other failure, an OBJECT_NOT_FOUND from that read included, still aborts the sweep. LifecycleEngineLike['registry'] declares the optional getObject member the scan reads. The runtime noise-capture header no longer says the org probe catches only a missing table. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
1 parent 36ad321 commit dfb2af2

4 files changed

Lines changed: 334 additions & 9 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
`LifecycleService` asks the registry whether `sys_organization` is registered before its governance tenant scan reads it, so a composition that registers no `sys_organization` sweeps single-tenant again instead of aborting every sweep
6+
7+
Clause-②: no
8+
9+
- The engine's in-process verbs refuse an object name the registry does not resolve (`OBJECT_NOT_FOUND`, 404) before any driver is asked. Take a composition with a settings service and lifecycle-declared objects but no `sys_organization` object. Its tenant scan got that refusal instead of a missing table, so every sweep aborted before applying any policy. The scan now asks `engine.registry.getObject('sys_organization')` first, the same shape `ObjectQL.probeInstallOrganizations` takes. An unregistered object answers "no tenant overrides", and the sweep runs one global pass on each declared window.
10+
- A registered `sys_organization` is read as before. A missing table is still the one benign driver cause. Every other failure still aborts the sweep and is reported through `report.errors`, an `OBJECT_NOT_FOUND` from that read included.
11+
- `LifecycleEngineLike['registry']` now declares the optional `getObject?(name)` member the scan reads. A registry without it cannot be asked, and the scan then reads exactly as before. No new export and no change to the sweep report's shape.
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21597] `loadGovernance`'s tenant scan asks the REGISTRY whether
5+
* `sys_organization` is registered before it reads it.
6+
*
7+
* Since commit eb9ef791bd an in-process engine verb refuses an object name the
8+
* registry does not resolve, with `OBJECT_NOT_FOUND` (404), before any driver
9+
* is asked. A composition that registers no `sys_organization` therefore no
10+
* longer reaches a driver with the tenant scan's read: it gets the engine's
11+
* refusal. That is not a missing table, so the scan's catch rethrew it, and
12+
* every sweep aborted before applying a single policy.
13+
*
14+
* Unregistered is the single-tenant answer. With no organization object there
15+
* is no tenant to hold an override, and that is the same answer
16+
* `ObjectQL.probeInstallOrganizations` gives to the same question. What does
17+
* NOT change is the catch: a missing table on a REGISTERED `sys_organization`
18+
* is still the one benign driver cause, and every other failure still aborts
19+
* the sweep (#12853). In particular an `OBJECT_NOT_FOUND` is not read as
20+
* absence wholesale: the registry already said the object is registered, so a
21+
* refusal arriving from the read is about something else.
22+
*
23+
* Every case runs on a REAL `ObjectQL` engine over a stub driver, so the
24+
* refusal the guard avoids is the engine's own and not a double's guess at it.
25+
* `engine.find` is spied with call-through: the spy records what the sweep
26+
* asked the engine, and the engine still answers.
27+
*/
28+
29+
import { describe, it, expect, vi } from 'vitest';
30+
import { ObjectQL } from '../engine.js';
31+
import { LifecycleService } from './lifecycle-service.js';
32+
import { parseLifecycleDuration } from './duration.js';
33+
34+
const FIXED_NOW = 1_700_000_000_000;
35+
const PACKAGE_ID = 'lifecycle-organization-registry';
36+
37+
/** The lifecycle-declared object every sweep below reaps. */
38+
const TELEMETRY_OBJ = {
39+
name: 'sys_job_run',
40+
fields: { status: { type: 'text' } },
41+
lifecycle: { class: 'telemetry', retention: { maxAge: '30d' } },
42+
} as any;
43+
44+
const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } } as any;
45+
46+
const isoCutoff = (literal: string) => new Date(FIXED_NOW - parseLifecycleDuration(literal)).toISOString();
47+
48+
/** The regulated tenant keeps its rows three times longer than the global window. */
49+
const TENANT_OVERRIDES = { org_reg: { retention_overrides: { sys_job_run: { maxAge: '90d' } } } };
50+
51+
/** Settings service whose only values are tenant-scoped ones. */
52+
function fakeSettings(tenantValues: Record<string, Record<string, unknown>>) {
53+
return {
54+
async get(_ns: string, key: string, ctx?: Record<string, unknown>) {
55+
const tenantId = ctx?.tenantId as string | undefined;
56+
if (tenantId && tenantValues[tenantId] && key in tenantValues[tenantId]) {
57+
return { value: tenantValues[tenantId][key], source: 'tenant' };
58+
}
59+
return { value: undefined, source: 'default' };
60+
},
61+
};
62+
}
63+
64+
/** A transient database outage, the shape `isMissingTableError` answers `false` for. */
65+
const outage = () =>
66+
Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), { code: 'ECONNREFUSED' });
67+
68+
/** The benign unprovisioned table, in the SQLite-family spelling. */
69+
const missingTable = () => new Error('no such table: sys_organization');
70+
71+
const abortedError = (message: string) =>
72+
`governance snapshot could not be loaded (${message}) — sweep aborted before any policy ` +
73+
'was applied, so no rows were reaped for this object';
74+
75+
/**
76+
* A real engine with `sys_job_run` registered, and `sys_organization`
77+
* registered only when asked. The stub driver answers the organization read
78+
* with `organizationRead` and every candidate page with no rows.
79+
*/
80+
async function lifecycleEngine(opts: {
81+
registerOrganization: boolean;
82+
organizationRead?: () => Array<Record<string, unknown>>;
83+
}) {
84+
const driverReads: string[] = [];
85+
const driverDeletes: string[] = [];
86+
const organizationRead = opts.organizationRead ?? (() => [{ id: 'org_reg' }]);
87+
const driver = {
88+
name: 'memory',
89+
version: '0.0.0',
90+
supports: {},
91+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
92+
async execute() { return null; },
93+
async find(object: string) {
94+
driverReads.push(object);
95+
return object === 'sys_organization' ? organizationRead() : [];
96+
},
97+
async findOne() { return null; },
98+
async count() { return 0; },
99+
async create(_object: string, data: any) { return { id: 'r_1', ...data }; },
100+
async update(_object: string, id: string, data: any) { return { id, ...data }; },
101+
async delete(object: string) { driverDeletes.push(object); return true; },
102+
async bulkCreate(_object: string, rows: any[]) { return rows; },
103+
async bulkUpdate() { return []; },
104+
async bulkDelete(object: string) { driverDeletes.push(object); },
105+
async syncSchema() {},
106+
} as any;
107+
108+
const engine = new ObjectQL();
109+
engine.registerDriver(driver, true);
110+
await engine.init();
111+
engine.registry.registerObject(TELEMETRY_OBJ, PACKAGE_ID);
112+
if (opts.registerOrganization) engine.registry.registerObject(ORG_OBJECT, PACKAGE_ID);
113+
114+
const find = vi.spyOn(engine, 'find');
115+
const callsOn = (object: string) =>
116+
find.mock.calls.map((call, i) => ({ call, i })).filter(({ call }) => call[0] === object);
117+
118+
return {
119+
engine,
120+
driverReads,
121+
driverDeletes,
122+
/** The `where` of every candidate read the reaper issued through the engine. */
123+
reapReads: () => callsOn('sys_job_run').map(({ call }) => (call[1] as any)?.where),
124+
/** How many times the tenant scan asked the engine for `sys_organization`. */
125+
orgReads: () => callsOn('sys_organization').length,
126+
/** What the engine rejected the tenant scan's read with. */
127+
orgReadRejection: async () => {
128+
const [first] = callsOn('sys_organization');
129+
return (find.mock.results[first.i].value as Promise<unknown>).then(
130+
() => { throw new Error('the tenant scan read resolved; expected a rejection'); },
131+
(error: unknown) => error as Error & { code?: unknown; status?: unknown; object?: unknown },
132+
);
133+
},
134+
};
135+
}
136+
137+
function sweepOnce(engine: ObjectQL, warn: (msg: string) => void = () => {}) {
138+
return new LifecycleService({
139+
getEngine: () => engine,
140+
logger: { info: () => {}, warn, debug: () => {} },
141+
now: () => FIXED_NOW,
142+
initialDelayMs: 1,
143+
sweepIntervalMs: 10,
144+
getSettings: () => fakeSettings(TENANT_OVERRIDES),
145+
referenceAudit: { enabled: false },
146+
}).sweep();
147+
}
148+
149+
describe('LifecycleService.sweep — the tenant scan asks the registry first (#21597)', () => {
150+
// ── POSITIVE CONTROL ──────────────────────────────────────────────────────
151+
152+
it('control: a REGISTERED, provisioned sys_organization is read, and its tenant gets its own window', async () => {
153+
const box = await lifecycleEngine({ registerOrganization: true });
154+
155+
const report = await sweepOnce(box.engine);
156+
157+
// The guard does not skip the scan for a registered object: the read
158+
// reached the driver, and the tenant pass ran before the global one.
159+
expect(box.orgReads()).toBe(1);
160+
expect(box.driverReads.filter((o) => o === 'sys_organization')).toHaveLength(1);
161+
expect(box.reapReads()).toEqual([
162+
{ created_at: { $lt: isoCutoff('90d') }, organization_id: 'org_reg' },
163+
{
164+
created_at: { $lt: isoCutoff('30d') },
165+
$or: [{ organization_id: { $nin: ['org_reg'] } }, { organization_id: null }],
166+
},
167+
]);
168+
expect(report.errors).toEqual([]);
169+
});
170+
171+
// ── THE DEFECT ────────────────────────────────────────────────────────────
172+
173+
it('premise: with no sys_organization registered, the engine refuses the read itself', async () => {
174+
const box = await lifecycleEngine({ registerOrganization: false });
175+
176+
const refusal = await box.engine
177+
.find('sys_organization', { limit: 1, context: { isSystem: true } } as any)
178+
.then(() => undefined, (error: unknown) => error as Error & { code?: unknown; status?: unknown; object?: unknown });
179+
180+
expect(refusal?.code).toBe('OBJECT_NOT_FOUND');
181+
expect(refusal?.status).toBe(404);
182+
expect(refusal?.object).toBe('sys_organization');
183+
// Refused before any driver was asked: this is not a missing table.
184+
expect(box.driverReads).toEqual([]);
185+
});
186+
187+
it('an UNREGISTERED sys_organization is the single-tenant answer: the sweep runs on the global window', async () => {
188+
const warn = vi.fn();
189+
const box = await lifecycleEngine({ registerOrganization: false });
190+
191+
const report = await sweepOnce(box.engine, warn);
192+
193+
// The sweep was not aborted. Asserted first so that a regression shows the
194+
// abort it reported, not a call count.
195+
expect(report.errors).toEqual([]);
196+
// The registry answered, so the scan never read: no engine refusal to
197+
// abort on, and nothing reached the driver for `sys_organization`.
198+
expect(box.orgReads()).toBe(0);
199+
expect(box.driverReads.filter((o) => o === 'sys_organization')).toEqual([]);
200+
// The sweep RAN: one global pass, no tenant pass.
201+
expect(box.reapReads()).toEqual([{ created_at: { $lt: isoCutoff('30d') } }]);
202+
expect(report.swept).toEqual([
203+
{ object: 'sys_job_run', class: 'telemetry', policy: 'retention', cutoff: isoCutoff('30d'), deleted: 0 },
204+
]);
205+
expect(warn).not.toHaveBeenCalled();
206+
});
207+
208+
// ── THE BENIGN DRIVER CAUSE IS UNCHANGED ──────────────────────────────────
209+
210+
it('a REGISTERED but unprovisioned sys_organization keeps the missing-table answer', async () => {
211+
const box = await lifecycleEngine({
212+
registerOrganization: true,
213+
organizationRead: () => { throw missingTable(); },
214+
});
215+
216+
const report = await sweepOnce(box.engine);
217+
218+
// Proof the benign branch was exercised: the scan read, the driver threw.
219+
expect(box.orgReads()).toBe(1);
220+
expect(box.driverReads.filter((o) => o === 'sys_organization')).toHaveLength(1);
221+
expect(box.reapReads()).toEqual([{ created_at: { $lt: isoCutoff('30d') } }]);
222+
expect(report.errors).toEqual([]);
223+
expect(report.swept).toHaveLength(1);
224+
});
225+
226+
// ── EVERYTHING ELSE STILL ABORTS ──────────────────────────────────────────
227+
228+
it('a real driver fault on a registered sys_organization still aborts the sweep, with the fault itself', async () => {
229+
const warn = vi.fn();
230+
const box = await lifecycleEngine({
231+
registerOrganization: true,
232+
organizationRead: () => { throw outage(); },
233+
});
234+
235+
const report = await sweepOnce(box.engine, warn);
236+
const fault = await box.orgReadRejection();
237+
238+
// The read the sweep aborted on rejected with the driver's own fault.
239+
expect(fault.code).toBe('ECONNREFUSED');
240+
expect(fault.message).toContain('ECONNREFUSED');
241+
// …and that fault, not a swallowed outcome, is what the sweep reports.
242+
expect(box.reapReads()).toEqual([]);
243+
expect(box.driverDeletes).toEqual([]);
244+
expect(report.swept).toEqual([]);
245+
expect(report.errors).toEqual([{ object: 'sys_job_run', error: abortedError(fault.message) }]);
246+
expect(warn).toHaveBeenCalledTimes(1);
247+
expect(warn.mock.calls[0][0]).toContain(`(${fault.message})`);
248+
});
249+
250+
it('an OBJECT_NOT_FOUND attributed to ANOTHER object is not read as absence: the sweep aborts', async () => {
251+
const box = await lifecycleEngine({ registerOrganization: true });
252+
// A hook on the organization read that itself reads an object this
253+
// composition never registered — the engine refuses THAT read, and the
254+
// refusal surfaces from the `sys_organization` scan.
255+
box.engine.registerHook(
256+
'beforeFind',
257+
async () => {
258+
await box.engine.find('sys_org_unit', { limit: 1, context: { isSystem: true } } as any);
259+
},
260+
{ object: 'sys_organization' },
261+
);
262+
263+
const report = await sweepOnce(box.engine);
264+
const refusal = await box.orgReadRejection();
265+
266+
expect(refusal.code).toBe('OBJECT_NOT_FOUND');
267+
expect(refusal.status).toBe(404);
268+
expect(refusal.object).toBe('sys_org_unit');
269+
expect(box.reapReads()).toEqual([]);
270+
expect(report.swept).toEqual([]);
271+
expect(report.errors).toEqual([{ object: 'sys_job_run', error: abortedError(refusal.message) }]);
272+
});
273+
});

‎packages/objectql/src/lifecycle/lifecycle-service.ts‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,21 @@ export const DEFAULT_LIFECYCLE_INITIAL_DELAY_MS = 60_000;
8585

8686
/** Minimal engine surface the service needs — duck-typed for tests. */
8787
export interface LifecycleEngineLike {
88-
registry: { getAllObjects(): LifecycleObjectLike[] };
88+
registry: {
89+
getAllObjects(): LifecycleObjectLike[];
90+
/**
91+
* [#21597] Registry presence by name: the question the engine's in-process
92+
* verbs ask before they read (`ObjectQL`'s `SchemaRegistry.getObject`). A
93+
* name it does not resolve is refused with `OBJECT_NOT_FOUND` before any
94+
* driver is asked, so the governance tenant scan asks this first and reads
95+
* `sys_organization` only when it is registered.
96+
*
97+
* Optional so that a double modelling only `getAllObjects` stays a legal
98+
* engine. Such a registry cannot be asked, and the tenant scan then reads
99+
* exactly as it did before this member existed. Every real engine has it.
100+
*/
101+
getObject?(name: string): unknown;
102+
};
89103
delete(
90104
object: string,
91105
options: { where: Record<string, unknown>; multi: true; context: LifecycleSweepContext },
@@ -806,7 +820,10 @@ export class LifecycleService {
806820
* [#12853] The tenant scan is not. A `sys_organization` read that FAILED
807821
* throws out of here, because an empty `tenantOverrides` is the same value
808822
* as "this deployment has no tenant overrides" and the caller acts on the
809-
* difference by DELETING rows. See the catch below. */
823+
* difference by DELETING rows. See the catch below.
824+
*
825+
* [#21597] The scan asks the registry before it reads. An unregistered
826+
* `sys_organization` is never read, and answers "no tenant overrides". */
810827
private async loadGovernance(
811828
engine: LifecycleEngineLike,
812829
declared: LifecycleObjectLike[],
@@ -835,7 +852,22 @@ export class LifecycleService {
835852
// Tenant-level windows (ADR-0057 §3.2): only overrides genuinely stored
836853
// at TENANT scope count — inherited global values would otherwise turn
837854
// every tenant into a "tenant override" and break the global pass.
838-
if (typeof engine.find === 'function' && declared.length > 0) {
855+
//
856+
// [#21597] Registry first, the shape `ObjectQL.probeInstallOrganizations`
857+
// takes on the same question. Since commit eb9ef791bd the engine's
858+
// in-process verbs refuse a name the registry does not resolve with
859+
// `OBJECT_NOT_FOUND`, before any driver is asked. So in a composition that
860+
// registers no `sys_organization` (a lean embedding) the read below cannot
861+
// reach a table at all, and its refusal is not the missing-table cause the
862+
// catch accepts: every sweep used to abort on it. That composition has no
863+
// organization object, so it has no tenant to hold an override, and "no
864+
// tenant overrides" is the truth: the single-tenant answer, given here
865+
// without reading. The question is the registry's own `getObject`, the
866+
// same one the refusal asks, never a list of names. A registry that cannot
867+
// be asked (a double without `getObject`) reads as before.
868+
const organizationUnregistered =
869+
typeof engine.registry.getObject === 'function' && !engine.registry.getObject('sys_organization');
870+
if (typeof engine.find === 'function' && declared.length > 0 && !organizationUnregistered) {
839871
try {
840872
const orgs = await engine.find('sys_organization', {
841873
limit: TENANT_SCAN_LIMIT,
@@ -887,6 +919,12 @@ export class LifecycleService {
887919
// incomplete evidence" is the correct failure direction: a log cannot
888920
// bring back a reaped row, and the rows this defers are still there for
889921
// the next sweep to reap once the read succeeds.
922+
//
923+
// [#21597] That includes an `OBJECT_NOT_FOUND`. The unregistered case
924+
// never reaches this catch (the registry was asked above), so a
925+
// refusal here names some other object (a hook's nested read, say) or
926+
// contradicts the registry's own answer. Neither is evidence that no
927+
// tenant exists, and neither is read as absence.
890928
if (!isMissingTableError(error, 'sys_organization')) throw error;
891929
}
892930
}

‎packages/runtime/src/expected-read-refusal-noise.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@
1616
* * `resolveUserAuthzGrants` (`core/src/security/resolve-authz-context.ts`)
1717
* `tryFind`s six `sys_*` tables per grant resolution — the resolver is
1818
* fail-closed and must always resolve;
19-
* * `ObjectQL.probeInstallOrganizations` (`objectql/src/engine.ts`) reads
20-
* `sys_organization` and catches `isMissingTableError` **only**, which its
21-
* own doc comment names as "the one benign cause";
22-
* * `SeedLoaderService.resolveSoleOrganizationId`
23-
* (`metadata-protocol/src/seed-loader.ts`) and
24-
* `LifecycleService`'s governance snapshot read the same table best-effort;
19+
* * `ObjectQL.probeInstallOrganizations` (`objectql/src/engine.ts`) asks the
20+
* registry first and never reads an UNREGISTERED `sys_organization`; for a
21+
* registered one it catches `isMissingTableError` **only**, which its own
22+
* doc comment names as the one benign driver cause;
23+
* * `LifecycleService`'s governance snapshot takes the same registry-first
24+
* shape on the same table, and `SeedLoaderService.resolveSoleOrganizationId`
25+
* (`metadata-protocol/src/seed-loader.ts`) reads it best-effort, accepting a
26+
* missing table or the engine's `OBJECT_NOT_FOUND` refusal attributed to
27+
* `sys_organization` itself;
2528
* * `runBuildProbes` (`metadata-protocol/src/build-probes.ts`) reads the
2629
* object a published view is bound to, and turns a failure into a
2730
* `view_read_failed` publish issue rather than an exception;

0 commit comments

Comments
 (0)