Skip to content

Commit 48fcf70

Browse files
authored
fix(objectql,runtime): the ADR-0110 D5 inventory is engine-owned — AppPlugin never ran it under os dev (#4011)
A positive-control dogfood (inject an undeclared handler, expect it named at boot) proved the inventory's kernel:ready hook never fired on the platform's own dev loop: AppPlugin is registered CONDITIONALLY, so the checklist that justifies D3's no-opt-out refusal (#3987) was never printed where an upgrade most needs it. The addressing vocabulary and reconcileActionRegistrations move into @objectstack/objectql — the engine owns the map they describe, and the dependency direction (runtime -> objectql) permits no other home. Runtime re-exports them unchanged, so dispatch, MCP and the inventory keep reading ONE implementation. ObjectQLPlugin audits in its existing kernel:ready handler after resyncAuthoredActions, re-runs on metadata:reloaded with fingerprint suppression, and stops the N-apps -> N-identical-reports multiplication. os dev / os serve still swallow ALL plugin boot logs (pre-existing sink defect, #4012); the 3 local analytics-slot test failures reproduce on clean main and are tracked in #4013.
1 parent be7945a commit 48fcf70

8 files changed

Lines changed: 523 additions & 193 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/runtime': patch
4+
---
5+
6+
**[ADR-0110 D5] The action-governance inventory moves to the engine plugin —
7+
AppPlugin never ran it on the platform's own dev path.**
8+
9+
Dogfooding the inventory with a positive control (an injected undeclared
10+
handler) showed the `kernel:ready` hook it hung on never fired under `os dev`:
11+
AppPlugin is registered conditionally (`serve.ts` skips it when the host wraps
12+
itself; the dev fast path loads apps without it), so the checklist that
13+
justifies D3's no-opt-out refusal was never printed where an upgrade most
14+
needs it.
15+
16+
- The addressing vocabulary (`GLOBAL_ACTION_OBJECT_KEY`,
17+
`actionHandlerObjectKeys`, `isObjectLessActionKey`,
18+
`resolveActionHandlerKeys`) and the reconciliation move into
19+
`@objectstack/objectql` — the engine owns the map they describe, and the
20+
dependency direction (runtime → objectql) permits no other home.
21+
`@objectstack/runtime` re-exports them unchanged, so dispatch, the MCP
22+
bridge and existing importers keep reading ONE implementation.
23+
- `ObjectQLPlugin` now runs the inventory in its existing `kernel:ready`
24+
handler — after `resyncAuthoredActions`, so the audited registry is final —
25+
and again on `metadata:reloaded`, fingerprint-suppressed so a reload that
26+
changed nothing action-related logs nothing. A Studio edit that orphans or
27+
binds a handler updates the report live; the old boot-only snapshot went
28+
stale on the first edit.
29+
- Verified end-to-end with a programmatic kernel: the injected orphan is
30+
named, a clean registry is silent. The `os dev` / `os serve` consoles still
31+
swallow ALL plugin boot logs (pre-existing, tracked separately) — on those
32+
surfaces the inventory becomes visible once that sink is fixed.

examples/app-todo/src/actions/register-handlers.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,12 @@ export function registerTaskActionHandlers(engine: {
8686
engine.registerAction('todo_task', 'deleteCompletedTasks', deleteCompletedTasks);
8787
engine.registerAction('todo_task', 'exportTasksToCSV', exportTasksToCSV);
8888

89-
// ─── Modal-type actions (server-side form submission handlers) ─────
90-
// These process the params collected by the modal UI before the
91-
// engine updates the record. The modal target (e.g. 'defer_task_modal')
92-
// tells the UI which modal page to open; the handler below processes
93-
// the submitted form data.
89+
// ─── Param-collecting script actions ───────────────────────────────
90+
// `defer_task` / `set_reminder` declare `params`, so the runner collects
91+
// them in a dialog and these handlers run with the values. They were
92+
// `type: 'modal'` until #3959 — a modal action has no server dispatch, so
93+
// the targets named modal pages that did not exist and neither handler had
94+
// ever executed. They are `type: 'script'` targeting these keys now.
9495
engine.registerAction('todo_task', 'deferTask', deferTask);
9596
engine.registerAction('todo_task', 'setReminder', setReminder);
9697
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0110 D5] The engine-owned governance inventory.
5+
*
6+
* The reconciliation itself is pinned by @objectstack/runtime's
7+
* action-reconciliation tests (through the back-compat wrapper). These pin
8+
* the REPORTING layer that moved here with it: that the inventory names the
9+
* orphans, that a clean registry stays silent, that duplicate findings are
10+
* fingerprint-suppressed across `metadata:reloaded` re-runs, and that a
11+
* failing declaration source degrades to a debug line instead of throwing —
12+
* a diagnostic must never be the reason a kernel fails to boot.
13+
*/
14+
15+
import { describe, it, expect, vi } from 'vitest';
16+
import { runActionGovernanceInventory } from './action-governance.js';
17+
18+
const makeLogger = () => ({ warn: vi.fn(), debug: vi.fn() });
19+
20+
const todoObjects = [{
21+
name: 'todo_task',
22+
actions: [{ name: 'complete_task', type: 'script', target: 'completeTask' }],
23+
}];
24+
25+
describe('runActionGovernanceInventory (ADR-0110 D5)', () => {
26+
it('names a registered handler that no declaration addresses', async () => {
27+
const logger = makeLogger();
28+
await runActionGovernanceInventory({
29+
registered: [
30+
{ objectName: 'todo_task', actionName: 'completeTask' },
31+
{ objectName: 'todo_task', actionName: 'ghostProbe' },
32+
],
33+
objects: todoObjects,
34+
logger,
35+
});
36+
37+
expect(logger.warn).toHaveBeenCalledWith(
38+
expect.stringMatching(/registered handlers with NO declaration/),
39+
expect.objectContaining({ count: 1, handlers: ['todo_task:ghostProbe'] }),
40+
);
41+
});
42+
43+
it('names a declared script action bound to no handler', async () => {
44+
const logger = makeLogger();
45+
await runActionGovernanceInventory({
46+
registered: [],
47+
objects: todoObjects,
48+
logger,
49+
});
50+
51+
expect(logger.warn).toHaveBeenCalledWith(
52+
expect.stringMatching(/declared script actions with NO handler/),
53+
expect.objectContaining({ actions: ['todo_task:complete_task'] }),
54+
);
55+
});
56+
57+
it('stays silent when both sides reconcile', async () => {
58+
const logger = makeLogger();
59+
await runActionGovernanceInventory({
60+
registered: [{ objectName: 'todo_task', actionName: 'completeTask' }],
61+
objects: todoObjects,
62+
logger,
63+
});
64+
65+
expect(logger.warn).not.toHaveBeenCalled();
66+
});
67+
68+
it('folds standalone `action` items in, embedded declaration winning', async () => {
69+
const logger = makeLogger();
70+
await runActionGovernanceInventory({
71+
registered: [
72+
{ objectName: 'todo_task', actionName: 'completeTask' },
73+
{ objectName: 'global', actionName: 'logCall' },
74+
],
75+
objects: todoObjects,
76+
loadStandaloneActions: async () => [
77+
{ name: 'log_call', type: 'script', target: 'logCall' }, // object-less
78+
],
79+
logger,
80+
});
81+
82+
expect(logger.warn).not.toHaveBeenCalled();
83+
});
84+
85+
it('suppresses a byte-identical repeat via the fingerprint', async () => {
86+
const logger = makeLogger();
87+
const args = {
88+
registered: [{ objectName: 'todo_task', actionName: 'ghostProbe' }],
89+
objects: [] as any[],
90+
logger,
91+
};
92+
const fp = await runActionGovernanceInventory(args);
93+
expect(logger.warn).toHaveBeenCalledTimes(1);
94+
95+
// metadata:reloaded with nothing action-related changed → no repeat.
96+
await runActionGovernanceInventory({ ...args, lastFingerprint: fp });
97+
expect(logger.warn).toHaveBeenCalledTimes(1);
98+
99+
// A CHANGED finding set logs again.
100+
await runActionGovernanceInventory({
101+
...args,
102+
registered: [...args.registered, { objectName: 'todo_task', actionName: 'ghostProbe2' }],
103+
lastFingerprint: fp,
104+
});
105+
expect(logger.warn).toHaveBeenCalledTimes(2);
106+
});
107+
108+
it('degrades to debug when the declaration source throws — never throws itself', async () => {
109+
const logger = makeLogger();
110+
await expect(runActionGovernanceInventory({
111+
registered: [{ objectName: 'todo_task', actionName: 'x' }],
112+
// A poisoned objects array: property access explodes.
113+
objects: [new Proxy({}, { get() { throw new Error('boom'); } })],
114+
logger,
115+
})).resolves.toBeDefined();
116+
117+
expect(logger.debug).toHaveBeenCalledWith(
118+
expect.stringMatching(/inventory skipped/),
119+
expect.objectContaining({ error: 'boom' }),
120+
);
121+
});
122+
});

0 commit comments

Comments
 (0)