diff --git a/.changeset/objectql-plugin-registry-read-seams.md b/.changeset/objectql-plugin-registry-read-seams.md new file mode 100644 index 0000000000..32b11b89aa --- /dev/null +++ b/.changeset/objectql-plugin-registry-read-seams.md @@ -0,0 +1,61 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): `ObjectQLPlugin`'s three registry reads stop inventing an empty registry — one of them silently skipped schema sync for every object at boot (#9285) + +`ObjectQLPlugin` read the registered object set in three places, all spelled +`this.ql.registry?.getAllObjects?.() ?? []`. That expression folds three +different facts into one value: + +1. the registry answered, and holds no objects; +2. the engine exposes no `registry` at all; +3. the registry exposes no `getAllObjects` — a **structural** omission that + never throws, so it is invisible precisely when it is wrong. + +Only (1) is truthfully *"no objects"*. #8895 ruled this family **discriminate or +propagate**; #9002 and #9154 applied it to the two delete-cascade seams and the +roll-up summary index. This closes the same shape in the plugin, where the +consequential seam is at **boot**. + +The three seams get three different answers, and the difference is the fix: + +- **`syncRegisteredSchemas` — propagates.** Its next line is + `if (allObjects.length === 0) return;`, so an invented empty answer meant **no + registered object's schema was synced to any driver** — no table created, no + column added — silently, at boot, with the plugin reporting a clean start. + Failing the boot is more truthful than starting against a store whose DDL + never ran. On the `metadata:reloaded` path the existing caller already catches + this and reports it at `error` (#4632), so propagation there is a loud + durability report rather than a dead kernel. +- **`reconcileFederatedBindings` — reports at `error`, then degrades.** The pass + exists to *name* the federated objects it could not bind ("a boot with nothing + to report says nothing"), so an unreadable registry making it report nothing + was exactly the silence it was written to prevent. It stays exception-proof: + it is a post-hoc reconciliation run after every `start()`, deliberately not a + boot gate. +- **`runGovernanceInventory` — reports at `warn`, then skips.** This seam + carried **two independent swallows** (`?.()` *and* a wrapping + `try { … } catch { return [] }`), so a *throwing* registry was + indistinguishable from an empty one. Feeding the audit an invented empty + object set is worse than silence: with no objects, every handler declared *on* + an object reconciles as an "undeclared handler … REFUSED at dispatch", so an + unreadable registry accused a healthy deployment. The inventory is warn-only + and exception-proof by contract, so it reports and skips instead of + propagating, and leaves its report fingerprint untouched so the next + successful run is not suppressed as "unchanged". + +All three now read through one shared helper that throws rather than inventing, +naming the consequence; a registry that *throws* propagates its own error +verbatim. + +This is a **structural** close, not a live defect — re-derived on this tree: +`SchemaRegistry.getAllObjects()` is a walk over in-memory `Map`s calling +`resolveObject()`, which returns `undefined` on every failure branch it models +and never throws, and `ObjectQL.registry` is a getter over a field-initialized +`SchemaRegistry`, so for a real engine neither optional link can short-circuit. +The reach that is real is a duck-typed `ql` — an incomplete test double, which +#9154 measured shipping in nine suites at once. + +The `objectsRegistered` count in the `ObjectQL engine started` info log is +deliberately unchanged: a wrong `0` there costs one advisory line and no data. diff --git a/packages/objectql/src/plugin-registry-read-failure.test.ts b/packages/objectql/src/plugin-registry-read-failure.test.ts new file mode 100644 index 0000000000..7deba0a51b --- /dev/null +++ b/packages/objectql/src/plugin-registry-read-failure.test.ts @@ -0,0 +1,374 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#9285] `ObjectQLPlugin`'s three registry reads must not answer a read that + * could not run with an invented *"the registry holds nothing"*. + * + * All three used to spell the read `this.ql.registry?.getAllObjects?.() ?? []`, + * which folds three different facts into one value: the registry answered and + * holds nothing; the engine exposes no `registry`; the registry exposes no + * `getAllObjects`. Only the first is truthfully "no objects". #8895 ruled this + * family *discriminate or propagate*, and #9002 / #9154 applied it to the two + * delete-cascade seams and the roll-up summary index; this closes the same + * shape in the plugin, where the consequential seam is at BOOT. + * + * The three seams get three different answers, and the difference is the point: + * + * 1. `syncRegisteredSchemas` — PROPAGATES. Its next line is + * `if (allObjects.length === 0) return;`, so an invented empty answer means + * NO registered object's schema is synced to any driver — no table created, + * no column added — silently, at boot, with the plugin reporting a clean + * start. Failing the boot is more truthful than starting against a store + * whose DDL never ran. + * 2. `reconcileFederatedBindings` — REPORTS at `error`, then degrades. The + * pass exists to NAME the federated objects it could not bind ("a boot with + * nothing to report says nothing"), so an unreadable registry making it + * report nothing is exactly the silence it was written to prevent. It is a + * post-hoc reconciliation run after every `start()`, deliberately not a + * boot gate, so it reports rather than throws. + * 3. `runGovernanceInventory` — REPORTS at `warn`, then skips. This seam + * carried TWO independent swallows (`?.()` and a wrapping + * `try { … } catch { return [] }`), and feeding the audit an invented empty + * object set is worse than silence: with no objects, every handler declared + * ON an object reconciles as an "undeclared handler … REFUSED at dispatch", + * so an unreadable registry accused a healthy deployment. The inventory is + * warn-only and exception-proof by contract, so it must not propagate. + * + * ⚠️ This pins a STRUCTURAL close, not a live defect — re-derived on this tree. + * `SchemaRegistry.getAllObjects()` is a walk over in-memory `Map`s calling + * `resolveObject()`, which returns `undefined` on every failure branch it + * models and never throws; the fold below it is spreads and comparisons, with + * no I/O and no driver. And `ObjectQL.registry` is a getter over a + * field-initialized `SchemaRegistry`, so for a real engine neither optional + * link can short-circuit either. The failure is therefore injected AT the + * registry — and the injection IS the statement that nothing shipped reaches + * these seams today. The reach that is real is a duck-typed `ql`: an + * incomplete test double, which #9154 measured shipping in nine suites at once. + * + * One case is labelled PRESERVED rather than fixed, because the ablation said + * so: a throwing registry already propagated out of `syncRegisteredSchemas` + * before #9285 (its `?.` chain short-circuits on ABSENCE, never on a throw, and + * that seam had no `catch`). Seam 2's defect was the structural half alone. The + * test is kept — relabelled — because it fails the day someone wraps this read + * in a `try/catch`, which is how seam 3 acquired its second swallow. + * + * Every failure case is paired with a POSITIVE CONTROL in the same describe — + * a genuinely empty registry, and a populated one that still does the work — so + * a plugin that had stopped syncing/binding/auditing altogether could not pass + * any of this vacuously. + */ + +import { describe, it, expect } from 'vitest'; +import type { ServiceObject } from '@objectstack/spec/data'; +import { ObjectQL } from './engine.js'; +import { ObjectQLPlugin } from './plugin.js'; + +/* Fixtures are typed as `ServiceObject` rather than left to inference, so this + * file adds nothing to `@objectstack/objectql`'s TEST_DEBT ledger (#5278). */ +const acct: ServiceObject = { + name: 'acct', + label: 'Account', + fields: { id: { name: 'id', label: 'ID', type: 'text' as const } }, +}; + +/** A federated object — the input `reconcileFederatedBindings` exists for. */ +const remoteAcct: ServiceObject = { + name: 'remote_acct', + label: 'Remote account', + fields: { id: { name: 'id', label: 'ID', type: 'text' as const } }, + external: { remoteName: 'acct_remote' }, +}; + +/** An object that DECLARES the action the engine has a handler for. */ +const acctWithAction: ServiceObject = { + ...acct, + actions: [ + { + name: 'ping', + label: 'Ping', + type: 'script' as const, + body: { language: 'js' as const, source: 'return 1;' }, + }, + ], +}; + +interface Recorded { + level: 'debug' | 'info' | 'warn' | 'error'; + message: string; + args: unknown[]; +} + +function recordingLogger() { + const records: Recorded[] = []; + const push = (level: Recorded['level']) => (message: string, ...args: unknown[]) => + void records.push({ level, message: String(message), args }); + return { + records, + logger: { debug: push('debug'), info: push('info'), warn: push('warn'), error: push('error') }, + at(level: Recorded['level']) { + return records.filter((r) => r.level === level); + }, + }; +} + +/** The error object every "registry throws" test asserts the IDENTITY of. */ +const INJECTED = new Error('registry read exploded'); + +/** + * The three shapes the old `?.()` / `?? []` spelling folded together, injected + * at the registry itself. + * + * `throws` shadows the prototype method with an own property; the other two + * shadow the `registry` GETTER with an own property, which is the only way to + * express "this engine's registry does not implement the method" / "this engine + * has no registry" against a real class. Both are exactly the duck-typed `ql` + * an incomplete test double produces. + */ +type Injection = 'throws' | 'no-method' | 'no-registry'; + +function injectRegistryFailure(engine: ObjectQL, how: Injection): void { + if (how === 'throws') { + (engine.registry as any).getAllObjects = () => { + throw INJECTED; + }; + return; + } + Object.defineProperty(engine, 'registry', { + value: how === 'no-registry' ? undefined : { getObject: () => undefined }, + configurable: true, + }); +} + +/** A driver that records the DDL it was asked for. */ +function recordingDriver() { + const synced: string[] = []; + const bound: string[] = []; + return { + synced, + bound, + driver: { + name: 'default', + supports: {}, + async syncSchema(tableName: string) { + synced.push(tableName); + }, + async registerExternalObject(obj: { name: string }) { + bound.push(obj.name); + }, + async find() { + return []; + }, + }, + }; +} + +function makePlugin(objects: ServiceObject[], driver?: unknown) { + const rec = recordingLogger(); + const engine = new ObjectQL({ logger: rec.logger } as any); + if (driver) engine.registerDriver(driver as any); + for (const obj of objects) engine.registerObject(obj as any); + const plugin = new ObjectQLPlugin(); + (plugin as any).ql = engine; + return { rec, engine, plugin, ctx: { logger: rec.logger } as any }; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Seam 2 — syncRegisteredSchemas: PROPAGATE +// ─────────────────────────────────────────────────────────────────────────── + +describe('#9285 seam 2 — syncRegisteredSchemas propagates a registry it could not read', () => { + /** + * ⚠️ PRESERVED behaviour, not fixed behaviour — measured, and labelled as + * such because reverse verification proved it green in BOTH directions. + * + * Seam 2's swallow was the OPTIONAL-CHAIN half only: `?? []` never caught + * anything, because `?.` short-circuits on absence, not on a throw, and this + * seam had no `catch` (unlike seam 3). So a throwing registry propagated here + * before #9285 too. This test therefore pins nothing about the fix — its job + * is the opposite one, and a real one: it fails the day someone "hardens" + * this seam by wrapping the read in a `try/catch`, which is exactly how + * seam 3 acquired its second swallow. + */ + it('a THROWING registry already propagated, and must keep propagating (preserved, not fixed)', async () => { + const { plugin, engine, ctx, rec } = makePlugin([acct], recordingDriver().driver); + injectRegistryFailure(engine, 'throws'); + + await expect((plugin as any).syncRegisteredSchemas(ctx)).rejects.toBe(INJECTED); + // …and it did NOT quietly become "nothing to sync": no pass-complete line. + expect(rec.at('info').filter((r) => /complete/i.test(r.message))).toHaveLength(0); + }); + + it('fails on a registry that does not implement getAllObjects, naming the consequence', async () => { + const { plugin, engine, ctx } = makePlugin([acct], recordingDriver().driver); + injectRegistryFailure(engine, 'no-method'); + + // The `?.()` half of the swallow: a STRUCTURAL omission that never throws, + // so before #9285 this was byte-identical to an empty registry. + await expect((plugin as any).syncRegisteredSchemas(ctx)).rejects.toThrow( + /syncRegisteredSchemas: the object registry could not be read/, + ); + await expect((plugin as any).syncRegisteredSchemas(ctx)).rejects.toThrow( + /NOT "no objects are registered"/, + ); + }); + + it('fails when the engine exposes no registry at all', async () => { + const { plugin, engine, ctx } = makePlugin([acct], recordingDriver().driver); + injectRegistryFailure(engine, 'no-registry'); + + await expect((plugin as any).syncRegisteredSchemas(ctx)).rejects.toThrow( + /the engine exposes no `registry`/, + ); + }); + + it('positive control — a genuinely EMPTY registry still returns quietly', async () => { + const { plugin, ctx, rec } = makePlugin([], recordingDriver().driver); + + await expect((plugin as any).syncRegisteredSchemas(ctx)).resolves.toBeUndefined(); + expect(rec.at('error')).toHaveLength(0); + }); + + it('positive control — a readable registry still syncs every object it holds', async () => { + const d = recordingDriver(); + const { plugin, ctx, rec } = makePlugin([acct], d.driver); + + await (plugin as any).syncRegisteredSchemas(ctx); + + expect(d.synced).toHaveLength(1); + expect(rec.at('error')).toHaveLength(0); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// Seam 1 — reconcileFederatedBindings: REPORT at error, then degrade +// ─────────────────────────────────────────────────────────────────────────── + +describe('#9285 seam 1 — reconcileFederatedBindings reports the unreadable registry instead of reporting nothing', () => { + for (const how of ['throws', 'no-method', 'no-registry'] as Injection[]) { + it(`reports at error and does not throw (registry ${how})`, async () => { + const { plugin, engine, ctx, rec } = makePlugin([remoteAcct], recordingDriver().driver); + injectRegistryFailure(engine, how); + + // Exception-proof: this pass runs at `kernel:ready`, after every + // `start()`, and is deliberately not a boot gate. + await expect((plugin as any).reconcileFederatedBindings(ctx)).resolves.toBeUndefined(); + + const errors = rec.at('error'); + expect(errors).toHaveLength(1); + // CONSEQUENCE — named, not merely "something failed". + expect(errors[0].message).toMatch(/reconciliation did NOT run/); + expect(errors[0].message).toMatch(/stays registered and served/); + expect(errors[0].message).toMatch(/no such table/); + // The invention itself, called out so the reader cannot read this as + // "there were none". + expect(errors[0].message).toMatch(/NOT "no federated objects to bind"/); + // FIX. + expect(errors[0].message).toMatch(/restart \(or trigger a metadata reload\)/); + // `Logger.error` is `(message, error?, meta?)` — the cause rides slot 2. + expect(errors[0].args[0]).toBeInstanceOf(Error); + }); + } + + it('preserves the injected error as the reported cause', async () => { + const { plugin, engine, ctx, rec } = makePlugin([remoteAcct], recordingDriver().driver); + injectRegistryFailure(engine, 'throws'); + + await (plugin as any).reconcileFederatedBindings(ctx); + + expect(rec.at('error')[0].args[0]).toBe(INJECTED); + }); + + it('positive control — a readable registry with NO federated objects stays silent', async () => { + const { plugin, ctx, rec } = makePlugin([acct], recordingDriver().driver); + + await (plugin as any).reconcileFederatedBindings(ctx); + + expect(rec.at('error')).toHaveLength(0); + }); + + it('positive control — a readable registry still binds its federated objects', async () => { + const d = recordingDriver(); + const { plugin, ctx, rec } = makePlugin([remoteAcct], d.driver); + + await (plugin as any).reconcileFederatedBindings(ctx); + + expect(d.bound).toEqual(['remote_acct']); + expect(rec.at('error')).toHaveLength(0); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// Seam 3 — runGovernanceInventory: REPORT at warn, then skip (BOTH swallows) +// ─────────────────────────────────────────────────────────────────────────── + +/** The false accusation an invented empty object set produces. */ +const UNDECLARED_WARN = /registered handlers with NO declaration/; +const SKIPPED_WARN = /inventory SKIPPED/; + +function governancePlugin(how?: Injection) { + const built = makePlugin([acctWithAction], recordingDriver().driver); + built.engine.registerAction('acct', 'ping', async () => 1); + if (how) injectRegistryFailure(built.engine, how); + // `runGovernanceInventory` probes a metadata service; a kernel without one + // is the ordinary case and the seam under test is downstream of it. + built.ctx.getService = () => undefined; + return built; +} + +describe('#9285 seam 3 — runGovernanceInventory reports both swallows instead of auditing an invented empty set', () => { + for (const how of ['throws', 'no-method', 'no-registry'] as Injection[]) { + it(`warns that the audit was SKIPPED and accuses nobody (registry ${how})`, async () => { + const { plugin, ctx, rec } = governancePlugin(how); + + // Warn-only and exception-proof BY CONTRACT: a diagnostic must never be + // the reason a kernel fails to boot. + await expect((plugin as any).runGovernanceInventory(ctx)).resolves.toBeUndefined(); + + const warns = rec.at('warn'); + const skipped = warns.filter((r) => SKIPPED_WARN.test(r.message)); + expect(skipped).toHaveLength(1); + expect(skipped[0].message).toMatch(/could not be read/); + // The whole point of seam 3: an empty object set does not merely audit + // nothing, it accuses every object-declared action of being undeclared. + expect(warns.filter((r) => UNDECLARED_WARN.test(r.message))).toHaveLength(0); + // `warn` is `(message, meta?)` — the cause rides slot 1, not slot 2. + expect(skipped[0].args[0]).toMatchObject({ error: expect.any(String) }); + }); + } + + it('leaves the fingerprint untouched, so the NEXT successful run is not suppressed as unchanged', async () => { + const { plugin, engine, ctx, rec } = governancePlugin('throws'); + (plugin as any).lastGovernanceFingerprint = 'previous-run'; + + await (plugin as any).runGovernanceInventory(ctx); + expect((plugin as any).lastGovernanceFingerprint).toBe('previous-run'); + + // Recover the registry, drop the declaration: the audit must now speak. + delete (engine.registry as any).getAllObjects; + engine.registerObject(acct as any); + engine.registry.invalidate('acct'); + rec.records.length = 0; + + await (plugin as any).runGovernanceInventory(ctx); + expect(rec.at('warn').filter((r) => UNDECLARED_WARN.test(r.message))).toHaveLength(1); + }); + + it('positive control — a readable registry audits, and a DECLARED handler is not accused', async () => { + const { plugin, ctx, rec } = governancePlugin(); + + await (plugin as any).runGovernanceInventory(ctx); + + expect(rec.at('warn')).toHaveLength(0); + expect((plugin as any).lastGovernanceFingerprint).toBe(''); + }); + + it('positive control — a readable registry DOES accuse a genuinely undeclared handler', async () => { + const built = makePlugin([acct], recordingDriver().driver); + built.engine.registerAction('acct', 'ping', async () => 1); + built.ctx.getService = () => undefined; + + await (built.plugin as any).runGovernanceInventory(built.ctx); + + expect(built.rec.at('warn').filter((r) => UNDECLARED_WARN.test(r.message))).toHaveLength(1); + }); +}); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 74804ffdba..e71cac5e28 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -11,6 +11,7 @@ import { lifecycleSettingsManifest } from './lifecycle/lifecycle-settings.js'; import type { DanglingReferenceAuditOptions } from './integrity/dangling-reference-audit.js'; import { runActionGovernanceInventory } from './action-governance.js'; import type { IMetadataService } from '@objectstack/spec/contracts'; +import type { ServiceObject } from '@objectstack/spec/data'; export type { Plugin, PluginContext }; @@ -1093,6 +1094,61 @@ export class ObjectQLPlugin implements Plugin { * instead of all rows. */ + /** + * Read every registered object — or FAIL. The ONE registry read the three + * seams below share, and it exists so that none of them can answer a read + * that could not run with an invented *"the registry holds nothing"*. + * + * [#9285] All three used to spell the read + * `this.ql.registry?.getAllObjects?.() ?? []`, which folds three different + * facts into one value: + * + * 1. the registry answered, and holds no objects; + * 2. the engine exposes no `registry` at all; + * 3. the registry exposes no `getAllObjects` — a STRUCTURAL omission that + * never throws, so it is invisible precisely when it is wrong. + * + * Only (1) is truthfully "no objects". #8895 ruled this family + * *discriminate or propagate*; #9002 and #9154 applied it to the two + * delete-cascade seams and the roll-up summary index. There is no benign + * failure class to discriminate here either, so (2) and (3) become a thrown + * error that names the consequence, and a registry that THROWS propagates + * its own error verbatim — this helper adds no `catch` of its own. + * + * What each caller DOES with that failure is the caller's decision, taken at + * the seam and justified there: {@link syncRegisteredSchemas} lets it + * propagate (a boot that could not read the registry must not report a clean + * start against a store whose DDL never ran), while the two diagnostic + * passes report it once, loudly, and degrade — an audit that did not run + * must never be spelled the same way as an audit that found nothing. + * + * ⚠️ A STRUCTURAL close, not a live defect — re-derived on this tree: + * `SchemaRegistry.getAllObjects()` is a walk over in-memory `Map`s calling + * `resolveObject()`, which returns `undefined` on every failure branch it + * models and never throws, and `ObjectQL.registry` is a getter over a + * field-initialized `SchemaRegistry`, so for a real engine neither optional + * link can short-circuit. The only reach is a duck-typed `ql` — which is + * exactly the incomplete test double #9154 measured. + * + * @param seam - the calling method, named in the thrown message. + */ + private readRegisteredObjects(seam: string): ServiceObject[] { + const registry: any = (this.ql as any)?.registry; + const read: unknown = registry?.getAllObjects; + if (registry == null || typeof read !== 'function') { + throw new Error( + `[ObjectQLPlugin] ${seam}: the object registry could not be read — ` + + (registry == null + ? 'the engine exposes no `registry`.' + : '`registry.getAllObjects` is not a function.') + + ' This is NOT "no objects are registered": that answer was never obtained, and the two have opposite ' + + 'consequences (nothing to sync vs. nothing synced). Give the engine a registry that implements ' + + '`getAllObjects()` — a duck-typed engine or an incomplete test double is the only way to reach this line.', + ); + } + return (read as () => ServiceObject[]).call(registry); + } + /** * Bind every declared FEDERATED (external) object to its remote table — * once, at `kernel:ready`, when the boot has finished moving (#7737). @@ -1141,7 +1197,37 @@ export class ObjectQLPlugin implements Plugin { private async reconcileFederatedBindings(ctx: PluginContext): Promise { if (!this.ql) return; - const allObjects = this.ql.registry?.getAllObjects?.() ?? []; + // [#9285] The read no longer invents an empty registry. This pass exists + // to NAME what it could not bind — "a boot with nothing to report says + // nothing" (above) — so a read that could not RUN must not be spelled as a + // boot with nothing to report; that is precisely the silence the pass was + // written to prevent. It is reported and the pass degrades rather than + // throwing: this is a post-hoc reconciliation, deliberately run after every + // `start()` has completed so that a late-connecting datasource is not a + // boot failure, and propagating here would turn a diagnostic into the hard + // stop it was written not to be. (By then {@link syncRegisteredSchemas} has + // already read the same registry successfully on every boot that does not + // set `skipSchemaSync`, so a failure at THIS line is a registry that became + // unreadable mid-boot, not the boot-wide condition seam 2 catches.) + // `error`, not `warn`, per the AGENTS.md degradation-log-level rule and for + // the same reason the report below it is: from the outside the deployment + // looks healthy while declared data is simply not reachable. + let allObjects: ServiceObject[]; + try { + allObjects = this.readRegisteredObjects('reconcileFederatedBindings'); + } catch (e: unknown) { + ctx.logger.error( + 'Federated (external) object reconciliation did NOT run — the object registry could not be read, so this boot ' + + 'never determined which declared external objects are bound to their remote tables, and never re-drove the ' + + 'bindings this pass exists to install. Any object left unbound stays registered and served: it keeps its REST ' + + 'routes and keeps rendering in the UI, while every read against it resolves to a table named after the OBJECT ' + + 'instead of the remote table it declares — so those reads fail with "no such table", or answer from the wrong ' + + 'table. This is NOT "no federated objects to bind": that answer was never obtained. Fix the registry error ' + + 'below and restart (or trigger a metadata reload) to re-run this binding.', + e instanceof Error ? e : new Error(String(e)), + ); + return; + } const federated = allObjects.filter((o: any) => o?.external != null); if (federated.length === 0) return; @@ -1211,7 +1297,17 @@ export class ObjectQLPlugin implements Plugin { private async syncRegisteredSchemas(ctx: PluginContext) { if (!this.ql) return; - const allObjects = this.ql.registry?.getAllObjects?.() ?? []; + // [#9285] The read PROPAGATES — no `?.`, no `?? []`. The next line turns + // "no objects" into an early return, i.e. NO registered object's schema is + // synced to any driver: no table created, no column added, silently, at + // boot, with the plugin reporting a clean start. "The registry holds + // nothing" and "the registry could not be read" have opposite consequences + // here and only the first is a truthful reason to skip, so a read that + // could not run fails the boot instead — more truthful than starting + // against a store whose DDL was never run. On the `metadata:reloaded` path + // the caller already catches this and reports it at `error` (#4632), so + // propagation there is a loud durability report, not a dead kernel. + const allObjects = this.readRegisteredObjects('syncRegisteredSchemas'); if (allObjects.length === 0) return; let synced = 0; @@ -1997,9 +2093,39 @@ export class ObjectQLPlugin implements Plugin { loadStandaloneActions = () => loadMany.call(meta, 'action'); } } catch { /* no metadata service — registry objects still audit */ } + // [#9285] BOTH swallows are gone. The old spelling — + // `(() => { try { return ql.registry?.getAllObjects?.() ?? []; } catch { return []; } })()` + // — carried two independent inventions on one expression (`?.()` for a + // registry that does not implement the method, `catch` for one that + // throws), and handed the result to an audit that then ran against an + // invented empty object set. That is worse than silence: with no objects + // every handler declared ON an object reconciles as an "undeclared handler + // … REFUSED at dispatch", so an unreadable registry produced a page of + // false accusations against a healthy deployment. This inventory is + // warn-only and exception-proof BY CONTRACT (above: a diagnostic must never + // be the reason a kernel fails to boot), so it does not propagate — it + // reports once and skips, leaving `lastGovernanceFingerprint` untouched so + // the next successful run reports in full rather than being suppressed as + // "unchanged". `warn`, not `error`, per the AGENTS.md degradation-log-level + // rule: an audit that did not run is a FUNCTIONAL degradation — nothing + // here claims to have been persisted. + let objects: ServiceObject[]; + try { + objects = this.readRegisteredObjects('runGovernanceInventory'); + } catch (e: unknown) { + ctx.logger.warn( + '[action-governance] inventory SKIPPED — the object registry could not be read, so this run audited nothing: ' + + 'registered handlers with no declaration (REFUSED at dispatch, ADR-0110 D3) and declared actions with no ' + + 'handler are neither found nor named. Deliberately NOT audited against an empty object set — that would ' + + 'report every object-declared action as an undeclared handler. The audit re-runs at the next boot or on the ' + + 'next `metadata:reloaded`; fix the registry error named here.', + { error: e instanceof Error ? e.message : String(e) }, + ); + return; + } this.lastGovernanceFingerprint = await runActionGovernanceInventory({ registered: ql.listRegisteredActions(), - objects: (() => { try { return ql.registry?.getAllObjects?.() ?? []; } catch { return []; } })(), + objects, loadStandaloneActions, logger: ctx.logger, lastFingerprint: this.lastGovernanceFingerprint,