Skip to content

Commit a1ca156

Browse files
fix(objectql): lifecycle tenant scan asks the registry before reading sys_organization (#21628)
Fixes #21597 Clause-②: no ## What changed - **`packages/objectql/src/lifecycle/lifecycle-service.ts`**: `loadGovernance`'s tenant scan now asks `engine.registry.getObject('sys_organization')` before it reads that object. - An **unregistered** `sys_organization` is the single-tenant answer. There is no read and no tenant override, and the sweep runs one global pass on each declared window. - A **registered** one is read exactly as before. The catch still accepts only `isMissingTableError(error, 'sys_organization')`. Every other failure still aborts the sweep, and that includes an `OBJECT_NOT_FOUND` thrown from that read. - **`LifecycleEngineLike['registry']`** declares the optional `getObject?(name: string): unknown` member the scan reads, because a published type must not refuse a key the code below it reads. - It is optional so that a double modelling only `getAllObjects` stays a legal engine. Such a registry cannot be asked, and the scan then reads as before. - Every real engine has it: `ObjectQL.registry` is the `SchemaRegistry`. - **`packages/runtime/src/expected-read-refusal-noise.ts`** (the claim's declared cross-lane path, comment only): the header no longer says the org probe catches only a missing table. It now says that the probe and the lifecycle snapshot both ask the registry first, and it states what the seed-loader accepts. - **New pins** in `packages/objectql/src/lifecycle/lifecycle-service.organization-registry.test.ts`: six cases on a REAL `ObjectQL` engine over a stub driver, so the refusal the guard avoids is the engine's own and not a double's guess. - **`.changeset/21597-lifecycle-registry-first-guard.md`**: `patch` for `@objectstack/objectql` only. The runtime half is measured below. ## Why: the mechanism, measured - Since `eb9ef791bd`, `ObjectQL.resolveObjectName` throws `objectNotFoundError(name)` exactly when `this._registry.getObject(name)` is falsy. Every in-process verb resolves through it. So `engine.registry.getObject` is the refusal's own predicate: the guard asks the very question the refusal asks, never a list of names. - `getAllObjects` was not used for this. It is a full merging walk with a side effect, and it can disagree with `getObject`'s short-name and FQN index. The dangerous direction of that disagreement is "absent" for an object that resolves. - **Premise at base `36ad3210d4`:** the new file was run before the fix and gave 5 passed, 1 failed. - The premise case shows the engine refusing `find('sys_organization')` with `code: 'OBJECT_NOT_FOUND'`, `status: 404` and `object: 'sys_organization'`, with no driver read at all. - The unregistered case fails on the scan having read `sys_organization` (`expected 1 to be +0`). ## Which shape was followed, and why No shared helper exists: there is no registry-presence helper in objectql, core or types. #21545 left two shapes: - the engine probe's **registry presence before the read** (`probeInstallOrganizations`); - the seed-loader's **catch-side refusal attributed on `object`** (`resolveSoleOrganizationId`). This follows the engine probe's shape, with its catch unchanged. It is spelled with `MigrationRecoveryPlugin`'s capability check (`typeof ... === 'function' && !...`), because `LifecycleService` holds a duck-typed engine rather than the registry itself. It is not the seed-loader's catch-side shape, for three reasons: 1. The card's ruling names the registry-first guard. 2. On a real engine the registry question makes a catch-side `sys_organization` arm unreachable, so adding that arm as well would make a third variant. 3. An `OBJECT_NOT_FOUND` that arrives after the registry said "registered" either names another object or contradicts the registry. It propagates, and this is pinned. There is no new engine API and no `engine.ts` edit. The registry comes from the engine's existing `registry` accessor, which settles H3. ## Pins (real `ObjectQL`, `engine.find` spied with call-through) | case | asserts | |:--|:--| | control: registered, provisioned, one org with a 90d tenant override | scan reads once (reaches the driver); tenant pass at 90d, then the global 30d pass; no error | | premise: unregistered | the engine refuses the read: `OBJECT_NOT_FOUND`, 404, `object: 'sys_organization'`; the driver saw nothing | | **unregistered: single-tenant sweep** | `report.errors` empty; zero scan reads; one global 30d pass; `report.swept` records the 30d cutoff; no warn | | registered but unprovisioned (`no such table`) | the scan reads and the driver throws; global pass; no error (unchanged missing-table answer) | | real driver fault (`ECONNREFUSED`) | the scan's read rejects with `code: 'ECONNREFUSED'`; no candidate read, no delete, nothing swept; `report.errors` and the warn quote that fault's own message | | `OBJECT_NOT_FOUND` attributed to ANOTHER object (a `beforeFind` hook reads an unregistered `sys_org_unit`) | rejection `OBJECT_NOT_FOUND`, 404, `object: 'sys_org_unit'`; the sweep aborts; not read as absence | ## Verification, at final head `190cea2e9e` - **Reverse verification.** The fix was committed first, then the guard was ablated through `scripts/ablation-replace.mjs`: the anchor hit once, and the blob went `a3ff3c2d4d3d` to `6c3fa03e505d`. Result: `src/lifecycle/` gave **1 failed, 115 passed (116)**. Only the unregistered pin went red, and it reported the defect itself: `governance snapshot could not be loaded (Object 'sys_organization' not found) — sweep aborted before any policy was applied`. - The restore was `git checkout HEAD -- ABSPATH`. The blob is back at the HEAD blob `a3ff3c2d4d3d`, `git diff HEAD` is empty and `git status --porcelain` is empty. - The first run of the same ablation, at `dfb2af2144`, gave the same direction. - The 110 pre-existing lifecycle tests stay green with the guard ablated. Their doubles have no `getObject`, so they never reach the guard. - **Tests.** - `pnpm --filter @objectstack/objectql test`: Test Files 368 passed (368), Tests 7421 passed (7421). - `src/lifecycle/`: 2 files, 116 passed. - **Typecheck.** `pnpm --filter @objectstack/objectql typecheck` exit 0, with `check:test-typecheck: OK`. The new file is in the test program (`tsc -p tsconfig.test.json --listFilesOnly`: 1 hit) and compiles with zero errors. - **Import side.** objectql's published `.d.ts` gains the optional member. Three test files in other packages build typed `LifecycleEngineLike` doubles, all `registry: { getAllObjects }` and none passing `getSettings`. - `pnpm --filter @objectstack/service-messaging typecheck` exits 0. - Reverse leg: pasting `getObject: 42` into its double gives `TS2352 ... The types of 'registry.getObject' are incompatible` against the rebuilt `.d.ts`. The same paste passes an `as` assertion against the old type, so this proves the consumer read the rebuilt declarations. Restore proven. - **Runtime.** - `pnpm --filter @objectstack/runtime typecheck` exit 0. - `src/expected-read-refusal-noise.channel-asymmetry.test.ts`: 4 passed. - The edited module is not in runtime's published files. After a build, its header text and its export `captureExpectedReadRefusals` have 0 hits in `dist/`, and the module is 0 of 79 `sources` in both sourcemaps. The positive control `migration-recovery-plugin` is 1 of 79, and its export hits 4 `dist` files. So the changeset carries no runtime entry. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` derived 64 commands at `190cea2e9e`, and all 64 exited 0. `--ran` reconciliation: 64 derived, 64 run, 0 NOT-MEASURED (a derived zero, every exit code recorded). - `check:query-options-erasure` was red once on the first head (test surface 236 to 238: two `as any` query options in the new file). Both are typed now, and the ratchet holds at 236. - **Lint (a proven narrowing; `pnpm lint` is CI's).** `eslint --no-inline-config --format json` was run on the three TS files in the diff: 3 files, 0 errors, 0 warnings. The changeset `.md` is outside eslint's configuration ("no matching configuration"). `eslint.config.mjs` states that it never enables type-aware linting, so this diff cannot move any untouched file's verdict. ## Acceptance notes - **Boundary of the ruling, noted, not filed.** Take a composition that registers no `sys_organization` while its database still holds an organization table written by another composition. It now sweeps every tenant on the global window. That is the card's stated semantics, and it is the same answer `probeInstallOrganizations` gives. Since `eb9ef791bd` no in-process verb can read that table by its raw name anyway. - **Clause line.** `Clause-②: no` is copied from the claim. The only type change is the optional member on the published input type `LifecycleEngineLike['registry']`. Every engine accepted before is still accepted, and there is no new export. Precedent: commit `0f38ab084` added the optional `tenancy` key to `LifecycleObjectLike`, and it shipped as an objectql `patch`. - **Branch base.** The branch is 2 commits behind `origin/main` (`f97660cdd6`). Neither commit touches objectql, the lifecycle or the runtime header, so the branch is not merged here. - **Worker count.** The full objectql run passed `--maxWorkers=2` after a bare `--`, which vitest drops, so it ran at vitest's default worker count. It is still a whole-package measurement. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0fc8087 commit a1ca156

4 files changed

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

‎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)