diff --git a/.changeset/action-governance-engine-owned.md b/.changeset/action-governance-engine-owned.md new file mode 100644 index 0000000000..9bdb58e833 --- /dev/null +++ b/.changeset/action-governance-engine-owned.md @@ -0,0 +1,32 @@ +--- +'@objectstack/objectql': minor +'@objectstack/runtime': patch +--- + +**[ADR-0110 D5] The action-governance inventory moves to the engine plugin — +AppPlugin never ran it on the platform's own dev path.** + +Dogfooding the inventory with a positive control (an injected undeclared +handler) showed the `kernel:ready` hook it hung on never fired under `os dev`: +AppPlugin is registered conditionally (`serve.ts` skips it when the host wraps +itself; the dev fast path loads apps without it), so the checklist that +justifies D3's no-opt-out refusal was never printed where an upgrade most +needs it. + +- The addressing vocabulary (`GLOBAL_ACTION_OBJECT_KEY`, + `actionHandlerObjectKeys`, `isObjectLessActionKey`, + `resolveActionHandlerKeys`) and the reconciliation move into + `@objectstack/objectql` — the engine owns the map they describe, and the + dependency direction (runtime → objectql) permits no other home. + `@objectstack/runtime` re-exports them unchanged, so dispatch, the MCP + bridge and existing importers keep reading ONE implementation. +- `ObjectQLPlugin` now runs the inventory in its existing `kernel:ready` + handler — after `resyncAuthoredActions`, so the audited registry is final — + and again on `metadata:reloaded`, fingerprint-suppressed so a reload that + changed nothing action-related logs nothing. A Studio edit that orphans or + binds a handler updates the report live; the old boot-only snapshot went + stale on the first edit. +- Verified end-to-end with a programmatic kernel: the injected orphan is + named, a clean registry is silent. The `os dev` / `os serve` consoles still + swallow ALL plugin boot logs (pre-existing, tracked separately) — on those + surfaces the inventory becomes visible once that sink is fixed. diff --git a/examples/app-todo/src/actions/register-handlers.ts b/examples/app-todo/src/actions/register-handlers.ts index 3f36062f45..299c1c78f7 100644 --- a/examples/app-todo/src/actions/register-handlers.ts +++ b/examples/app-todo/src/actions/register-handlers.ts @@ -86,11 +86,12 @@ export function registerTaskActionHandlers(engine: { engine.registerAction('todo_task', 'deleteCompletedTasks', deleteCompletedTasks); engine.registerAction('todo_task', 'exportTasksToCSV', exportTasksToCSV); - // ─── Modal-type actions (server-side form submission handlers) ───── - // These process the params collected by the modal UI before the - // engine updates the record. The modal target (e.g. 'defer_task_modal') - // tells the UI which modal page to open; the handler below processes - // the submitted form data. + // ─── Param-collecting script actions ─────────────────────────────── + // `defer_task` / `set_reminder` declare `params`, so the runner collects + // them in a dialog and these handlers run with the values. They were + // `type: 'modal'` until #3959 — a modal action has no server dispatch, so + // the targets named modal pages that did not exist and neither handler had + // ever executed. They are `type: 'script'` targeting these keys now. engine.registerAction('todo_task', 'deferTask', deferTask); engine.registerAction('todo_task', 'setReminder', setReminder); } diff --git a/packages/objectql/src/action-governance.test.ts b/packages/objectql/src/action-governance.test.ts new file mode 100644 index 0000000000..57a52ae9f2 --- /dev/null +++ b/packages/objectql/src/action-governance.test.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0110 D5] The engine-owned governance inventory. + * + * The reconciliation itself is pinned by @objectstack/runtime's + * action-reconciliation tests (through the back-compat wrapper). These pin + * the REPORTING layer that moved here with it: that the inventory names the + * orphans, that a clean registry stays silent, that duplicate findings are + * 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. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { runActionGovernanceInventory } from './action-governance.js'; + +const makeLogger = () => ({ warn: vi.fn(), debug: vi.fn() }); + +const todoObjects = [{ + name: 'todo_task', + actions: [{ name: 'complete_task', type: 'script', target: 'completeTask' }], +}]; + +describe('runActionGovernanceInventory (ADR-0110 D5)', () => { + it('names a registered handler that no declaration addresses', async () => { + const logger = makeLogger(); + await runActionGovernanceInventory({ + registered: [ + { objectName: 'todo_task', actionName: 'completeTask' }, + { objectName: 'todo_task', actionName: 'ghostProbe' }, + ], + objects: todoObjects, + logger, + }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/registered handlers with NO declaration/), + expect.objectContaining({ count: 1, handlers: ['todo_task:ghostProbe'] }), + ); + }); + + it('names a declared script action bound to no handler', async () => { + const logger = makeLogger(); + await runActionGovernanceInventory({ + registered: [], + objects: todoObjects, + logger, + }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/declared script actions with NO handler/), + expect.objectContaining({ actions: ['todo_task:complete_task'] }), + ); + }); + + it('stays silent when both sides reconcile', async () => { + const logger = makeLogger(); + await runActionGovernanceInventory({ + registered: [{ objectName: 'todo_task', actionName: 'completeTask' }], + objects: todoObjects, + logger, + }); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('folds standalone `action` items in, embedded declaration winning', async () => { + const logger = makeLogger(); + await runActionGovernanceInventory({ + registered: [ + { objectName: 'todo_task', actionName: 'completeTask' }, + { objectName: 'global', actionName: 'logCall' }, + ], + objects: todoObjects, + loadStandaloneActions: async () => [ + { name: 'log_call', type: 'script', target: 'logCall' }, // object-less + ], + logger, + }); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('suppresses a byte-identical repeat via the fingerprint', async () => { + const logger = makeLogger(); + const args = { + registered: [{ objectName: 'todo_task', actionName: 'ghostProbe' }], + objects: [] as any[], + logger, + }; + const fp = await runActionGovernanceInventory(args); + expect(logger.warn).toHaveBeenCalledTimes(1); + + // metadata:reloaded with nothing action-related changed → no repeat. + await runActionGovernanceInventory({ ...args, lastFingerprint: fp }); + expect(logger.warn).toHaveBeenCalledTimes(1); + + // A CHANGED finding set logs again. + await runActionGovernanceInventory({ + ...args, + registered: [...args.registered, { objectName: 'todo_task', actionName: 'ghostProbe2' }], + lastFingerprint: fp, + }); + expect(logger.warn).toHaveBeenCalledTimes(2); + }); + + it('degrades to debug when the declaration source throws — never throws itself', async () => { + const logger = makeLogger(); + await expect(runActionGovernanceInventory({ + registered: [{ objectName: 'todo_task', actionName: 'x' }], + // A poisoned objects array: property access explodes. + objects: [new Proxy({}, { get() { throw new Error('boom'); } })], + logger, + })).resolves.toBeDefined(); + + expect(logger.debug).toHaveBeenCalledWith( + expect.stringMatching(/inventory skipped/), + expect.objectContaining({ error: 'boom' }), + ); + }); +}); diff --git a/packages/objectql/src/action-governance.ts b/packages/objectql/src/action-governance.ts new file mode 100644 index 0000000000..7489b9342b --- /dev/null +++ b/packages/objectql/src/action-governance.ts @@ -0,0 +1,253 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0110] Action governance — the addressing vocabulary and the + * declaration↔executable reconciliation, owned by the ENGINE package. + * + * These lived in `@objectstack/runtime`'s action-execution module first, and + * the D5 boot inventory hung off `AppPlugin`. That placement failed in the + * field: `AppPlugin` is registered CONDITIONALLY (`serve.ts` skips it when the + * host already wraps itself, and the `os dev` fast path loads apps without it + * at all), so on the platform's own dev loop the inventory never ran — the + * one surface where an upgrade-blocking 404 most needs its checklist printed. + * The registry being audited is `ObjectQL`'s own `actions` map, so the audit + * belongs to the plugin that owns the map and is unconditionally present + * wherever actions can execute. + * + * 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. + */ + +/** + * The engine object key an object-LESS ("global") action registers under. + * + * Canonical since #3913, and it is `'global'` because that is what the two + * writers have always written: `AppPlugin` (`action.object || 'global'`) and + * `ObjectQLPlugin.actionObjectKey`. `engine.executeAction` is an exact-string + * `Map` lookup with no wildcard semantics, so the READERS have to probe the + * same literal — before this, the REST route and the MCP bridge both rotated + * to `'*'`, which nothing ever registers, and every global action came back as + * `Action '' on object '*' not found`. + */ +export const GLOBAL_ACTION_OBJECT_KEY = 'global'; + +/** + * True when the routed "object" is the object-less placeholder rather than a + * real object — the canonical `'global'`, the legacy `'*'`, or nothing at all. + */ +export function isObjectLessActionKey(objectName: string | undefined | null): boolean { + return !objectName || objectName === GLOBAL_ACTION_OBJECT_KEY || objectName === '*'; +} + +/** + * The engine object keys to probe, in order, for a route's action handler. + * + * The routed object first, then the canonical object-less key (#3913), then + * the legacy `'*'` — kept last so a handler that user code registered directly + * against the wildcard still resolves. Deduped, so a request routed AT + * `/actions/global/:action` probes `'global'` exactly once. + */ +export function actionHandlerObjectKeys(objectName: string): string[] { + return [objectName, GLOBAL_ACTION_OBJECT_KEY, '*'].filter( + (k, i, all) => all.indexOf(k) === i, + ); +} + +/** + * [ADR-0110 D2] Handler-key candidates for an action, most-specific first — + * the *addressing* half of "resolve, then address". + * + * A registration key is NOT an action's identity. `AppPlugin` auto-registers + * **body** actions under `name`, while user code registers a **target-bound** + * script action under `target` (`engine.registerAction('todo_task', + * 'completeTask', …)`). Identity is always the declarative `name` (D1); which + * key the handler happens to live under is derived HERE, from the already- + * resolved declaration, so no caller ever has to know it. + * + * `fallbackKey` (the routed URL segment) is the last candidate and exists for + * the UNDECLARED case, where there is no declaration to derive anything from. + * It is deduped away whenever the declaration already yields it, so it never + * widens what a declared action can reach. + */ +export function resolveActionHandlerKeys(action: any, fallbackKey?: string): string[] { + const primary = action ? (action.body ? action.name : (action.target || action.name)) : undefined; + return [primary, action?.target, action?.name, fallbackKey].filter( + (k: unknown, i: number, a: unknown[]): k is string => + typeof k === 'string' && k.length > 0 && a.indexOf(k) === i, + ); +} + +/** + * [ADR-0110 D5] Reconcile the two halves of the declaration↔executable + * bijection and report the orphans on both sides. + * + * ADR-0078 outlaws a declaration nothing executes (silently *inert*); D3 + * outlaws an executable nothing declares (silently *ungoverned*). Together + * they are one invariant — everything declared runs, everything that runs is + * declared — and this is the mechanism that makes a violation visible at boot + * instead of when a caller happens to hit the route. + * + * 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. + * - `unboundDeclarations` — a declared `script` action with no `body` and no + * handler under any candidate key: a button wired to nothing. + */ +export function reconcileActionRegistrations( + registered: Array<{ objectName: string; actionName: string; package?: string }>, + declarations: Array<{ action: any; objectName: string }>, +): { + undeclaredHandlers: Array<{ objectName: string; actionName: string; package?: string }>; + unboundDeclarations: Array<{ objectName: string; actionName: string }>; +} { + // Every key any declaration can address, per owning object key. + const addressable = new Map>(); + const addKey = (objectKey: string, handlerKey: string) => { + let set = addressable.get(objectKey); + if (!set) addressable.set(objectKey, (set = new Set())); + set.add(handlerKey); + }; + for (const { action, objectName } of declarations) { + for (const key of resolveActionHandlerKeys(action)) addKey(objectName, key); + } + + const covers = (objectName: string, actionName: string): boolean => { + if (addressable.get(objectName)?.has(actionName)) return true; + // A handler registered under an object-less key is addressable by any + // object-less declaration, mirroring `actionHandlerObjectKeys`. + if (!isObjectLessActionKey(objectName)) return false; + for (const [objectKey, keys] of addressable) { + if (isObjectLessActionKey(objectKey) && keys.has(actionName)) return true; + } + return false; + }; + + const undeclaredHandlers = registered.filter((r) => !covers(r.objectName, r.actionName)); + + const registeredKeys = new Set(registered.map((r) => `${r.objectName}:${r.actionName}`)); + const unboundDeclarations: Array<{ objectName: string; actionName: string }> = []; + for (const { action, objectName } of declarations) { + if ((action?.type ?? 'script') !== 'script') continue; // only script needs a handler + if (action?.body) continue; // its handler is synthesized + const bound = resolveActionHandlerKeys(action).some((key) => + actionHandlerObjectKeys(objectName).some((obj) => registeredKeys.has(`${obj}:${key}`))); + if (!bound) unboundDeclarations.push({ objectName, actionName: action?.name }); + } + + return { undeclaredHandlers, unboundDeclarations }; +} + +/** Minimal logger surface the inventory needs — matches PluginContext.logger. */ +export interface GovernanceLogger { + warn(message: string, meta?: Record): void; + debug?(message: string, meta?: Record): void; +} + +/** + * Collect every action declaration this engine can dispatch against, as + * `{ action, objectName }` rows: each registry object's embedded `actions[]` + * (the same schemas `getSchema` serves to the router), then standalone + * `action` items from the metadata service — deduped by `:` + * with the object-embedded copy winning, mirroring the execution layer's + * artifact-wins rule. + */ +export async function collectEngineActionDeclarations( + objects: any[], + loadStandaloneActions: (() => Promise) | undefined, +): Promise> { + const out: Array<{ action: any; objectName: string }> = []; + const seen = new Set(); + for (const obj of objects ?? []) { + const objectName: string | undefined = obj?.name; + if (!objectName) continue; + for (const action of Array.isArray(obj?.actions) ? obj.actions : []) { + if (!action || typeof action.name !== 'string') continue; + seen.add(`${objectName}:${action.name}`); + out.push({ action, objectName }); + } + } + let standalone: any[] = []; + try { + standalone = (await loadStandaloneActions?.()) ?? []; + } catch { + standalone = []; // no standalone-item source on this kernel + } + 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 key = `${objectName}:${action.name}`; + if (seen.has(key)) continue; // object-embedded declaration wins + seen.add(key); + out.push({ action, objectName }); + } + return out; +} + +/** Stable fingerprint of a finding set, for duplicate-report suppression. */ +function fingerprint(r: ReturnType): string { + return [ + ...r.undeclaredHandlers.map((h) => `u:${h.objectName}:${h.actionName}`).sort(), + ...r.unboundDeclarations.map((d) => `d:${d.objectName}:${d.actionName}`).sort(), + ].join('|'); +} + +/** + * [ADR-0110 D5] Run the governance inventory and report findings. + * + * Warn-only and exception-proof: a DIAGNOSTIC must never be the reason a + * kernel fails to boot or a metadata reload fails. Returns the fingerprint of + * what was reported so callers can suppress byte-identical repeats + * (`metadata:reloaded` re-runs this; a re-sync that changed nothing should + * not repeat the same warning). + */ +export async function runActionGovernanceInventory(args: { + registered: Array<{ objectName: string; actionName: string; package?: string }>; + objects: any[]; + loadStandaloneActions?: () => Promise; + 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 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', + { + count: findings.undeclaredHandlers.length, + handlers: findings.undeclaredHandlers.map((h) => `${h.objectName}:${h.actionName}`), + }, + ); + } + if (findings.unboundDeclarations.length > 0) { + args.logger.warn( + '[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: findings.unboundDeclarations.length, + actions: findings.unboundDeclarations.map((d) => `${d.objectName}:${d.actionName}`), + }, + ); + } + return fp; + } catch (err: any) { + args.logger.debug?.('[action-governance] inventory skipped', { + error: err?.message ?? String(err), + }); + return args.lastFingerprint ?? ''; + } +} diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 0ce2e983e0..f3cac898e4 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -1,5 +1,20 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// [ADR-0110] Action governance — addressing vocabulary + the D5 inventory. +// The engine owns the action-handler map, so the audit of that map and the +// key derivation dispatch uses both live here; @objectstack/runtime +// re-exports them so both packages read ONE implementation. +export { + GLOBAL_ACTION_OBJECT_KEY, + isObjectLessActionKey, + actionHandlerObjectKeys, + resolveActionHandlerKeys, + reconcileActionRegistrations, + collectEngineActionDeclarations, + runActionGovernanceInventory, +} from './action-governance.js'; +export type { GovernanceLogger } from './action-governance.js'; + // Export Registry export { SchemaRegistry, diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 04c2169cac..e39a4ca921 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -6,6 +6,7 @@ import { Plugin, PluginContext } from '@objectstack/core'; import { StorageNameMapping } from '@objectstack/spec/system'; import { LifecycleService } from './lifecycle/lifecycle-service.js'; import { lifecycleSettingsManifest } from './lifecycle/lifecycle-settings.js'; +import { runActionGovernanceInventory } from './action-governance.js'; export type { Plugin, PluginContext }; @@ -334,6 +335,13 @@ export class ObjectQLPlugin implements Plugin { ctx.hook('kernel:ready', async () => { await this.resyncAuthoredHooks(ctx); await this.resyncAuthoredActions(ctx); + // [ADR-0110 D5] Governance inventory — AFTER the authored-action + // re-sync, so the registry it audits is final for this boot. It lived + // in AppPlugin first, which is registered conditionally; on the `os + // dev` path it never ran, so the checklist that justifies D3's hard + // refusal was never printed where an upgrade most needs it. The + // engine owns the map being audited; the engine plugin reports on it. + await this.runGovernanceInventory(ctx); // ADR-0057 P4: surface the lifecycle governance namespace in Settings // (overrides / quotas / growth alerts) when a SettingsService exists. try { @@ -376,6 +384,12 @@ export class ObjectQLPlugin implements Plugin { }); await this.reloadSchemaSync; } + // [ADR-0110 D5] Re-run the inventory after a live metadata reload — + // a Studio edit can orphan a handler (declaration deleted) or bind + // one (declaration added), and a boot-only snapshot goes stale the + // moment either happens. Fingerprint-suppressed: a reload that + // changed nothing action-related logs nothing. + await this.runGovernanceInventory(ctx); }); // Discover features from Kernel Services @@ -1458,6 +1472,47 @@ export class ObjectQLPlugin implements Plugin { */ private authoredActionResyncChain: Promise = Promise.resolve(); + /** + * [ADR-0110 D5] Fingerprint of the last governance report, so a + * `metadata:reloaded` that changed nothing action-related does not repeat + * the same warning verbatim. + */ + private lastGovernanceFingerprint = ''; + + /** + * [ADR-0110 D5] Audit the engine's action-handler registry against the + * declarations it can dispatch for, and warn about the orphans on both + * sides. Runs at `kernel:ready` (after {@link resyncAuthoredActions}, so + * 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. + * + * Warn-only, exception-proof (the runner swallows its own failures): a + * diagnostic must never be the reason a kernel fails to boot. + */ + private async runGovernanceInventory(ctx: PluginContext): Promise { + const ql: any = this.ql; + if (!ql || typeof ql.listRegisteredActions !== 'function') return; + let loadStandaloneActions: (() => Promise) | undefined; + try { + const meta: any = ctx.getService('metadata'); + if (meta && typeof meta.loadMany === 'function') { + loadStandaloneActions = () => meta.loadMany('action'); + } + } catch { /* no metadata service — registry objects still audit */ } + this.lastGovernanceFingerprint = await runActionGovernanceInventory({ + registered: ql.listRegisteredActions(), + objects: (() => { try { return ql.registry?.getAllObjects?.() ?? []; } catch { return []; } })(), + loadStandaloneActions, + logger: ctx.logger, + lastFingerprint: this.lastGovernanceFingerprint, + }); + } + /** * (Re-)register runtime-authored actions on the engine (#2605 item 1 — * the action-path parallel of {@link resyncAuthoredHooks}). diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 7c27ea731a..0b614f0c11 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -18,6 +18,26 @@ import { validateActionParams, type ResolvedActionParam } from '@objectstack/spec/ui'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { checkApiExposure } from './api-exposure.js'; +import { + GLOBAL_ACTION_OBJECT_KEY, + actionHandlerObjectKeys, + isObjectLessActionKey, + reconcileActionRegistrations as reconcileActionRegistrationsPure, + resolveActionHandlerKeys, +} from '@objectstack/objectql'; + +// [ADR-0110] The addressing vocabulary and the D5 reconciliation moved to +// @objectstack/objectql (the engine owns the map they describe, and its +// plugin now runs the boot inventory — AppPlugin, the previous host, is +// registered conditionally and never ran it on the `os dev` path). Runtime +// re-exports them so dispatch, the MCP bridge and existing importers keep +// reading the ONE implementation. +export { + GLOBAL_ACTION_OBJECT_KEY, + actionHandlerObjectKeys, + isObjectLessActionKey, + resolveActionHandlerKeys, +}; /** A `sys_`-prefixed object is a system table — off-limits to external MCP agents. */ export function isSystemObjectName(name: string): boolean { @@ -908,71 +928,6 @@ export async function collectActionDeclarations(deps: ActionExecutionDeps, return out; } -/** - * [ADR-0110 D5] Reconcile the two halves of the declaration↔executable - * bijection and report the orphans on both sides. - * - * ADR-0078 outlaws a declaration nothing executes (silently *inert*); D3 - * outlaws an executable nothing declares (silently *ungoverned*). Together - * they are one invariant — everything declared runs, everything that runs is - * declared — and this is the mechanism that makes a violation visible instead - * of waiting for someone to invoke the route. - * - * Two findings: - * - `undeclared_handler` — 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. - * - `unbound_declaration` — a declared `script` action with no `body` and no - * handler under any candidate key: a button wired to nothing. - * - * A handler reconciles when some declaration for its object (or an object-less - * one) yields it among {@link resolveActionHandlerKeys} — the SAME derivation - * dispatch uses, so the inventory cannot disagree with the router. - */ -export function reconcileActionRegistrations(deps: ActionExecutionDeps, - registered: Array<{ objectName: string; actionName: string; package?: string }>, - declarations: Array<{ action: any; objectName: string }>, -): { - undeclaredHandlers: Array<{ objectName: string; actionName: string; package?: string }>; - unboundDeclarations: Array<{ objectName: string; actionName: string }>; -} { - // Every key any declaration can address, per owning object key. - const addressable = new Map>(); - const addKey = (objectKey: string, handlerKey: string) => { - let set = addressable.get(objectKey); - if (!set) addressable.set(objectKey, (set = new Set())); - set.add(handlerKey); - }; - for (const { action, objectName } of declarations) { - for (const key of resolveActionHandlerKeys(action)) addKey(objectName, key); - } - - const covers = (objectName: string, actionName: string): boolean => { - if (addressable.get(objectName)?.has(actionName)) return true; - // A handler registered under an object-less key is addressable by any - // object-less declaration, mirroring `actionHandlerObjectKeys`. - if (!isObjectLessActionKey(objectName)) return false; - for (const [objectKey, keys] of addressable) { - if (isObjectLessActionKey(objectKey) && keys.has(actionName)) return true; - } - return false; - }; - - const undeclaredHandlers = registered.filter((r) => !covers(r.objectName, r.actionName)); - - const registeredKeys = new Set(registered.map((r) => `${r.objectName}:${r.actionName}`)); - const unboundDeclarations: Array<{ objectName: string; actionName: string }> = []; - for (const { action, objectName } of declarations) { - if ((action?.type ?? 'script') !== 'script') continue; // only script needs a handler - if (action?.body) continue; // its handler is synthesized - const bound = resolveActionHandlerKeys(action).some((key) => - actionHandlerObjectKeys(objectName).some((obj) => registeredKeys.has(`${obj}:${key}`))); - if (!bound) unboundDeclarations.push({ objectName, actionName: action?.name }); - } - - return { undeclaredHandlers, unboundDeclarations }; -} /** * Owning object of a standalone `action` item — must stay in lockstep with @@ -987,32 +942,7 @@ export function standaloneActionObjectName(deps: ActionExecutionDeps, action: an return GLOBAL_ACTION_OBJECT_KEY; } -/** - * The engine object key an object-LESS ("global") action registers under. - * - * Canonical since #3913, and it is `'global'` because that is what the two - * writers have always written: `AppPlugin` (`action.object || 'global'`) and - * `ObjectQLPlugin.actionObjectKey`. `engine.executeAction` is an exact-string - * `Map` lookup with no wildcard semantics, so the READERS have to probe the - * same literal — before this, the REST route and the MCP bridge both rotated - * to `'*'`, which nothing ever registers, and every global action came back as - * `Action '' on object '*' not found`. - */ -export const GLOBAL_ACTION_OBJECT_KEY = 'global'; -/** - * The engine object keys to probe, in order, for a route's action handler. - * - * The routed object first, then the canonical object-less key (#3913), then - * the legacy `'*'` — kept last so a handler that user code registered directly - * against the wildcard still resolves. Deduped, so a request routed AT - * `/actions/global/:action` probes `'global'` exactly once. - */ -export function actionHandlerObjectKeys(objectName: string): string[] { - return [objectName, GLOBAL_ACTION_OBJECT_KEY, '*'].filter( - (k, i, all) => all.indexOf(k) === i, - ); -} /** * True when the error is `executeAction`'s "no such key in the registry" miss @@ -1028,30 +958,6 @@ export function isActionNotRegisteredError(err: any): boolean { return /Action '.+' on object '.+' not found/i.test(String(err?.message ?? err)); } -/** - * [ADR-0110 D2] Handler-key candidates for an action, most-specific first — - * the *addressing* half of "resolve, then address". - * - * A registration key is NOT an action's identity. `app-plugin.ts` - * auto-registers **body** actions under `name`, while user code registers a - * **target-bound** script action under `target` - * (`engine.registerAction('todo_task', 'completeTask', …)`). Identity is - * always the declarative `name` (D1); which key the handler happens to live - * under is derived HERE, from the already-resolved declaration, so no caller - * ever has to know it. - * - * `fallbackKey` (the routed URL segment) is the last candidate and exists for - * the UNDECLARED case, where there is no declaration to derive anything from. - * It is deduped away whenever the declaration already yields it, so it never - * widens what a declared action can reach. - */ -export function resolveActionHandlerKeys(action: any, fallbackKey?: string): string[] { - const primary = action ? (action.body ? action.name : (action.target || action.name)) : undefined; - return [primary, action?.target, action?.name, fallbackKey].filter( - (k: unknown, i: number, a: unknown[]): k is string => - typeof k === 'string' && k.length > 0 && a.indexOf(k) === i, - ); -} /** * [ADR-0110 D2] Run a script/body action through the engine's handler @@ -1091,16 +997,6 @@ export async function executeRegisteredAction(deps: ActionExecutionDeps, return { dispatched: false }; } -/** - * True when the routed "object" is the object-less placeholder rather than a - * real object — the canonical `'global'`, the legacy `'*'`, or nothing at all - * (`POST /actions//:action`). Callers use it to skip work that only makes - * sense for a record-scoped action: loading `ctx.record`, and naming an - * `object` on the flow-automation context. - */ -export function isObjectLessActionKey(objectName: string | undefined | null): boolean { - return !objectName || objectName === GLOBAL_ACTION_OBJECT_KEY || objectName === '*'; -} /** * Resolve the DECLARATION behind a `/` route pair — the @@ -1187,3 +1083,15 @@ export async function resolveRouteActionDeclaration(deps: ActionExecutionDeps, return { action: undefined, obj, degraded, reason }; } + +/** + * [ADR-0110 D5] Back-compat wrapper over the engine-owned reconciliation — + * the `deps` parameter was never read; kept so existing call sites and tests + * are source-compatible. New code should import from `@objectstack/objectql`. + */ +export function reconcileActionRegistrations(_deps: ActionExecutionDeps, + registered: Array<{ objectName: string; actionName: string; package?: string }>, + declarations: Array<{ action: any; objectName: string }>, +): ReturnType { + return reconcileActionRegistrationsPure(registered, declarations); +} diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index ab0b312358..1c8c1f7158 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -10,12 +10,7 @@ import { loadDisabledPackageIds } from './package-state-store.js'; import type { IMetadataService, II18nService } from '@objectstack/spec/contracts'; import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; import { hookBodyRunnerFactory, actionBodyRunnerFactory } from './sandbox/body-runner.js'; -import { - GLOBAL_ACTION_OBJECT_KEY, - collectActionDeclarations, - reconcileActionRegistrations, - type ActionExecutionDeps, -} from './action-execution.js'; +import { GLOBAL_ACTION_OBJECT_KEY } from './action-execution.js'; import { countServerTiming } from '@objectstack/observability'; /** @@ -690,64 +685,13 @@ export class AppPlugin implements Plugin { }); } - // ── [ADR-0110 D5] Action governance inventory ──────────────────── - // Reconcile the handler registry against the declarations, on - // `kernel:ready` so imperative `engine.registerAction(...)` calls in - // user code (which run after this bind) are counted. - // - // Since D3 an undeclared handler is REFUSED at dispatch. A hard - // failure with no inventory is a support ticket; with one it is a - // checklist, so this list is the whole point of shipping the refusal - // and the inventory in the same release. Best-effort and warn-only — - // a diagnostic must never be the reason a kernel fails to boot. - // A host whose context predates `hook` (or a partial test double) must - // not lose its kernel to a DIAGNOSTIC — the guard keeps this block's - // own promise, which registering unconditionally did not. - ctx.hook?.('kernel:ready', async () => { - try { - const engine: any = ctx.getService('objectql'); - if (!engine || typeof engine.listRegisteredActions !== 'function') return; - let meta: any; - try { meta = ctx.getService('metadata'); } catch { /* optional */ } - // The reconciliation reads no services of its own — it is pure - // over the two lists — so a thin deps shim is enough here. - const actionDeps: ActionExecutionDeps = { - resolveService: (name: string) => { try { return ctx.getService(name); } catch { return undefined; } }, - getObjectQL: async () => engine, - }; - const declarations = await collectActionDeclarations(actionDeps, meta); - const { undeclaredHandlers, unboundDeclarations } = reconcileActionRegistrations( - actionDeps, engine.listRegisteredActions(), declarations, - ); - if (undeclaredHandlers.length > 0) { - ctx.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', - { - appId, - count: undeclaredHandlers.length, - handlers: undeclaredHandlers.map((h) => `${h.objectName}:${h.actionName}`), - }, - ); - } - if (unboundDeclarations.length > 0) { - ctx.logger.warn( - '[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`', - { - appId, - count: unboundDeclarations.length, - actions: unboundDeclarations.map((d) => `${d.objectName}:${d.actionName}`), - }, - ); - } - } catch (err: any) { - ctx.logger.debug('[action-governance] inventory skipped', { - appId, error: err?.message ?? String(err), - }); - } - }); + // [ADR-0110 D5] The action-governance inventory used to hang off a + // `kernel:ready` hook HERE. Moved to ObjectQLPlugin: AppPlugin is + // registered conditionally (serve.ts skips it when the host wraps + // itself; the `os dev` fast path loads apps without it), so on the + // platform's own dev loop the inventory never ran — and the engine + // plugin owns the very registry being audited, is unconditionally + // present, and re-runs the audit on `metadata:reloaded`. // ── Auto-register declarative Background Jobs ──────────────────── // Jobs declared via `defineStack({ jobs })` are scheduled against the