diff --git a/.changeset/action-governance-registry-rung.md b/.changeset/action-governance-registry-rung.md new file mode 100644 index 0000000000..d1959798f8 --- /dev/null +++ b/.changeset/action-governance-registry-rung.md @@ -0,0 +1,24 @@ +--- +'@objectstack/objectql': patch +--- + +Startup `[action-governance]` resolves declarations through the same rungs the router does + +The boot inventory built its declaration set from object-embedded `actions[]` plus the +metadata service's `action` rows. `resolveRouteActionDeclaration` resolves through a third +source between those two — the engine registry's standalone `action` items, +`registry.getItem('action', name)`, accepted when the item owns the route. On the in-process +boot (`new AppPlugin(...)` then `kernel.bootstrap()`), where the metadata plane holds no +`action` rows at all, every object-less `defineAction` was therefore reported as a +"registered handler with NO declaration — REFUSED at dispatch (ADR-0110 D3) and there is no +opt-out" in the same boot in which the router resolved it at that rung and dispatched it. +Both remedies the message offered were wrong for that shape: the action was already declared +with `defineAction`, and dropping the registration would have broken a working endpoint under +a green `pnpm validate`. + +The registry rung is now injected into the audit by `ObjectQLPlugin` — the one caller holding +the engine, because objectql cannot import the router — and judged by the same ownership test +the router applies. The warning also stops asserting a dispatch outcome it never checked: it +names the three sources it read, says it did not dispatch, and points an author whose action +IS declared at the real bug instead of at deleting the registration. The other finding in the +block, `declared script actions with NO handler`, is unchanged in wording and in population. diff --git a/packages/objectql/src/action-governance.test.ts b/packages/objectql/src/action-governance.test.ts index 57a52ae9f2..6b0e9fd3d9 100644 --- a/packages/objectql/src/action-governance.test.ts +++ b/packages/objectql/src/action-governance.test.ts @@ -10,6 +10,18 @@ * fingerprint-suppressed across `metadata:reloaded` re-runs, and that a * failing declaration source degrades to a debug line instead of throwing — * a diagnostic must never be the reason a kernel fails to boot. + * + * The second describe block pins the router's registry rung. The measured + * defect: on the in-process boot (`new AppPlugin(...)` then + * `kernel.bootstrap()`), `meta.loadMany('action')` answers `[]` while + * `registry.getItem('action', name)` answers the declaration, so every + * object-LESS `defineAction` was named as a "registered handler with NO + * declaration ... REFUSED at dispatch" in the same boot in which the router + * resolved it at rung 2 and dispatched it. Pinned here: the two boots (the + * registry holds it, the plane does not), both call forms (object-bound and + * object-less), a positive control that must stay reported, the ownership + * test that keeps the rung from clearing a foreign declaration, and the + * second warning holding its exact wording while the first changes. */ import { describe, it, expect, vi } from 'vitest'; @@ -120,3 +132,169 @@ describe('runActionGovernanceInventory (ADR-0110 D5)', () => { ); }); }); + +describe('runActionGovernanceInventory — the router registry rung (#14123)', () => { + /** `registry.getItem('action', name)`, as the plugin injects it. */ + const registryOf = (items: Record) => (name: string) => items[name]; + + const applyAction = { name: 'duly_catalog_apply', type: 'script', locations: [] }; + + it('clears an object-LESS declaration the registry holds and the plane does not', async () => { + const logger = makeLogger(); + await runActionGovernanceInventory({ + registered: [{ objectName: 'global', actionName: 'duly_catalog_apply' }], + objects: [], // no object embeds it + loadStandaloneActions: async () => [], // in-process boot: the plane is empty + lookupRegistryAction: registryOf({ duly_catalog_apply: applyAction }), + logger, + }); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('clears an object-BOUND declaration the registry holds and the plane does not', async () => { + const logger = makeLogger(); + await runActionGovernanceInventory({ + registered: [{ objectName: 'todo_task', actionName: 'archive_task' }], + objects: [{ name: 'todo_task', actions: [] }], + loadStandaloneActions: async () => [], + lookupRegistryAction: registryOf({ + archive_task: { name: 'archive_task', objectName: 'todo_task', type: 'script' }, + }), + logger, + }); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('POSITIVE CONTROL — a handler no source declares is still named', async () => { + const logger = makeLogger(); + await runActionGovernanceInventory({ + registered: [ + { objectName: 'global', actionName: 'duly_catalog_apply' }, + { objectName: 'global', actionName: 'ghostProbe' }, + { objectName: 'todo_task', actionName: 'ghostBound' }, + ], + objects: [{ name: 'todo_task', actions: [] }], + loadStandaloneActions: async () => [], + lookupRegistryAction: registryOf({ duly_catalog_apply: applyAction }), + logger, + }); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/registered handlers with NO declaration/), + expect.objectContaining({ count: 2, handlers: ['global:ghostProbe', 'todo_task:ghostBound'] }), + ); + }); + + it('applies the router ownership test — a foreign object-bound item does not cover the route', async () => { + const logger = makeLogger(); + await runActionGovernanceInventory({ + registered: [{ objectName: 'todo_task', actionName: 'archive_task' }], + objects: [{ name: 'todo_task', actions: [] }], + lookupRegistryAction: registryOf({ + archive_task: { name: 'archive_task', objectName: 'crm_lead', type: 'script' }, + }), + logger, + }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/registered handlers with NO declaration/), + expect.objectContaining({ handlers: ['todo_task:archive_task'] }), + ); + }); + + it('stops asserting a dispatch outcome it did not check, and names the sources it did read', async () => { + const logger = makeLogger(); + await runActionGovernanceInventory({ + registered: [{ objectName: 'global', actionName: 'ghostProbe' }], + objects: [], + logger, + }); + + const [message] = logger.warn.mock.calls[0]; + expect(message).not.toMatch(/REFUSED at dispatch/); + expect(message).not.toMatch(/there is no opt-out/); + expect(message).not.toMatch(/drop the registration/); + expect(message).toMatch(/it did not dispatch/); + expect(message).toMatch(/object-embedded `actions\[\]`/); + expect(message).toMatch(/the engine registry standalone `action` items/); + expect(message).toMatch(/the metadata service `action` rows/); + }); + + it('leaves the OTHER warning byte-identical — a registry item is not folded into the declaration set', async () => { + const logger = makeLogger(); + await runActionGovernanceInventory({ + registered: [], + objects: todoObjects, + lookupRegistryAction: registryOf({ + // A registry-only script declaration with no handler anywhere. It must + // not join `unboundDeclarations`: the router never enumerates the + // registry, so neither does this audit. + orphan_action: { name: 'orphan_action', type: 'script', target: 'orphanHandler' }, + }), + logger, + }); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + '[action-governance] declared script actions with NO handler — a button wired to ' + + 'nothing (ADR-0078); add a `body`, or register a handler under the declared `target`', + { count: 1, actions: ['todo_task:complete_task'] }, + ); + }); + + it('keeps the handler when the registry lookup throws — and never throws itself', async () => { + const logger = makeLogger(); + await expect(runActionGovernanceInventory({ + registered: [{ objectName: 'global', actionName: 'duly_catalog_apply' }], + objects: [], + lookupRegistryAction: () => { throw new Error('registry unreadable'); }, + logger, + })).resolves.toBeDefined(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/registered handlers with NO declaration/), + expect.objectContaining({ handlers: ['global:duly_catalog_apply'] }), + ); + }); + + it('fingerprints the FILTERED set, so a rung-cleared boot reports and remembers nothing', async () => { + const logger = makeLogger(); + const fp = await runActionGovernanceInventory({ + registered: [{ objectName: 'global', actionName: 'duly_catalog_apply' }], + objects: [], + lookupRegistryAction: registryOf({ duly_catalog_apply: applyAction }), + logger, + }); + + expect(fp).toBe(''); + expect(logger.warn).not.toHaveBeenCalled(); + + // The declaration disappears on a later reload: the finding is new, so it reports. + await runActionGovernanceInventory({ + registered: [{ objectName: 'global', actionName: 'duly_catalog_apply' }], + objects: [], + lookupRegistryAction: registryOf({}), + logger, + lastFingerprint: fp, + }); + + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + + it('is unchanged when no rung is injected — two sources, and the finding stands', async () => { + const logger = makeLogger(); + await runActionGovernanceInventory({ + registered: [{ objectName: 'global', actionName: 'duly_catalog_apply' }], + objects: [], + logger, + }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/registered handlers with NO declaration/), + expect.objectContaining({ handlers: ['global:duly_catalog_apply'] }), + ); + }); +}); diff --git a/packages/objectql/src/action-governance.ts b/packages/objectql/src/action-governance.ts index 7489b9342b..29b6f13028 100644 --- a/packages/objectql/src/action-governance.ts +++ b/packages/objectql/src/action-governance.ts @@ -16,10 +16,35 @@ * * Dependency direction forces the same conclusion: runtime → objectql, never * the reverse, so shared logic that the engine plugin needs must live here. - * Runtime re-exports these under their old names — dispatch and the MCP - * bridge keep reading the SAME functions, which is the load-bearing property: - * the inventory can never disagree with the router about what a declaration - * can address. + * Runtime re-exports these under their old names, so dispatch and the MCP + * bridge keep reading the SAME functions. + * + * That sharing bought LESS than this docblock used to claim, and the claim is + * corrected here rather than merely repaired below. It read: "the inventory + * can never disagree with the router about what a declaration can address." + * True of ADDRESSING — which handler keys a declaration reaches, derived by + * {@link resolveActionHandlerKeys} / {@link actionHandlerObjectKeys} on both + * sides. Never true of EXISTENCE, which the two sides answered from different + * sources: `resolveRouteActionDeclaration` resolves a declaration in three + * rungs — the object's embedded `actions[]`, then the engine registry's + * standalone `action` items (`registry.getItem('action', name)`, accepted + * when the item owns the route), then the metadata service's `action` rows — + * while this inventory built its declaration set from the first and the last + * only. Measured consequence: on the in-process boot, where the metadata + * plane carries no `action` rows, an object-LESS `defineAction` living in the + * registry alone was reported as a "registered handler with NO declaration … + * REFUSED at dispatch" in the same boot in which the router resolved it at + * rung 2 and dispatched it. + * + * Both halves are shared now. The addressing vocabulary lives here, and so + * does the ownership test that decides whether a registry item covers a route + * ({@link standaloneActionOwnerKey}, in lockstep with the runtime's + * `standaloneActionObjectName` and `ObjectQLPlugin.actionObjectKey`). The + * registry rung itself arrives as the caller-injected `lookupRegistryAction`, + * because objectql cannot import the router — the one caller that holds `ql` + * hands the rung over. The invariant this file may claim, and no more: the + * inventory reports a handler as undeclared only when EVERY source the router + * resolves through answered nothing for it. */ /** @@ -43,6 +68,40 @@ export function isObjectLessActionKey(objectName: string | undefined | null): bo return !objectName || objectName === GLOBAL_ACTION_OBJECT_KEY || objectName === '*'; } +/** + * The engine object key a STANDALONE action declaration owns. + * + * Standalone `action` metadata declares `objectName` (spec `ActionSchema`); + * bundle collectors attach `object`; an object-less action owns the canonical + * `'global'` key. Three writers had this same three-line ladder — the + * runtime's `standaloneActionObjectName`, `ObjectQLPlugin.actionObjectKey`, + * and an inline copy inside {@link collectEngineActionDeclarations}. It is + * spelled once here because the router's rung-2 ownership test and this + * inventory now have to agree on it exactly; the other two stay in lockstep + * by their own docblocks (the runtime cannot import backwards, and the + * plugin's copy is a private method). + */ +export function standaloneActionOwnerKey(action: any): string { + if (typeof action?.objectName === 'string' && action.objectName.length > 0) return action.objectName; + if (typeof action?.object === 'string' && action.object.length > 0) return action.object; + return GLOBAL_ACTION_OBJECT_KEY; +} + +/** + * The router's rung-2 acceptance test: does this standalone declaration own + * the route a handler is registered on? + * + * Byte-for-byte the `ownsRoute` predicate inside + * `resolveRouteActionDeclaration` — `owner === objectName || + * isObjectLessActionKey(owner)`. Note the asymmetry, which is deliberate and + * must be mirrored rather than tidied: an object-LESS declaration owns ANY + * route, while an object-bound one owns only its own object. + */ +export function standaloneActionOwnsRoute(action: any, objectName: string): boolean { + const owner = standaloneActionOwnerKey(action); + return owner === objectName || isObjectLessActionKey(owner); +} + /** * The engine object keys to probe, in order, for a route's action handler. * @@ -93,9 +152,14 @@ export function resolveActionHandlerKeys(action: any, fallbackKey?: string): str * * Two findings: * - `undeclaredHandlers` — a registered key that reconciles to no - * declaration. Since D3 those are REFUSED at dispatch, so this list is the - * upgrade checklist: everything on it is an endpoint that stopped working - * and the exact `defineAction` that fixes it. + * declaration IN THE SET IT WAS GIVEN. Read the scope literally: this + * function is pure set reconciliation and knows nothing about the sources + * that set came from, so its answer is the upgrade checklist only once the + * caller has consulted every source the router resolves through. + * {@link runActionGovernanceInventory} is what does that, and it passes + * this list through the router's registry rung before reporting a word of + * it. A caller that skips that step is asserting a dispatch outcome from + * two of the router's three sources. * - `unboundDeclarations` — a declared `script` action with no `body` and no * handler under any candidate key: a button wired to nothing. */ @@ -180,10 +244,7 @@ export async function collectEngineActionDeclarations( } for (const action of standalone) { if (!action || typeof action.name !== 'string') continue; - const objectName = - (typeof action.objectName === 'string' && action.objectName) || - (typeof action.object === 'string' && action.object) || - GLOBAL_ACTION_OBJECT_KEY; + const objectName = standaloneActionOwnerKey(action); const key = `${objectName}:${action.name}`; if (seen.has(key)) continue; // object-embedded declaration wins seen.add(key); @@ -192,6 +253,50 @@ export async function collectEngineActionDeclarations( return out; } +/** + * The router's SECOND rung, applied to the handlers the declaration set did + * not cover: `registry.getItem('action', )`, + * accepted on the router's own ownership test. + * + * Why a by-NAME probe rather than folding the registry into the declaration + * set: the router never enumerates the registry, it asks it for one name, so + * mirroring it means asking for one name. That also keeps the other finding + * — `unboundDeclarations`, declared script actions with no handler — reading + * exactly the population it read before; whether a registry-only declaration + * with no handler should join it is a different question from this one, and + * folding would have answered it silently. + * + * A handler registered under key `K` on object `O` is dispatchable at + * `/actions/O/K` precisely when the router resolves a declaration for the + * name `K` that owns `O` (its `fallbackKey` then addresses `K` back). So this + * probe is not an approximation of dispatch — for the direct route it is the + * same question, asked of the same source. + * + * Conservative in exactly one direction, on purpose: a lookup that throws, or + * answers something that is not an object, leaves the handler ON the list. + * The audit can therefore over-report a broken registry; it cannot clear a + * handler on the strength of an answer it could not read. + */ +async function dropHandlersDeclaredInRegistry( + handlers: Array<{ objectName: string; actionName: string; package?: string }>, + lookupRegistryAction: ((actionName: string) => unknown) | undefined, +): Promise> { + if (!lookupRegistryAction || handlers.length === 0) return handlers; + const kept: Array<{ objectName: string; actionName: string; package?: string }> = []; + for (const handler of handlers) { + let item: unknown; + try { + item = await lookupRegistryAction(handler.actionName); + } catch { + kept.push(handler); // registry could not answer — see above + continue; + } + if (item && typeof item === 'object' && standaloneActionOwnsRoute(item, handler.objectName)) continue; + kept.push(handler); + } + return kept; +} + /** Stable fingerprint of a finding set, for duplicate-report suppression. */ function fingerprint(r: ReturnType): string { return [ @@ -213,20 +318,38 @@ export async function runActionGovernanceInventory(args: { registered: Array<{ objectName: string; actionName: string; package?: string }>; objects: any[]; loadStandaloneActions?: () => Promise; + /** + * The router's rung 2, injected: `registry.getItem('action', name)` from + * the caller that holds the engine. Omitting it is not a neutral default + * — the inventory then reads two of the three sources the router reads, + * which is the state that reported a live object-less action as refused + * at dispatch. Callers with an engine in hand pass it. + */ + lookupRegistryAction?: (actionName: string) => unknown; logger: GovernanceLogger; /** Fingerprint returned by the previous run — identical findings are not re-logged. */ lastFingerprint?: string; }): Promise { try { const declarations = await collectEngineActionDeclarations(args.objects, args.loadStandaloneActions); - const findings = reconcileActionRegistrations(args.registered, declarations); + const reconciled = reconcileActionRegistrations(args.registered, declarations); + const findings = { + ...reconciled, + undeclaredHandlers: await dropHandlersDeclaredInRegistry( + reconciled.undeclaredHandlers, args.lookupRegistryAction), + }; const fp = fingerprint(findings); if (fp === (args.lastFingerprint ?? '')) return fp; if (findings.undeclaredHandlers.length > 0) { args.logger.warn( - '[action-governance] registered handlers with NO declaration — these are REFUSED ' + - 'at dispatch (ADR-0110 D3) and there is no opt-out; declare each one with ' + - '`defineAction`, or drop the registration if nothing should invoke it over HTTP', + '[action-governance] registered handlers with NO declaration in any source the ' + + 'router resolves through (object-embedded `actions[]`, the engine registry ' + + 'standalone `action` items, the metadata service `action` rows). ADR-0110 D3 ' + + 'refuses a handler whose declaration the router cannot resolve, so each of these ' + + 'is expected to answer 404 — expected, not measured: this audit read the sources, ' + + 'it did not dispatch. Declare each one with `defineAction`; if you believe it IS ' + + 'declared, then its declaration is not reaching this engine, and that is the bug ' + + 'to report rather than dropping a registration that may still be serving traffic', { count: findings.undeclaredHandlers.length, handlers: findings.undeclaredHandlers.map((h) => `${h.objectName}:${h.actionName}`), diff --git a/packages/objectql/src/plugin-action-governance-rung.test.ts b/packages/objectql/src/plugin-action-governance-rung.test.ts new file mode 100644 index 0000000000..9de8a8d41a --- /dev/null +++ b/packages/objectql/src/plugin-action-governance-rung.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14123] The wiring pin: `ObjectQLPlugin.runGovernanceInventory` hands the + * router's registry rung to the audit. + * + * `action-governance.test.ts` pins what the inventory DOES with the rung. + * Nothing there can fail if the plugin stops passing it — the audit accepts + * the argument as optional, and its absence is silent by construction (two of + * the router's three sources, and a false accusation against a healthy + * deployment). So the pin that matters lives here, on the real caller: the + * one call site with `ql` in hand is the only place the rung can join the + * audit, and this drives that method rather than a copy of it. + * + * The shape under test is the card's own probe, reproduced against the real + * plugin: the registry answers `getItem('action', name)` while the metadata + * plane holds no `action` rows at all (the in-process boot). Before the fix + * that boot printed "registered handlers with NO declaration ... REFUSED at + * dispatch" for exactly the actions the router was dispatching. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectQLPlugin } from './plugin.js'; +import type { ObjectQL } from './engine.js'; + +type AnyRecord = Record; + +/** A PluginContext with no services — the in-process boot's empty plane. */ +function makeCtx() { + return { + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getService: vi.fn((name: string) => { + throw new Error(`service '${name}' not registered`); + }), + hook: vi.fn(), + } as AnyRecord; +} + +function makeQl(opts: { + registered: Array<{ objectName: string; actionName: string }>; + objects?: AnyRecord[]; + registryActions?: Record; +}) { + return { + listRegisteredActions: vi.fn(() => opts.registered), + registry: { + getAllObjects: vi.fn(() => opts.objects ?? []), + getItem: vi.fn((type: string, name: string) => + (type === 'action' ? opts.registryActions?.[name] : undefined)), + }, + } as AnyRecord; +} + +const makePlugin = (ql: AnyRecord) => new ObjectQLPlugin({ ql: ql as unknown as ObjectQL }); + +describe('ObjectQLPlugin.runGovernanceInventory — the injected registry rung (#14123)', () => { + it('probes the registry for a handler the objects and the plane do not declare', async () => { + const ctx = makeCtx(); + const ql = makeQl({ + registered: [{ objectName: 'global', actionName: 'duly_catalog_apply' }], + registryActions: { + duly_catalog_apply: { name: 'duly_catalog_apply', type: 'script', locations: [] }, + }, + }); + + await (makePlugin(ql) as any).runGovernanceInventory(ctx); + + expect(ql.registry.getItem).toHaveBeenCalledWith('action', 'duly_catalog_apply'); + expect(ctx.logger.warn).not.toHaveBeenCalled(); + }); + + it('clears an object-BOUND registry declaration through the same call site', async () => { + const ctx = makeCtx(); + const ql = makeQl({ + registered: [{ objectName: 'todo_task', actionName: 'archive_task' }], + objects: [{ name: 'todo_task', actions: [] }], + registryActions: { + archive_task: { name: 'archive_task', objectName: 'todo_task', type: 'script' }, + }, + }); + + await (makePlugin(ql) as any).runGovernanceInventory(ctx); + + expect(ctx.logger.warn).not.toHaveBeenCalled(); + }); + + it('POSITIVE CONTROL — a handler the registry does not know is still reported', async () => { + const ctx = makeCtx(); + const ql = makeQl({ + registered: [{ objectName: 'global', actionName: 'ghostProbe' }], + registryActions: {}, + }); + + await (makePlugin(ql) as any).runGovernanceInventory(ctx); + + expect(ql.registry.getItem).toHaveBeenCalledWith('action', 'ghostProbe'); + expect(ctx.logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/registered handlers with NO declaration/), + expect.objectContaining({ count: 1, handlers: ['global:ghostProbe'] }), + ); + }); + + it('does not fail the boot when the registry lookup throws', async () => { + const ctx = makeCtx(); + const ql = makeQl({ registered: [{ objectName: 'global', actionName: 'duly_catalog_apply' }] }); + ql.registry.getItem = vi.fn(() => { throw new Error('registry unreadable'); }); + + await expect((makePlugin(ql) as any).runGovernanceInventory(ctx)).resolves.toBeUndefined(); + + expect(ctx.logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/registered handlers with NO declaration/), + expect.objectContaining({ handlers: ['global:duly_catalog_apply'] }), + ); + }); + + it('survives an engine whose registry has no `getItem` at all', async () => { + const ctx = makeCtx(); + const ql = makeQl({ registered: [{ objectName: 'global', actionName: 'ghostProbe' }] }); + delete ql.registry.getItem; + + await expect((makePlugin(ql) as any).runGovernanceInventory(ctx)).resolves.toBeUndefined(); + + expect(ctx.logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/registered handlers with NO declaration/), + expect.objectContaining({ handlers: ['global:ghostProbe'] }), + ); + }); +}); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 72614be0b2..32694183ff 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -2476,10 +2476,13 @@ export class ObjectQLPlugin implements Plugin { * the registry is final for the boot) and again on `metadata:reloaded`. * * This is the checklist that makes D3's hard refusal a migration step - * instead of a mystery: every handler listed here answers 404 at dispatch, - * and the message says which `defineAction` fixes it. It lives on the - * ENGINE plugin deliberately — AppPlugin hosted it first and is registered - * conditionally, so the platform's own `os dev` path never printed it. + * instead of a mystery — but only while it reads what the router reads, so + * every source `resolveRouteActionDeclaration` resolves through is wired in + * below, the registry rung included. A handler listed here is one no source + * declares; the message says so in those terms and stops short of claiming + * a dispatch this audit never performed. It lives on the ENGINE plugin + * deliberately — AppPlugin hosted it first and is registered conditionally, + * so the platform's own `os dev` path never printed it. * * Warn-only, exception-proof (the runner swallows its own failures): a * diagnostic must never be the reason a kernel fails to boot. @@ -2529,6 +2532,18 @@ export class ObjectQLPlugin implements Plugin { registered: ql.listRegisteredActions(), objects, loadStandaloneActions, + // The router's SECOND rung, handed over from here because objectql + // cannot import the router (runtime -> objectql, never the reverse). + // `resolveRouteActionDeclaration` resolves a declaration through + // object-embedded `actions[]` -> THIS lookup -> the metadata plane, and + // the inventory had the first and the last: every object-less + // `defineAction` that lives in the registry alone (the in-process boot, + // where the plane holds no `action` rows) was reported as a handler + // with no declaration while the router was dispatching it. This call + // site is the only place with `ql` in hand, so it is where the rung + // joins the audit; the ownership test that judges the answer lives with + // the rest of the addressing vocabulary in `action-governance.ts`. + lookupRegistryAction: (actionName: string) => ql.registry?.getItem?.('action', actionName), logger: ctx.logger, lastFingerprint: this.lastGovernanceFingerprint, });