Skip to content

Commit 0765b14

Browse files
committed
fix(runtime): mount the two operator run-lifecycle verbs the ledger already declares
`POST /automation/:name/runs/:runId/cancel` and `.../restore-suspension` were declared in `route-ledger.ts` and answered by the `/automation` domain handler, but nothing registered them on the HTTP router: `registerAutomationRoutes` in `dispatcher-plugin.ts` mounts one literal registration per automation route and gained neither. Both URLs answered Hono's `notFound` at runtime while every ledger-reading guard passed them — the class the route-ledger to live-mount parity gate (#7526) exists to catch, and the class it caught this in. Two literal `server!.post` registrations, mirroring the `resume` arm beside them. They carry no authority logic: the gate stays the single `isRunLifecycleWrite` predicate in `domains/automation.ts` on the ADR-0095 posture rung, required unconditionally. The dogfood authz probe blind-spot census moves with the population it measures: the runtime ledger reads 82 rows instead of 80. Both new rows carry `domain: '/automation'`, an already-classified key, so `reachable` moves with `population`, `blindSpot` stays 0 and the key count stays 21. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
1 parent c9c1c6d commit 0765b14

3 files changed

Lines changed: 43 additions & 5 deletions

File tree

packages/qa/dogfood/test/authz-conformance.matrix.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
// dispatcher domain files.
2626
//
2727
// The population comes from `packages/rest/src/rest-route-ledger.ts` (94 rows
28-
// / 19 families) and `packages/runtime/src/route-ledger.ts` (80 rows / 21
28+
// / 19 families) and `packages/runtime/src/route-ledger.ts` (82 rows / 21
2929
// domains) because those two are enumerated from a RUNNING server and guarded
3030
// in both directions by their own conformance tests — so a new family or
3131
// domain cannot be silently absent from them, and therefore cannot be silently

packages/qa/dogfood/test/authz-probe-blind-spot.census.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@
106106
// `RestServer.getRoutes()` on a booted server and guarded per route by
107107
// `rest-route-ledger.conformance.test.ts`. It reaches all 17 registrars;
108108
// this table reaches 1.
109-
// `packages/runtime/src/route-ledger.ts`: 80 rows over 21 domains. Its
109+
// `packages/runtime/src/route-ledger.ts`: 82 rows over 21 domains. Its
110110
// machine contract is DOMAIN-level, by live registry introspection
111111
// (`domainRegistry.list()`), the per-route rows being documentation. It
112112
// covers all 15 `async handle*(` methods in `http-dispatcher.ts` and all
@@ -295,11 +295,16 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
295295
kinds: ['ROUTE_ENUMERATION'],
296296
probes: 1,
297297
keys: 21,
298-
population: 80,
299-
reachable: 80,
298+
// [#13953] 80 -> 82: the two operator run-lifecycle rows
299+
// (`POST /automation/:name/runs/:runId/cancel` and `.../restore-suspension`).
300+
// Both carry `domain: '/automation'`, an EXISTING key, so `reachable` moves
301+
// with `population`, `blindSpot` stays 0 and `keys` stays 21 — a population
302+
// that grows inside an already-classified domain mints nothing new.
303+
population: 82,
304+
reachable: 82,
300305
blindSpot: 0,
301306
populationRule: 'ledger rows inside ROUTE_LEDGER; reachable = rows carrying a `domain` (each distinct value mints a key)',
302-
controls: { "route: '": 80, "domain: '": 80, RouteLedgerEntry: 2 },
307+
controls: { "route: '": 82, "domain: '": 82, RouteLedgerEntry: 2 },
303308
note:
304309
'The dispatcher half. Its machine contract is DOMAIN-level by live registry introspection ' +
305310
'(domainRegistry.list()), guarded in BOTH directions by route-ledger.conformance.test.ts: every ' +

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1553,6 +1553,39 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
15531553
}
15541554
});
15551555

1556+
// [#13953] The two OPERATOR RUN-LIFECYCLE verbs — cancel a
1557+
// suspended run (ADR-0044) and put back the suspension a failed
1558+
// resume consumed (#13909). LITERAL registrations, one per verb,
1559+
// for the reason `resume` above is one: nothing else registered
1560+
// on this router answers a 5-segment POST under `/automation`,
1561+
// so a ledger row with no `server!.post` here answers Hono's
1562+
// `notFound` at runtime while every ledger-reading guard passes
1563+
// it — the exact class the route-ledger ↔ live-mount parity gate
1564+
// (#7526) exists to catch, and the class it caught this in.
1565+
//
1566+
// ⛔ These arms carry NO authority logic. The gate is one
1567+
// predicate in `domains/automation.ts` (`isRunLifecycleWrite`,
1568+
// the ADR-0095 posture rung, unconditional) read by the gate and
1569+
// by both route arms there; a second spelling here would be a
1570+
// second policy that happens to agree today.
1571+
server!.post(`${base}/automation/:name/runs/:runId/cancel`, async (req: any, res: any) => {
1572+
try {
1573+
const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/runs/${req.params.runId}/cancel`, req.body, req.query, { request: req });
1574+
sendResult(result, res);
1575+
} catch (err: any) {
1576+
errorResponse(err, res);
1577+
}
1578+
});
1579+
1580+
server!.post(`${base}/automation/:name/runs/:runId/restore-suspension`, async (req: any, res: any) => {
1581+
try {
1582+
const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/runs/${req.params.runId}/restore-suspension`, req.body, req.query, { request: req });
1583+
sendResult(result, res);
1584+
} catch (err: any) {
1585+
errorResponse(err, res);
1586+
}
1587+
});
1588+
15561589
server!.get(`${base}/automation/:name/runs/:runId/screen`, async (req: any, res: any) => {
15571590
try {
15581591
const result = await dispatcher.dispatch('GET', `/automation/${req.params.name}/runs/${req.params.runId}/screen`, undefined, req.query, { request: req });

0 commit comments

Comments
 (0)