Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/21597-lifecycle-registry-first-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/objectql': patch
---

`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

Clause-②: no

- 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.
- 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.
- `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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#21597] `loadGovernance`'s tenant scan asks the REGISTRY whether
* `sys_organization` is registered before it reads it.
*
* Since commit eb9ef791bd an in-process engine verb refuses an object name the
* registry does not resolve, with `OBJECT_NOT_FOUND` (404), before any driver
* is asked. A composition that registers no `sys_organization` therefore no
* longer reaches a driver with the tenant scan's read: it gets the engine's
* refusal. That is not a missing table, so the scan's catch rethrew it, and
* every sweep aborted before applying a single policy.
*
* Unregistered is the single-tenant answer. With no organization object there
* is no tenant to hold an override, and that is the same answer
* `ObjectQL.probeInstallOrganizations` gives to the same question. What does
* NOT change is the catch: a missing table on a REGISTERED `sys_organization`
* is still the one benign driver cause, and every other failure still aborts
* the sweep (#12853). In particular an `OBJECT_NOT_FOUND` is not read as
* absence wholesale: the registry already said the object is registered, so a
* refusal arriving from the read is about something else.
*
* Every case runs 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 at it.
* `engine.find` is spied with call-through: the spy records what the sweep
* asked the engine, and the engine still answers.
*/

import { describe, it, expect, vi } from 'vitest';
import type { EngineQueryOptions } from '@objectstack/spec/data';
import { ObjectQL } from '../engine.js';
import { LifecycleService } from './lifecycle-service.js';
import { parseLifecycleDuration } from './duration.js';

const FIXED_NOW = 1_700_000_000_000;
const PACKAGE_ID = 'lifecycle-organization-registry';

/** The lifecycle-declared object every sweep below reaps. */
const TELEMETRY_OBJ = {
name: 'sys_job_run',
fields: { status: { type: 'text' } },
lifecycle: { class: 'telemetry', retention: { maxAge: '30d' } },
} as any;

const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } } as any;

/** A system-context read of one row, spelled as a typed query. */
const ORG_PROBE: EngineQueryOptions = { limit: 1, context: { isSystem: true } };

const isoCutoff = (literal: string) => new Date(FIXED_NOW - parseLifecycleDuration(literal)).toISOString();

/** The regulated tenant keeps its rows three times longer than the global window. */
const TENANT_OVERRIDES = { org_reg: { retention_overrides: { sys_job_run: { maxAge: '90d' } } } };

/** Settings service whose only values are tenant-scoped ones. */
function fakeSettings(tenantValues: Record<string, Record<string, unknown>>) {
return {
async get(_ns: string, key: string, ctx?: Record<string, unknown>) {
const tenantId = ctx?.tenantId as string | undefined;
if (tenantId && tenantValues[tenantId] && key in tenantValues[tenantId]) {
return { value: tenantValues[tenantId][key], source: 'tenant' };
}
return { value: undefined, source: 'default' };
},
};
}

/** A transient database outage, the shape `isMissingTableError` answers `false` for. */
const outage = () =>
Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), { code: 'ECONNREFUSED' });

/** The benign unprovisioned table, in the SQLite-family spelling. */
const missingTable = () => new Error('no such table: sys_organization');

const abortedError = (message: string) =>
`governance snapshot could not be loaded (${message}) — sweep aborted before any policy ` +
'was applied, so no rows were reaped for this object';

/**
* A real engine with `sys_job_run` registered, and `sys_organization`
* registered only when asked. The stub driver answers the organization read
* with `organizationRead` and every candidate page with no rows.
*/
async function lifecycleEngine(opts: {
registerOrganization: boolean;
organizationRead?: () => Array<Record<string, unknown>>;
}) {
const driverReads: string[] = [];
const driverDeletes: string[] = [];
const organizationRead = opts.organizationRead ?? (() => [{ id: 'org_reg' }]);
const driver = {
name: 'memory',
version: '0.0.0',
supports: {},
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
async execute() { return null; },
async find(object: string) {
driverReads.push(object);
return object === 'sys_organization' ? organizationRead() : [];
},
async findOne() { return null; },
async count() { return 0; },
async create(_object: string, data: any) { return { id: 'r_1', ...data }; },
async update(_object: string, id: string, data: any) { return { id, ...data }; },
async delete(object: string) { driverDeletes.push(object); return true; },
async bulkCreate(_object: string, rows: any[]) { return rows; },
async bulkUpdate() { return []; },
async bulkDelete(object: string) { driverDeletes.push(object); },
async syncSchema() {},
} as any;

const engine = new ObjectQL();
engine.registerDriver(driver, true);
await engine.init();
engine.registry.registerObject(TELEMETRY_OBJ, PACKAGE_ID);
if (opts.registerOrganization) engine.registry.registerObject(ORG_OBJECT, PACKAGE_ID);

const find = vi.spyOn(engine, 'find');
const callsOn = (object: string) =>
find.mock.calls.map((call, i) => ({ call, i })).filter(({ call }) => call[0] === object);

return {
engine,
driverReads,
driverDeletes,
/** The `where` of every candidate read the reaper issued through the engine. */
reapReads: () => callsOn('sys_job_run').map(({ call }) => call[1]?.where),
/** How many times the tenant scan asked the engine for `sys_organization`. */
orgReads: () => callsOn('sys_organization').length,
/** What the engine rejected the tenant scan's read with. */
orgReadRejection: async () => {
const [first] = callsOn('sys_organization');
return (find.mock.results[first.i].value as Promise<unknown>).then(
() => { throw new Error('the tenant scan read resolved; expected a rejection'); },
(error: unknown) => error as Error & { code?: unknown; status?: unknown; object?: unknown },
);
},
};
}

function sweepOnce(engine: ObjectQL, warn: (msg: string) => void = () => {}) {
return new LifecycleService({
getEngine: () => engine,
logger: { info: () => {}, warn, debug: () => {} },
now: () => FIXED_NOW,
initialDelayMs: 1,
sweepIntervalMs: 10,
getSettings: () => fakeSettings(TENANT_OVERRIDES),
referenceAudit: { enabled: false },
}).sweep();
}

describe('LifecycleService.sweep — the tenant scan asks the registry first (#21597)', () => {
// ── POSITIVE CONTROL ──────────────────────────────────────────────────────

it('control: a REGISTERED, provisioned sys_organization is read, and its tenant gets its own window', async () => {
const box = await lifecycleEngine({ registerOrganization: true });

const report = await sweepOnce(box.engine);

// The guard does not skip the scan for a registered object: the read
// reached the driver, and the tenant pass ran before the global one.
expect(box.orgReads()).toBe(1);
expect(box.driverReads.filter((o) => o === 'sys_organization')).toHaveLength(1);
expect(box.reapReads()).toEqual([
{ created_at: { $lt: isoCutoff('90d') }, organization_id: 'org_reg' },
{
created_at: { $lt: isoCutoff('30d') },
$or: [{ organization_id: { $nin: ['org_reg'] } }, { organization_id: null }],
},
]);
expect(report.errors).toEqual([]);
});

// ── THE DEFECT ────────────────────────────────────────────────────────────

it('premise: with no sys_organization registered, the engine refuses the read itself', async () => {
const box = await lifecycleEngine({ registerOrganization: false });

const refusal = await box.engine
.find('sys_organization', ORG_PROBE)
.then(() => undefined, (error: unknown) => error as Error & { code?: unknown; status?: unknown; object?: unknown });

expect(refusal?.code).toBe('OBJECT_NOT_FOUND');
expect(refusal?.status).toBe(404);
expect(refusal?.object).toBe('sys_organization');
// Refused before any driver was asked: this is not a missing table.
expect(box.driverReads).toEqual([]);
});

it('an UNREGISTERED sys_organization is the single-tenant answer: the sweep runs on the global window', async () => {
const warn = vi.fn();
const box = await lifecycleEngine({ registerOrganization: false });

const report = await sweepOnce(box.engine, warn);

// The sweep was not aborted. Asserted first so that a regression shows the
// abort it reported, not a call count.
expect(report.errors).toEqual([]);
// The registry answered, so the scan never read: no engine refusal to
// abort on, and nothing reached the driver for `sys_organization`.
expect(box.orgReads()).toBe(0);
expect(box.driverReads.filter((o) => o === 'sys_organization')).toEqual([]);
// The sweep RAN: one global pass, no tenant pass.
expect(box.reapReads()).toEqual([{ created_at: { $lt: isoCutoff('30d') } }]);
expect(report.swept).toEqual([
{ object: 'sys_job_run', class: 'telemetry', policy: 'retention', cutoff: isoCutoff('30d'), deleted: 0 },
]);
expect(warn).not.toHaveBeenCalled();
});

// ── THE BENIGN DRIVER CAUSE IS UNCHANGED ──────────────────────────────────

it('a REGISTERED but unprovisioned sys_organization keeps the missing-table answer', async () => {
const box = await lifecycleEngine({
registerOrganization: true,
organizationRead: () => { throw missingTable(); },
});

const report = await sweepOnce(box.engine);

// Proof the benign branch was exercised: the scan read, the driver threw.
expect(box.orgReads()).toBe(1);
expect(box.driverReads.filter((o) => o === 'sys_organization')).toHaveLength(1);
expect(box.reapReads()).toEqual([{ created_at: { $lt: isoCutoff('30d') } }]);
expect(report.errors).toEqual([]);
expect(report.swept).toHaveLength(1);
});

// ── EVERYTHING ELSE STILL ABORTS ──────────────────────────────────────────

it('a real driver fault on a registered sys_organization still aborts the sweep, with the fault itself', async () => {
const warn = vi.fn();
const box = await lifecycleEngine({
registerOrganization: true,
organizationRead: () => { throw outage(); },
});

const report = await sweepOnce(box.engine, warn);
const fault = await box.orgReadRejection();

// The read the sweep aborted on rejected with the driver's own fault.
expect(fault.code).toBe('ECONNREFUSED');
expect(fault.message).toContain('ECONNREFUSED');
// …and that fault, not a swallowed outcome, is what the sweep reports.
expect(box.reapReads()).toEqual([]);
expect(box.driverDeletes).toEqual([]);
expect(report.swept).toEqual([]);
expect(report.errors).toEqual([{ object: 'sys_job_run', error: abortedError(fault.message) }]);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain(`(${fault.message})`);
});

it('an OBJECT_NOT_FOUND attributed to ANOTHER object is not read as absence: the sweep aborts', async () => {
const box = await lifecycleEngine({ registerOrganization: true });
// A hook on the organization read that itself reads an object this
// composition never registered — the engine refuses THAT read, and the
// refusal surfaces from the `sys_organization` scan.
box.engine.registerHook(
'beforeFind',
async () => {
await box.engine.find('sys_org_unit', ORG_PROBE);
},
{ object: 'sys_organization' },
);

const report = await sweepOnce(box.engine);
const refusal = await box.orgReadRejection();

expect(refusal.code).toBe('OBJECT_NOT_FOUND');
expect(refusal.status).toBe(404);
expect(refusal.object).toBe('sys_org_unit');
expect(box.reapReads()).toEqual([]);
expect(report.swept).toEqual([]);
expect(report.errors).toEqual([{ object: 'sys_job_run', error: abortedError(refusal.message) }]);
});
});
44 changes: 41 additions & 3 deletions packages/objectql/src/lifecycle/lifecycle-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,21 @@ export const DEFAULT_LIFECYCLE_INITIAL_DELAY_MS = 60_000;

/** Minimal engine surface the service needs — duck-typed for tests. */
export interface LifecycleEngineLike {
registry: { getAllObjects(): LifecycleObjectLike[] };
registry: {
getAllObjects(): LifecycleObjectLike[];
/**
* [#21597] Registry presence by name: the question the engine's in-process
* verbs ask before they read (`ObjectQL`'s `SchemaRegistry.getObject`). A
* name it does not resolve is refused with `OBJECT_NOT_FOUND` before any
* driver is asked, so the governance tenant scan asks this first and reads
* `sys_organization` only when it is registered.
*
* Optional so that a double modelling only `getAllObjects` stays a legal
* engine. Such a registry cannot be asked, and the tenant scan then reads
* exactly as it did before this member existed. Every real engine has it.
*/
getObject?(name: string): unknown;
};
delete(
object: string,
options: { where: Record<string, unknown>; multi: true; context: LifecycleSweepContext },
Expand Down Expand Up @@ -806,7 +820,10 @@ export class LifecycleService {
* [#12853] The tenant scan is not. A `sys_organization` read that FAILED
* throws out of here, because an empty `tenantOverrides` is the same value
* as "this deployment has no tenant overrides" and the caller acts on the
* difference by DELETING rows. See the catch below. */
* difference by DELETING rows. See the catch below.
*
* [#21597] The scan asks the registry before it reads. An unregistered
* `sys_organization` is never read, and answers "no tenant overrides". */
private async loadGovernance(
engine: LifecycleEngineLike,
declared: LifecycleObjectLike[],
Expand Down Expand Up @@ -835,7 +852,22 @@ export class LifecycleService {
// Tenant-level windows (ADR-0057 §3.2): only overrides genuinely stored
// at TENANT scope count — inherited global values would otherwise turn
// every tenant into a "tenant override" and break the global pass.
if (typeof engine.find === 'function' && declared.length > 0) {
//
// [#21597] Registry first, the shape `ObjectQL.probeInstallOrganizations`
// takes on the same question. Since commit eb9ef791bd the engine's
// in-process verbs refuse a name the registry does not resolve with
// `OBJECT_NOT_FOUND`, before any driver is asked. So in a composition that
// registers no `sys_organization` (a lean embedding) the read below cannot
// reach a table at all, and its refusal is not the missing-table cause the
// catch accepts: every sweep used to abort on it. That composition has no
// organization object, so it has no tenant to hold an override, and "no
// tenant overrides" is the truth: the single-tenant answer, given here
// without reading. The question is the registry's own `getObject`, the
// same one the refusal asks, never a list of names. A registry that cannot
// be asked (a double without `getObject`) reads as before.
const organizationUnregistered =
typeof engine.registry.getObject === 'function' && !engine.registry.getObject('sys_organization');
if (typeof engine.find === 'function' && declared.length > 0 && !organizationUnregistered) {
try {
const orgs = await engine.find('sys_organization', {
limit: TENANT_SCAN_LIMIT,
Expand Down Expand Up @@ -887,6 +919,12 @@ export class LifecycleService {
// incomplete evidence" is the correct failure direction: a log cannot
// bring back a reaped row, and the rows this defers are still there for
// the next sweep to reap once the read succeeds.
//
// [#21597] That includes an `OBJECT_NOT_FOUND`. The unregistered case
// never reaches this catch (the registry was asked above), so a
// refusal here names some other object (a hook's nested read, say) or
// contradicts the registry's own answer. Neither is evidence that no
// tenant exists, and neither is read as absence.
if (!isMissingTableError(error, 'sys_organization')) throw error;
}
}
Expand Down
15 changes: 9 additions & 6 deletions packages/runtime/src/expected-read-refusal-noise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@
* * `resolveUserAuthzGrants` (`core/src/security/resolve-authz-context.ts`)
* `tryFind`s six `sys_*` tables per grant resolution — the resolver is
* fail-closed and must always resolve;
* * `ObjectQL.probeInstallOrganizations` (`objectql/src/engine.ts`) reads
* `sys_organization` and catches `isMissingTableError` **only**, which its
* own doc comment names as "the one benign cause";
* * `SeedLoaderService.resolveSoleOrganizationId`
* (`metadata-protocol/src/seed-loader.ts`) and
* `LifecycleService`'s governance snapshot read the same table best-effort;
* * `ObjectQL.probeInstallOrganizations` (`objectql/src/engine.ts`) asks the
* registry first and never reads an UNREGISTERED `sys_organization`; for a
* registered one it catches `isMissingTableError` **only**, which its own
* doc comment names as the one benign driver cause;
* * `LifecycleService`'s governance snapshot takes the same registry-first
* shape on the same table, and `SeedLoaderService.resolveSoleOrganizationId`
* (`metadata-protocol/src/seed-loader.ts`) reads it best-effort, accepting a
* missing table or the engine's `OBJECT_NOT_FOUND` refusal attributed to
* `sys_organization` itself;
* * `runBuildProbes` (`metadata-protocol/src/build-probes.ts`) reads the
* object a published view is bound to, and turns a failure into a
* `view_read_failed` publish issue rather than an exception;
Expand Down
Loading