Skip to content

Commit 19c50eb

Browse files
committed
fix(actions): pin the ledger fake's update dispatch, and declare how the activation door is actually served
Two CI failures, both real. 1. `check-engine-double-contract` — the activation-ledger fake's `update()` did not route through ObjectQL's own dispatch predicate, so the double could accept call shapes the engine refuses. Opened with `assertEngineUpdateDispatch(data, options)` from `@objectstack/metadata-core` (the predicate's home since #5619; importing objectql's own re-export from inside objectql would be a self-import), matching the flow twin's fake. The new pinned coverage is recorded in the pinned ledger via the sanctioned `--write`; the shrink-only baseline is untouched. 2. `route-ledger-live-mount-parity` (#7526) — the activation door was "LEDGERED BUT NOT MOUNTED, and DISGUISED". Root-caused on a booted showcase rather than guessed: nothing registers `/api/v1/actions/_activation/:object/:action`, and a 3-segment activation path is matched by `/api/v1/actions/:object/:action/:recordId` with `_activation` bound to `:object`. It still reaches the activation arm because that mount rebuilds the dispatch path from its matched params, byte for byte — measured on the wire: the 2-segment shape answers this door's own 400 naming the required shape, and a 3-segment undeclared action answers its own 404 ("nothing to switch off"), neither of which the invocation path can produce; a 4-segment path resolves to nothing and 404s at the router. So the honest fix is the declaration, not a second mount: the ledger row carries `servedBy: /api/v1/actions/:object/:action/:recordId` with the mechanism written down. Because a `servedBy` note cannot say the arm still ANSWERS — and the unit suites drive the domain handler directly, which is why they stayed green through this — the two wire assertions are pinned end-to-end in the actions dogfood suite, on a boot that already exists. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh
1 parent 8b01371 commit 19c50eb

4 files changed

Lines changed: 73 additions & 0 deletions

File tree

‎packages/objectql/src/action-activation.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@
2727
// `metadata:reloaded`, and a re-registered handler must stay switched off.
2828

2929
import { describe, it, expect, vi } from 'vitest';
30+
// The engine's OWN update-dispatch predicate, so the fake below cannot be
31+
// looser than ObjectQL.update (`pnpm check:engine-double-contract`). Imported
32+
// from metadata-core rather than from `@objectstack/objectql`: this test lives
33+
// INSIDE objectql, and the package re-exporting it is this one.
34+
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
3035

3136
import { ObjectQL } from './engine.js';
3237
import {
@@ -61,6 +66,14 @@ function makeStoreEngine(rows: any[] = []) {
6166
return { id: 'row_new', ...data };
6267
}),
6368
update: vi.fn(async (object: string, data: any, options?: any) => {
69+
// Routed through ObjectQL's OWN dispatch predicate, so this fake
70+
// cannot be looser than the engine it stands in for — #4434 shipped
71+
// a dead REST route with its suite green off exactly that gap.
72+
// `pnpm check:engine-double-contract` is the gate; the predicate
73+
// lives in metadata-core, which this package already depends on
74+
// (importing objectql's own re-export from inside objectql would be
75+
// a self-import). Same pin the flow twin's fake carries.
76+
assertEngineUpdateDispatch(data, options);
6477
calls.push({ op: 'update', object, data, options });
6578
return data;
6679
}),

‎packages/qa/dogfood/test/action-params-contract.dogfood.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,48 @@ describe('dogfood: action param contract enforced at dispatch (ADR-0104 D2)', ()
6969
delete process.env.OS_ALLOW_LAX_ACTION_PARAMS;
7070
}
7171
});
72+
73+
// ── [#12160] The ADR-0126 §8 activation door, on the REAL mount ───────────
74+
//
75+
// `POST /actions/_activation/:object/:action` is NOT a registered pattern.
76+
// Nothing mounts it: a 3-segment activation path is matched by
77+
// `/api/v1/actions/:object/:action/:recordId` with `_activation` bound to
78+
// `:object`, and it only reaches the activation arm because that mount
79+
// rebuilds the dispatch path from its matched params, byte for byte. The
80+
// route ledger says so with `servedBy`, and #7526's parity gate holds that
81+
// declaration to the live router.
82+
//
83+
// What the ledger row CANNOT say is that the arm still answers, and that is
84+
// the half worth pinning here: the unit suites drive `handleActionsRequest`
85+
// directly, so they would stay green if the mount ever stopped forwarding
86+
// the path faithfully (passing structured params instead, say) and every
87+
// activation call started being served as an INVOCATION of an action named
88+
// after the object segment. These two assertions fail the moment that
89+
// happens, because both messages are ones only the activation arm produces.
90+
describe('[#12160] the reserved `_activation` segment survives the real mount', () => {
91+
it('the 2-segment shape answers the activation door, not an invocation', async () => {
92+
const res = await stack.apiAs(token, 'POST', '/actions/_activation/showcase_mark_done', {});
93+
const text = await res.text();
94+
95+
// The invocation path's answer for this URL would be a 404 naming a
96+
// missing DECLARATION (`add \`defineAction\``) for action
97+
// `showcase_mark_done` on object `_activation`.
98+
expect(res.status, text).toBe(400);
99+
expect(text).toContain('/actions/_activation/:object/:action');
100+
});
101+
102+
it('the 3-segment shape reaches the activation door for a real object', async () => {
103+
const res = await stack.apiAs(
104+
token, 'POST', '/actions/_activation/showcase_task/no_such_action', { enabled: false },
105+
);
106+
const text = await res.text();
107+
108+
expect(res.status, text).toBe(404);
109+
// The activation door's own wording — the invocation door's 404 for an
110+
// undeclared action says `add \`defineAction\`` instead, so this
111+
// distinguishes "my arm ran" from "swallowed by the sibling".
112+
expect(text).toContain('nothing to switch off');
113+
expect(text).not.toContain('defineAction');
114+
});
115+
});
72116
});

‎packages/runtime/src/route-ledger.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,18 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
384384
{ route: 'POST /actions//:action', domain: '/actions', disposition: 'sdk', client: 'actions.invokeGlobal',
385385
note: 'the object-less spelling of the global-action call (#3913) — same handler and same `global` key as the row above, reached without naming an object' },
386386
{ route: 'POST /actions/_activation/:object/:action', domain: '/actions', disposition: 'server-only',
387+
servedBy: '/api/v1/actions/:object/:action/:recordId',
387388
note: '[#12160] ADR-0126 §8 item 2 — enable/disable ONE packaged action, the only non-invocation shape this domain serves. '
389+
+ '⚠️ `servedBy`, and MEASURED on a real boot (#7526 caught it): no pattern of this spelling is registered. The '
390+
+ 'dispatcher mounts three `/actions` patterns, and a 3-segment activation path is matched by the LAST of them with '
391+
+ '`_activation` bound to `:object` — but that mount rebuilds the dispatch path from the matched params '
392+
+ '(`/actions/${object}/${action}/${recordId}`), so the path `handleActionsRequest` parses is byte-identical to the '
393+
+ 'one the caller sent and the `_activation` arm still fires. Probed against a booted showcase: the 2-segment shape '
394+
+ 'answers this door\'s own 400 (`Path must be /actions/_activation/:object/:action`) and a 3-segment undeclared '
395+
+ 'action answers this door\'s own 404 (`nothing to switch off`), neither of which the invocation path can produce. '
396+
+ 'A 4-segment path resolves to NOTHING and 404s at the router. Pinned end-to-end through the real mount in '
397+
+ '`qa/dogfood/test/action-params-contract.dogfood.test.ts`, so if that reconstruction ever stops being faithful the '
398+
+ 'pin reddens rather than this note going quietly stale. '
388399
+ 'Body `{ enabled?: boolean }`; it writes a `sys_metadata_activation` row and nothing else (⛔ no definition write, ⛔ no clone: '
389400
+ 'the action-clone half is unchartered). Two authority tiers, the same pair `POST /automation/:name/toggle` carries: '
390401
+ '`manage_metadata`, then the ADR-0126 §5 posture rule requiring the platform operator in `group`/`isolated` — one shared '

‎scripts/engine-double-contract.pinned.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -951,6 +951,11 @@
951951
"verb": "update",
952952
"pinned": 1
953953
},
954+
{
955+
"file": "packages/objectql/src/action-activation.test.ts",
956+
"verb": "update",
957+
"pinned": 1
958+
},
954959
{
955960
"file": "packages/objectql/src/layered-overlay-integration.test.ts",
956961
"verb": "delete",

0 commit comments

Comments
 (0)