Skip to content

Commit bd8795e

Browse files
os-muskclaude
andauthored
fix(objectql): the action-governance audit resolves declarations through the router's rungs (#14421)
The boot inventory built its declaration set from object-embedded `actions[]` plus the metadata service's `action` rows, while `resolveRouteActionDeclaration` resolves through a third source between those two: the engine registry's standalone `action` items. On the in-process boot the metadata plane holds no `action` rows, so every object-less `defineAction` was reported as a registered handler with no declaration, "REFUSED at dispatch ... there is no opt-out", in the same boot in which the router resolved it at that rung and dispatched it. `ObjectQLPlugin` — the one caller holding the engine — now injects that rung, and the audit judges the answer with the router's own ownership test. The warning stops asserting a dispatch outcome it never performed: it names the sources it read, says it did not dispatch, and sends an author whose action IS declared to the real bug rather than to deleting a working registration. The file docblock's "the inventory can never disagree with the router" invariant is corrected to the one that now holds. `declared script actions with NO handler` is unchanged in wording and in population. Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 Co-authored-by: Claude <noreply@anthropic.com>
1 parent ad54eb3 commit bd8795e

5 files changed

Lines changed: 487 additions & 19 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
Startup `[action-governance]` resolves declarations through the same rungs the router does
6+
7+
The boot inventory built its declaration set from object-embedded `actions[]` plus the
8+
metadata service's `action` rows. `resolveRouteActionDeclaration` resolves through a third
9+
source between those two — the engine registry's standalone `action` items,
10+
`registry.getItem('action', name)`, accepted when the item owns the route. On the in-process
11+
boot (`new AppPlugin(...)` then `kernel.bootstrap()`), where the metadata plane holds no
12+
`action` rows at all, every object-less `defineAction` was therefore reported as a
13+
"registered handler with NO declaration — REFUSED at dispatch (ADR-0110 D3) and there is no
14+
opt-out" in the same boot in which the router resolved it at that rung and dispatched it.
15+
Both remedies the message offered were wrong for that shape: the action was already declared
16+
with `defineAction`, and dropping the registration would have broken a working endpoint under
17+
a green `pnpm validate`.
18+
19+
The registry rung is now injected into the audit by `ObjectQLPlugin` — the one caller holding
20+
the engine, because objectql cannot import the router — and judged by the same ownership test
21+
the router applies. The warning also stops asserting a dispatch outcome it never checked: it
22+
names the three sources it read, says it did not dispatch, and points an author whose action
23+
IS declared at the real bug instead of at deleting the registration. The other finding in the
24+
block, `declared script actions with NO handler`, is unchanged in wording and in population.

packages/objectql/src/action-governance.test.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@
1010
* fingerprint-suppressed across `metadata:reloaded` re-runs, and that a
1111
* failing declaration source degrades to a debug line instead of throwing —
1212
* a diagnostic must never be the reason a kernel fails to boot.
13+
*
14+
* The second describe block pins the router's registry rung. The measured
15+
* defect: on the in-process boot (`new AppPlugin(...)` then
16+
* `kernel.bootstrap()`), `meta.loadMany('action')` answers `[]` while
17+
* `registry.getItem('action', name)` answers the declaration, so every
18+
* object-LESS `defineAction` was named as a "registered handler with NO
19+
* declaration ... REFUSED at dispatch" in the same boot in which the router
20+
* resolved it at rung 2 and dispatched it. Pinned here: the two boots (the
21+
* registry holds it, the plane does not), both call forms (object-bound and
22+
* object-less), a positive control that must stay reported, the ownership
23+
* test that keeps the rung from clearing a foreign declaration, and the
24+
* second warning holding its exact wording while the first changes.
1325
*/
1426

1527
import { describe, it, expect, vi } from 'vitest';
@@ -120,3 +132,169 @@ describe('runActionGovernanceInventory (ADR-0110 D5)', () => {
120132
);
121133
});
122134
});
135+
136+
describe('runActionGovernanceInventory — the router registry rung (#14123)', () => {
137+
/** `registry.getItem('action', name)`, as the plugin injects it. */
138+
const registryOf = (items: Record<string, any>) => (name: string) => items[name];
139+
140+
const applyAction = { name: 'duly_catalog_apply', type: 'script', locations: [] };
141+
142+
it('clears an object-LESS declaration the registry holds and the plane does not', async () => {
143+
const logger = makeLogger();
144+
await runActionGovernanceInventory({
145+
registered: [{ objectName: 'global', actionName: 'duly_catalog_apply' }],
146+
objects: [], // no object embeds it
147+
loadStandaloneActions: async () => [], // in-process boot: the plane is empty
148+
lookupRegistryAction: registryOf({ duly_catalog_apply: applyAction }),
149+
logger,
150+
});
151+
152+
expect(logger.warn).not.toHaveBeenCalled();
153+
});
154+
155+
it('clears an object-BOUND declaration the registry holds and the plane does not', async () => {
156+
const logger = makeLogger();
157+
await runActionGovernanceInventory({
158+
registered: [{ objectName: 'todo_task', actionName: 'archive_task' }],
159+
objects: [{ name: 'todo_task', actions: [] }],
160+
loadStandaloneActions: async () => [],
161+
lookupRegistryAction: registryOf({
162+
archive_task: { name: 'archive_task', objectName: 'todo_task', type: 'script' },
163+
}),
164+
logger,
165+
});
166+
167+
expect(logger.warn).not.toHaveBeenCalled();
168+
});
169+
170+
it('POSITIVE CONTROL — a handler no source declares is still named', async () => {
171+
const logger = makeLogger();
172+
await runActionGovernanceInventory({
173+
registered: [
174+
{ objectName: 'global', actionName: 'duly_catalog_apply' },
175+
{ objectName: 'global', actionName: 'ghostProbe' },
176+
{ objectName: 'todo_task', actionName: 'ghostBound' },
177+
],
178+
objects: [{ name: 'todo_task', actions: [] }],
179+
loadStandaloneActions: async () => [],
180+
lookupRegistryAction: registryOf({ duly_catalog_apply: applyAction }),
181+
logger,
182+
});
183+
184+
expect(logger.warn).toHaveBeenCalledTimes(1);
185+
expect(logger.warn).toHaveBeenCalledWith(
186+
expect.stringMatching(/registered handlers with NO declaration/),
187+
expect.objectContaining({ count: 2, handlers: ['global:ghostProbe', 'todo_task:ghostBound'] }),
188+
);
189+
});
190+
191+
it('applies the router ownership test — a foreign object-bound item does not cover the route', async () => {
192+
const logger = makeLogger();
193+
await runActionGovernanceInventory({
194+
registered: [{ objectName: 'todo_task', actionName: 'archive_task' }],
195+
objects: [{ name: 'todo_task', actions: [] }],
196+
lookupRegistryAction: registryOf({
197+
archive_task: { name: 'archive_task', objectName: 'crm_lead', type: 'script' },
198+
}),
199+
logger,
200+
});
201+
202+
expect(logger.warn).toHaveBeenCalledWith(
203+
expect.stringMatching(/registered handlers with NO declaration/),
204+
expect.objectContaining({ handlers: ['todo_task:archive_task'] }),
205+
);
206+
});
207+
208+
it('stops asserting a dispatch outcome it did not check, and names the sources it did read', async () => {
209+
const logger = makeLogger();
210+
await runActionGovernanceInventory({
211+
registered: [{ objectName: 'global', actionName: 'ghostProbe' }],
212+
objects: [],
213+
logger,
214+
});
215+
216+
const [message] = logger.warn.mock.calls[0];
217+
expect(message).not.toMatch(/REFUSED at dispatch/);
218+
expect(message).not.toMatch(/there is no opt-out/);
219+
expect(message).not.toMatch(/drop the registration/);
220+
expect(message).toMatch(/it did not dispatch/);
221+
expect(message).toMatch(/object-embedded `actions\[\]`/);
222+
expect(message).toMatch(/the engine registry standalone `action` items/);
223+
expect(message).toMatch(/the metadata service `action` rows/);
224+
});
225+
226+
it('leaves the OTHER warning byte-identical — a registry item is not folded into the declaration set', async () => {
227+
const logger = makeLogger();
228+
await runActionGovernanceInventory({
229+
registered: [],
230+
objects: todoObjects,
231+
lookupRegistryAction: registryOf({
232+
// A registry-only script declaration with no handler anywhere. It must
233+
// not join `unboundDeclarations`: the router never enumerates the
234+
// registry, so neither does this audit.
235+
orphan_action: { name: 'orphan_action', type: 'script', target: 'orphanHandler' },
236+
}),
237+
logger,
238+
});
239+
240+
expect(logger.warn).toHaveBeenCalledTimes(1);
241+
expect(logger.warn).toHaveBeenCalledWith(
242+
'[action-governance] declared script actions with NO handler — a button wired to '
243+
+ 'nothing (ADR-0078); add a `body`, or register a handler under the declared `target`',
244+
{ count: 1, actions: ['todo_task:complete_task'] },
245+
);
246+
});
247+
248+
it('keeps the handler when the registry lookup throws — and never throws itself', async () => {
249+
const logger = makeLogger();
250+
await expect(runActionGovernanceInventory({
251+
registered: [{ objectName: 'global', actionName: 'duly_catalog_apply' }],
252+
objects: [],
253+
lookupRegistryAction: () => { throw new Error('registry unreadable'); },
254+
logger,
255+
})).resolves.toBeDefined();
256+
257+
expect(logger.warn).toHaveBeenCalledWith(
258+
expect.stringMatching(/registered handlers with NO declaration/),
259+
expect.objectContaining({ handlers: ['global:duly_catalog_apply'] }),
260+
);
261+
});
262+
263+
it('fingerprints the FILTERED set, so a rung-cleared boot reports and remembers nothing', async () => {
264+
const logger = makeLogger();
265+
const fp = await runActionGovernanceInventory({
266+
registered: [{ objectName: 'global', actionName: 'duly_catalog_apply' }],
267+
objects: [],
268+
lookupRegistryAction: registryOf({ duly_catalog_apply: applyAction }),
269+
logger,
270+
});
271+
272+
expect(fp).toBe('');
273+
expect(logger.warn).not.toHaveBeenCalled();
274+
275+
// The declaration disappears on a later reload: the finding is new, so it reports.
276+
await runActionGovernanceInventory({
277+
registered: [{ objectName: 'global', actionName: 'duly_catalog_apply' }],
278+
objects: [],
279+
lookupRegistryAction: registryOf({}),
280+
logger,
281+
lastFingerprint: fp,
282+
});
283+
284+
expect(logger.warn).toHaveBeenCalledTimes(1);
285+
});
286+
287+
it('is unchanged when no rung is injected — two sources, and the finding stands', async () => {
288+
const logger = makeLogger();
289+
await runActionGovernanceInventory({
290+
registered: [{ objectName: 'global', actionName: 'duly_catalog_apply' }],
291+
objects: [],
292+
logger,
293+
});
294+
295+
expect(logger.warn).toHaveBeenCalledWith(
296+
expect.stringMatching(/registered handlers with NO declaration/),
297+
expect.objectContaining({ handlers: ['global:duly_catalog_apply'] }),
298+
);
299+
});
300+
});

0 commit comments

Comments
 (0)