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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/action-governance-engine-owned.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 6 additions & 5 deletions examples/app-todo/src/actions/register-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
122 changes: 122 additions & 0 deletions packages/objectql/src/action-governance.test.ts
Original file line number Diff line number Diff line change
@@ -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' }),
);
});
});
Loading
Loading