From c67f66f93642dca18fb00f25eab63a1e44b136e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 03:02:47 +0000 Subject: [PATCH 1/4] feat(runtime): give the two operator run-lifecycle verbs a door `AutomationEngine`'s `cancelRun` (ADR-0044) and `restoreConsumedSuspension` (#13909) had no operator door: no REST route, no CLI command, and until `IAutomationService` declared them, no way for a contract-holding host to call them either. Add `POST /automation/:name/runs/:runId/cancel` and `POST /automation/:name/runs/:runId/restore-suspension`, behind the ADR-0095 `PLATFORM_ADMIN` rung, required unconditionally. Fail-closed on every axis the contract cannot close on its own: an absent optional member answers 501 rather than a 200, an unrecognised restore refusal code answers 500 rather than a 409 that would claim a diagnosis, and the cancel door's `false` names both readings instead of reading as a clean no-op. No once-only side effect keys off `cancelRun`'s non-exclusive `true`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../automation-run-lifecycle-operator-door.md | 15 + .../automation-run-lifecycle-door.test.ts | 574 +++++++++++++++++ packages/runtime/src/domains/automation.ts | 602 +++++++++++++++++- packages/runtime/src/route-ledger.ts | 4 + 4 files changed, 1194 insertions(+), 1 deletion(-) create mode 100644 .changeset/automation-run-lifecycle-operator-door.md create mode 100644 packages/runtime/src/domains/automation-run-lifecycle-door.test.ts diff --git a/.changeset/automation-run-lifecycle-operator-door.md b/.changeset/automation-run-lifecycle-operator-door.md new file mode 100644 index 0000000000..43fe91c10e --- /dev/null +++ b/.changeset/automation-run-lifecycle-operator-door.md @@ -0,0 +1,15 @@ +--- +"@objectstack/runtime": minor +--- + +The two operator run-lifecycle verbs get a door: `POST /automation/:name/runs/:runId/cancel` and `POST /automation/:name/runs/:runId/restore-suspension`, both gated on the platform operator. + +`AutomationEngine` has carried `cancelRun` (ADR-0044) and `restoreConsumedSuspension` (#13909) for as long as either has existed, and neither was reachable by an operator: no REST route, no CLI command, and — until `IAutomationService` declared them — no way for a host holding only the service contract to call them either. The gap mattered most for the repair verb, which has no in-process caller by design: it is meant to be asked for by a person, and there was no way to ask. Maintainer ruling, 2026-09-05 (option A): both are platform-operator verbs, and listing and acting go through `sys_automation_run`, never engine memory. + +- **The gate is the ADR-0095 D2/D3 posture rung** (`posture === 'PLATFORM_ADMIN'`), required **unconditionally**, with the usual `isSystem` bypass so `plugin-approvals`' in-process revise-window recall is untouched. It is deliberately not the `positions[]` entry spelling the built-in: `sys_user_position` is `apiEnabled` with unconstrained values, so a tenant can mint that row. It is also deliberately not posture-conditional the way the ADR-0126 §5 activation gate is — that gate falls open under `single` because a `manage_metadata` tier still stands in front of it, and this door has no tier in front of it, so the same conditionality would open an operator verb to any authenticated caller on every single-organization deployment. Which routes is one predicate, read by the gate and by both route arms so they cannot drift. +- **Refusing fail-closed on an absent member is the door's own job.** Both verbs are optional members of the contract, as 13 of its 15 are. A service that does not declare one answers **501 `NOT_IMPLEMENTED`** naming the member — never a 200 carrying a lifecycle verdict for a verb that was never dispatched, and never the `{ handled: false }` fall-through that renders as a 404 with a discovery hint for a route discovery does not list. +- **Refusals are refusals.** The restore door maps the implementation's refusal code onto the statuses this same door already answers those conditions with on `resume` — `RUN_NOT_FOUND` 404, `STORE_UNAVAILABLE` 503, and the run-state conflicts 409. The contract types that code as `refusal?: string`, a covariant widening of the engine's closed eight-member union, so the mapping is a **non-exhaustive string switch by construction**: an unrecognised code — or a refusal carrying none — answers **500**, not one of the 409s, which would claim a diagnosis the door did not make. The vocabulary is neither narrowed nor extended at the call site. The code rides `details.refusal`, never `details.code`, so `error.code` stays inside the ADR-0112 closed catalog. +- **`requestedBy` comes from the authenticated caller, never the wire.** The repair verb's trace records who asked and why; a wire-settable `requestedBy` would let one operator write another's name into the record of who re-armed a terminally-failed run. The body envelope is closed to `{ reason? }` and refuses the key by name, so a caller who tries gets a loud refusal instead of the silent impression that it took. +- **No once-only side effect keys off `cancelRun`'s return, and the wire says why.** The engine has no cancel-side compare-and-set, so two overlapping cancels each answer `true` and each record the terminal log. This door fires no notification, writes no audit entry and announces no kernel event; the `true` answer carries a notice stating the non-exclusivity so a caller does not build that side effect one tier up. The `false` answer carries the other half: the contract's idempotent-success reading **and** the unreadable-store reading, which land on the same `false` and which nothing above the engine can tell apart. + +⛔ No lister ships here. A lister backed by the engine's in-memory journal answers zero in any process that did not itself strand the run, and a confident zero is the failure this class is about; a correct one is a query over `sys_automation_run` terminal rows and is its own card. ⛔ No CLI command either — the ruling declines one for want of pull. diff --git a/packages/runtime/src/domains/automation-run-lifecycle-door.test.ts b/packages/runtime/src/domains/automation-run-lifecycle-door.test.ts new file mode 100644 index 0000000000..796553e240 --- /dev/null +++ b/packages/runtime/src/domains/automation-run-lifecycle-door.test.ts @@ -0,0 +1,574 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #13953 — the two operator run-lifecycle verbs get a door, and the door is + * fail-closed on every axis the contract cannot close on its own. + * + * `AutomationEngine` has carried `cancelRun` (ADR-0044) and + * `restoreConsumedSuspension` (#13909) for as long as either has existed, and + * until #16563 declared them on `IAutomationService` neither was reachable by + * an operator at all: no REST route, no CLI command, not on the contract. A + * deployment operator holding only HTTP could not cancel a suspended run, and + * could not repair a run a failed resume had stranded — the verb the platform's + * own `'stranded'` status names as the exit. + * + * Maintainer ruling, 2026-09-05 (option A): both are *"platform-operator verbs + * gated on the existing `platform_admin` position (no new permission type, no + * per-run ownership — a run belongs to the environment, not a user)"*. + * + * This file pins the five claims the door rests on. Each is a decision recorded + * on `domains/automation.ts`, and an untested decision is a comment. + * + * 1. **THE GATE** — the ADR-0095 posture RUNG, unconditionally, on both doors. + * Including the #15981 direction: a `positions[]` entry SPELLING the + * built-in is not the authority, because `sys_user_position` is `apiEnabled` + * with unconstrained values and a tenant can mint that row. + * 2. **AT LEAST AS STRICT AS `resume`** — the card's hard floor. The same + * caller `resume` lets through to the service is refused here, and nothing + * existing was relaxed to make the door usable. + * 3. **ABSENT-MEMBER FAIL-CLOSED** — both members are OPTIONAL on the + * contract, so probing for presence is this half's job: a service not + * declaring the verb must produce the refusal envelope, ⛔ never a 200 and + * ⛔ never the `{ handled: false }` fall-through. The whole fail-closed + * promise rests on this one. + * 4. **UNKNOWN REFUSAL CODES FAIL CLOSED** — the contract types the restore + * refusal as `refusal?: string`, a covariant widening of the engine's + * closed eight-member union, so the status mapping is a NON-EXHAUSTIVE + * string switch. An unrecognised code answers 500, ⛔ not a 409 (which + * would claim a diagnosis this door did not make) and ⛔ not a 200. + * 5. **NO ONCE-ONLY SIDE EFFECT KEYS OFF `cancelRun`'s RETURN** — the engine + * has no cancel-side compare-and-set, so two overlapping cancels each + * answer `true` and each record the terminal log. A notification or audit + * entry fired on `true` would fire twice; this door fires none, and says so + * on the wire. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { HttpDispatcher } from '../http-dispatcher.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; + +/** A refusal the engine's own vocabulary names, as the contract shapes it. */ +interface RestoreResult { + restored: boolean; + runId: string; + refusal?: string; + reason: string; +} + +interface Harness { + dispatcher: HttpDispatcher; + cancelRun: ReturnType; + restoreConsumedSuspension: ReturnType; + resume: ReturnType; + execute: ReturnType; + /** Every kernel event the request fired — the side-effect channel `deps.announceKernelEvent` uses. */ + kernelEvents: Array<{ event: string; payload: unknown }>; +} + +interface HarnessOptions { + /** What `cancelRun` answers; omit the member entirely with `'absent'`. */ + cancel?: boolean | 'absent'; + /** What `restoreConsumedSuspension` answers; omit the member with `'absent'`. */ + restore?: RestoreResult | 'absent'; +} + +function makeDispatcher(options: HarnessOptions = {}): Harness { + const kernelEvents: Array<{ event: string; payload: unknown }> = []; + const cancelRun = vi.fn(async () => (options.cancel === 'absent' ? false : options.cancel ?? true)); + const restoreConsumedSuspension = vi.fn(async () => ( + options.restore === 'absent' || options.restore === undefined + ? { restored: true, runId: 'run_7', reason: 'Suspension restored.' } + : options.restore + )); + const resume = vi.fn(async () => ({ success: true, runId: 'run_7' })); + const execute = vi.fn(async () => ({ success: true, runId: 'run_9' })); + + const automation: Record = { + handlerReady: true, + resume, + execute, + listFlows: async () => ['approval_flow'], + getFlow: async (name: string) => ({ name, nodes: [] }), + }; + if (options.cancel !== 'absent') automation.cancelRun = cancelRun; + if (options.restore !== 'absent') automation.restoreConsumedSuspension = restoreConsumedSuspension; + + const services: Record = { automation }; + const resolve = (name: string): unknown => services[name]; + const kernel = { + getService: resolve, + getServiceAsync: async (name: string) => resolve(name), + context: { + getService: resolve, + trigger: async (event: string, payload: unknown) => { kernelEvents.push({ event, payload }); }, + }, + }; + return { + dispatcher: new HttpDispatcher(kernel as never), + cancelRun, + restoreConsumedSuspension, + resume, + execute, + kernelEvents, + }; +} + +/** The platform operator — the ADR-0095 D2/D3 rung, derived from the unscoped `admin_full_access` grant. */ +const OPERATOR_CTX = (): HttpProtocolContext => + ({ request: {}, executionContext: { userId: 'usr_operator', posture: 'PLATFORM_ADMIN' } } as HttpProtocolContext); + +/** An ordinary authenticated caller. */ +const USER_CTX = (): HttpProtocolContext => + ({ request: {}, executionContext: { userId: 'user_1', positions: ['sales_rep'] } } as HttpProtocolContext); + +/** A tenant administrator — a rung, and the wrong one. */ +const TENANT_ADMIN_CTX = (): HttpProtocolContext => + ({ request: {}, executionContext: { userId: 'user_2', posture: 'TENANT_ADMIN' } } as HttpProtocolContext); + +/** + * #15981's caller: a tenant that MINTED a `sys_user_position` row spelling the + * built-in. `positions[]` carries it; the rung does not. + */ +const MINTED_POSITION_CTX = (): HttpProtocolContext => + ({ + request: {}, + executionContext: { userId: 'user_3', positions: ['platform_admin'], posture: 'TENANT_ADMIN' }, + } as HttpProtocolContext); + +/** Platform-internal invocation — the bypass every gate in this family carries. */ +const SYSTEM_CTX = (): HttpProtocolContext => + ({ request: {}, executionContext: { userId: 'usr_system', isSystem: true } } as HttpProtocolContext); + +const CANCEL_PATH = 'approval_flow/runs/run_7/cancel'; +const RESTORE_PATH = 'approval_flow/runs/run_7/restore-suspension'; + +const statusOf = (response: unknown): unknown => (response as any)?.status; +const payloadOf = (response: unknown): any => { + const r = response as any; + return r?.data ?? r?.body?.data ?? r; +}; +const codeOf = (response: unknown): unknown => { + const r = response as any; + return r?.body?.error?.code ?? r?.body?.error?.details?.code; +}; +const messageOf = (response: unknown): string => String((response as any)?.body?.error?.message ?? ''); +const detailsOf = (response: unknown): any => (response as any)?.body?.error?.details; + +describe('#13953 — the run-lifecycle doors require the platform operator', () => { + describe('the gate', () => { + for (const [label, ctx] of [ + ['an ordinary authenticated caller', USER_CTX], + ['a tenant administrator', TENANT_ADMIN_CTX], + ['a caller holding a MINTED `platform_admin` position but not the rung (#15981)', MINTED_POSITION_CTX], + ] as const) { + it(`refuses cancel for ${label} with PERMISSION_DENIED + 403, and never calls the verb`, async () => { + const h = makeDispatcher(); + const { response } = await h.dispatcher.handleAutomation( + CANCEL_PATH, 'POST', undefined, ctx(), undefined, + ); + + // ADR-0112 asserts BOTH halves: a 403 carrying a derived code, + // or a PERMISSION_DENIED riding a 200, each satisfy half. + expect(codeOf(response)).toBe('PERMISSION_DENIED'); + expect(statusOf(response)).toBe(403); + // The gate fires ahead of the service, so nothing was cancelled + // before the refusal — "cancel first, refuse second" is the + // worst shape a run-lifecycle door can have. + expect(h.cancelRun).not.toHaveBeenCalled(); + }); + + it(`refuses restore-suspension for ${label} the same way`, async () => { + const h = makeDispatcher(); + const { response } = await h.dispatcher.handleAutomation( + RESTORE_PATH, 'POST', undefined, ctx(), undefined, + ); + + expect(codeOf(response)).toBe('PERMISSION_DENIED'); + expect(statusOf(response)).toBe(403); + expect(h.restoreConsumedSuspension).not.toHaveBeenCalled(); + }); + } + + it('admits the ADR-0095 PLATFORM_ADMIN rung on both doors', async () => { + const h = makeDispatcher(); + const cancel = await h.dispatcher.handleAutomation(CANCEL_PATH, 'POST', undefined, OPERATOR_CTX(), undefined); + const restore = await h.dispatcher.handleAutomation(RESTORE_PATH, 'POST', undefined, OPERATOR_CTX(), undefined); + + expect(statusOf(cancel.response)).toBe(200); + expect(statusOf(restore.response)).toBe(200); + expect(h.cancelRun).toHaveBeenCalledTimes(1); + expect(h.restoreConsumedSuspension).toHaveBeenCalledTimes(1); + }); + + it('admits engine self-invocation (`isSystem`) — plugin-approvals\' in-process recall keeps working', async () => { + const h = makeDispatcher(); + const { response } = await h.dispatcher.handleAutomation( + CANCEL_PATH, 'POST', undefined, SYSTEM_CTX(), undefined, + ); + expect(statusOf(response)).toBe(200); + expect(h.cancelRun).toHaveBeenCalledTimes(1); + }); + + it('is UNCONDITIONAL — an authenticated caller with no posture at all is refused, not admitted', async () => { + // ⛔ The direction that matters. The ADR-0126 §5 activation gate + // falls open when it cannot read a posture, correctly, because a + // `manage_metadata` tier still stands in front of it. This door has + // no tier in front of it, so falling open would leave an operator + // verb open to any authenticated caller. + const h = makeDispatcher(); + const noPosture = { request: {}, executionContext: { userId: 'user_4' } } as HttpProtocolContext; + const { response } = await h.dispatcher.handleAutomation(CANCEL_PATH, 'POST', undefined, noPosture, undefined); + + expect(statusOf(response)).toBe(403); + expect(h.cancelRun).not.toHaveBeenCalled(); + }); + + it('does not answer the caller\'s authorization topology in the refusal (#7450)', async () => { + const h = makeDispatcher(); + const { response } = await h.dispatcher.handleAutomation( + CANCEL_PATH, 'POST', undefined, MINTED_POSITION_CTX(), undefined, + ); + const message = messageOf(response); + + // It names the standing that would admit ANY caller, and the + // sanctioned path a refused one does have. + expect(message).toMatch(/platform-operator standing/); + expect(message).toMatch(/resume/); + // …and nothing about THIS caller. + expect(message).not.toMatch(/user_3/); + expect(message).not.toMatch(/sales_rep/); + expect(message).not.toMatch(/TENANT_ADMIN/); + }); + + it('leaves the legacy execution door alone — `POST /automation/trigger/:name` for a flow named `runs`', async () => { + // The gate excludes `parts[0] === 'trigger'`, exactly as the toggle + // (#10243) and clone (#12156) arms do, and BOTH route arms repeat + // the exclusion so gate and route cannot drift. Over-blocking an + // execution door is the one thing the #10243 ruling did not do. + const h = makeDispatcher(); + const { response } = await h.dispatcher.handleAutomation( + 'trigger/runs/run_7/cancel', 'POST', undefined, USER_CTX(), undefined, + ); + + expect(statusOf(response)).not.toBe(403); + expect(h.execute).toHaveBeenCalledTimes(1); + expect(h.cancelRun).not.toHaveBeenCalled(); + }); + }); + + describe('at least as strict as `resume` — the card\'s hard floor', () => { + it('the same caller `resume` forwards to the service is refused at both lifecycle doors', async () => { + // `resume` has no route-level gate: it is fail-closed in the ENGINE + // on the suspended node's declared `resumeAuthority` (#3801/#5561), + // so an ordinary caller reaches the service and the engine decides. + // These doors refuse the same caller before the service is even + // resolved — strictly narrower, and nothing existing was relaxed to + // build them. + const h = makeDispatcher(); + const resumed = await h.dispatcher.handleAutomation( + 'approval_flow/runs/run_7/resume', 'POST', undefined, USER_CTX(), undefined, + ); + expect(h.resume).toHaveBeenCalledTimes(1); + expect(statusOf(resumed.response)).toBe(200); + + for (const path of [CANCEL_PATH, RESTORE_PATH]) { + const { response } = await h.dispatcher.handleAutomation(path, 'POST', undefined, USER_CTX(), undefined); + expect(statusOf(response), path).toBe(403); + } + expect(h.cancelRun).not.toHaveBeenCalled(); + expect(h.restoreConsumedSuspension).not.toHaveBeenCalled(); + }); + }); + + describe('absent member — fail-closed, ⛔ never a 200', () => { + it('a service not declaring `cancelRun` answers 501 NOT_IMPLEMENTED, and cancels nothing', async () => { + const h = makeDispatcher({ cancel: 'absent' }); + const { handled, response } = await h.dispatcher.handleAutomation( + CANCEL_PATH, 'POST', undefined, OPERATOR_CTX(), undefined, + ); + + expect(statusOf(response)).toBe(501); + expect(codeOf(response)).toBe('NOT_IMPLEMENTED'); + // ⛔ Never the `{ handled: false }` fall-through, which the + // dispatcher renders as 404 ROUTE_NOT_FOUND with a discovery hint — + // both halves false here, and an operator reads a routing bug that + // does not exist. + expect(handled).toBe(true); + // ⛔ And never a success envelope carrying a lifecycle verdict for a + // verb that was never dispatched. + expect(statusOf(response)).not.toBe(200); + expect(payloadOf(response)?.cancelled).toBeUndefined(); + expect(messageOf(response)).toMatch(/cancelRun/); + }); + + it('a service not declaring `restoreConsumedSuspension` answers 501 the same way', async () => { + const h = makeDispatcher({ restore: 'absent' }); + const { handled, response } = await h.dispatcher.handleAutomation( + RESTORE_PATH, 'POST', undefined, OPERATOR_CTX(), undefined, + ); + + expect(statusOf(response)).toBe(501); + expect(codeOf(response)).toBe('NOT_IMPLEMENTED'); + expect(handled).toBe(true); + expect(statusOf(response)).not.toBe(200); + expect(payloadOf(response)?.restored).toBeUndefined(); + expect(messageOf(response)).toMatch(/restoreConsumedSuspension/); + }); + + it('the absent-member refusal is reached only AFTER the permission gate', async () => { + // A caller without the rung must not be able to fingerprint which + // members this deployment's automation service implements. + const h = makeDispatcher({ cancel: 'absent', restore: 'absent' }); + for (const path of [CANCEL_PATH, RESTORE_PATH]) { + const { response } = await h.dispatcher.handleAutomation(path, 'POST', undefined, USER_CTX(), undefined); + expect(statusOf(response), path).toBe(403); + } + }); + }); + + describe('restore refusals — every code answered as a refusal, unknown ones fail closed', () => { + const KNOWN: ReadonlyArray = [ + ['RUN_NOT_FOUND', 404], + ['STORE_UNAVAILABLE', 503], + ['RESUME_IN_PROGRESS', 409], + ['RESTORE_IN_PROGRESS', 409], + ['RUN_SUSPENDED', 409], + ['RUN_COMPLETED', 409], + ['RUN_CANCELLED', 409], + ['NO_CONSUMED_SUSPENSION', 409], + ]; + + for (const [refusal, status] of KNOWN) { + it(`maps ${refusal} → ${status}, relaying the engine's own sentence`, async () => { + const h = makeDispatcher({ + restore: { restored: false, runId: 'run_7', refusal, reason: `Observed: ${refusal}.` }, + }); + const { response } = await h.dispatcher.handleAutomation( + RESTORE_PATH, 'POST', undefined, OPERATOR_CTX(), undefined, + ); + + expect(statusOf(response)).toBe(status); + expect(messageOf(response)).toBe(`Observed: ${refusal}.`); + expect(detailsOf(response)?.refusal).toBe(refusal); + expect(detailsOf(response)?.restored).toBe(false); + // ⛔ Never a 200 carrying `restored: false`, which reads as + // "your repair ran and the run did not come back". + expect(statusOf(response)).not.toBe(200); + }); + } + + it('an UNRECOGNISED refusal code answers 500 — ⛔ not a 409, ⛔ not a 200', async () => { + // The contract types `refusal` as `string`, a covariant widening of + // the engine's closed union, so this switch is non-exhaustive BY + // CONSTRUCTION. A 409 would claim a diagnosis this door did not + // make ("the run's state refuses this; retrying will not help"). + const h = makeDispatcher({ + restore: { + restored: false, + runId: 'run_7', + refusal: 'SOME_FUTURE_REFUSAL', + reason: 'A newer implementation refused for a reason of its own.', + }, + }); + const { response } = await h.dispatcher.handleAutomation( + RESTORE_PATH, 'POST', undefined, OPERATOR_CTX(), undefined, + ); + + expect(statusOf(response)).toBe(500); + expect(statusOf(response)).not.toBe(409); + expect(detailsOf(response)?.refusal).toBe('SOME_FUTURE_REFUSAL'); + }); + + it('a `restored: false` carrying NO refusal code fails closed too', async () => { + const h = makeDispatcher({ + restore: { restored: false, runId: 'run_7', reason: 'Refused.' } as RestoreResult, + }); + const { response } = await h.dispatcher.handleAutomation( + RESTORE_PATH, 'POST', undefined, OPERATOR_CTX(), undefined, + ); + expect(statusOf(response)).toBe(500); + }); + + it('a malformed result — not an object at all — fails closed rather than reading as success', async () => { + const h = makeDispatcher({ restore: undefined as unknown as RestoreResult }); + h.restoreConsumedSuspension.mockResolvedValue(undefined as never); + const { response } = await h.dispatcher.handleAutomation( + RESTORE_PATH, 'POST', undefined, OPERATOR_CTX(), undefined, + ); + expect(statusOf(response)).toBe(500); + expect(messageOf(response)).toMatch(/Nothing has been established/); + }); + + it('⛔ never promotes an engine refusal code into ADR-0112\'s closed `error.code`', async () => { + // `details.code` is PROMOTED into `error.code`, which ADR-0112 + // closes to `StandardErrorCode` ∪ the registered ledger — and none + // of the engine's eight are members. The code rides + // `details.refusal` instead and `error.code` derives from the + // status. + const h = makeDispatcher({ + restore: { restored: false, runId: 'run_7', refusal: 'RUN_COMPLETED', reason: 'The run finished.' }, + }); + const { response } = await h.dispatcher.handleAutomation( + RESTORE_PATH, 'POST', undefined, OPERATOR_CTX(), undefined, + ); + expect(codeOf(response)).not.toBe('RUN_COMPLETED'); + expect(codeOf(response)).toBe('RESOURCE_CONFLICT'); + }); + + it('a successful restore answers 200 with the run id this door was ASKED about', async () => { + const h = makeDispatcher({ + restore: { restored: true, runId: 'a-different-id', reason: 'Suspension restored at node `stage1`.' }, + }); + const { response } = await h.dispatcher.handleAutomation( + RESTORE_PATH, 'POST', undefined, OPERATOR_CTX(), undefined, + ); + + expect(statusOf(response)).toBe(200); + expect(payloadOf(response)).toEqual({ + runId: 'run_7', + restored: true, + reason: 'Suspension restored at node `stage1`.', + }); + }); + }); + + describe('who asked, and why', () => { + it('fills `requestedBy` from the AUTHENTICATED CALLER, never from the body', async () => { + const h = makeDispatcher(); + await h.dispatcher.handleAutomation( + RESTORE_PATH, 'POST', { reason: 'Storage outage recovered; re-arming.' }, OPERATOR_CTX(), undefined, + ); + + expect(h.restoreConsumedSuspension).toHaveBeenCalledWith('run_7', { + requestedBy: 'usr_operator', + reason: 'Storage outage recovered; re-arming.', + }); + }); + + it('refuses a wire-supplied `requestedBy` BY NAME rather than dropping it silently', async () => { + const h = makeDispatcher(); + const { response } = await h.dispatcher.handleAutomation( + RESTORE_PATH, 'POST', { requestedBy: 'somebody_else' }, OPERATOR_CTX(), undefined, + ); + + expect(statusOf(response)).not.toBe(200); + expect(messageOf(response)).toMatch(/requestedBy/); + expect(messageOf(response)).toMatch(/not settable from the wire/); + expect(h.restoreConsumedSuspension).not.toHaveBeenCalled(); + }); + + it('omits `requestedBy` entirely when the caller carries no user id', async () => { + const h = makeDispatcher(); + await h.dispatcher.handleAutomation( + RESTORE_PATH, 'POST', { reason: 'boot repair' }, + { request: {}, executionContext: { isSystem: true } } as HttpProtocolContext, undefined, + ); + expect(h.restoreConsumedSuspension).toHaveBeenCalledWith('run_7', { reason: 'boot repair' }); + }); + + it('relays the cancel `reason` VERBATIM — the contract calls it the operator\'s own words', async () => { + const h = makeDispatcher(); + await h.dispatcher.handleAutomation( + CANCEL_PATH, 'POST', { reason: 'Submitter withdrew the request.' }, OPERATOR_CTX(), undefined, + ); + expect(h.cancelRun).toHaveBeenCalledWith('run_7', 'Submitter withdrew the request.'); + }); + + it('closes the body envelope on both doors', async () => { + const h = makeDispatcher(); + for (const path of [CANCEL_PATH, RESTORE_PATH]) { + const unknownKey = await h.dispatcher.handleAutomation( + path, 'POST', { resaon: 'typo' }, OPERATOR_CTX(), undefined, + ); + expect(statusOf(unknownKey.response), path).not.toBe(200); + expect(messageOf(unknownKey.response), path).toMatch(/resaon/); + + const wrongType = await h.dispatcher.handleAutomation( + path, 'POST', { reason: 42 }, OPERATOR_CTX(), undefined, + ); + expect(statusOf(wrongType.response), path).not.toBe(200); + + const notAnObject = await h.dispatcher.handleAutomation( + path, 'POST', 'just a string', OPERATOR_CTX(), undefined, + ); + expect(statusOf(notAnObject.response), path).not.toBe(200); + } + expect(h.cancelRun).not.toHaveBeenCalled(); + expect(h.restoreConsumedSuspension).not.toHaveBeenCalled(); + }); + + it('accepts a bodyless call on both doors', async () => { + const h = makeDispatcher(); + const cancel = await h.dispatcher.handleAutomation(CANCEL_PATH, 'POST', undefined, OPERATOR_CTX(), undefined); + const restore = await h.dispatcher.handleAutomation(RESTORE_PATH, 'POST', undefined, OPERATOR_CTX(), undefined); + expect(statusOf(cancel.response)).toBe(200); + expect(statusOf(restore.response)).toBe(200); + expect(h.cancelRun).toHaveBeenCalledWith('run_7', undefined); + expect(h.restoreConsumedSuspension).toHaveBeenCalledWith('run_7', { requestedBy: 'usr_operator' }); + }); + }); + + describe('⛔ no once-only side effect keys off `cancelRun`\'s return value', () => { + it('two overlapping cancels each answering `true` produce two identical answers and NO side effect', async () => { + // The engine has no cancel-side compare-and-set: `cancelRun` does a + // `loadSuspendedRunStrict` then an unconditional delete-by-id, and + // only `resume` passes the `claimAdvance` compare-and-set through + // `forgetSuspendedRun`. So both cancels answer `true` and both + // record the terminal log. A notification or audit entry fired on + // `true` would fire TWICE — this door fires none. + const h = makeDispatcher({ cancel: true }); + const first = await h.dispatcher.handleAutomation(CANCEL_PATH, 'POST', undefined, OPERATOR_CTX(), undefined); + const second = await h.dispatcher.handleAutomation(CANCEL_PATH, 'POST', undefined, OPERATOR_CTX(), undefined); + + expect(h.cancelRun).toHaveBeenCalledTimes(2); + // Byte-identical answers: nothing accumulated, nothing was + // "already done". + expect(payloadOf(second.response)).toEqual(payloadOf(first.response)); + // The side-effect channel this domain has — `deps.announceKernelEvent`, + // which the packages domain uses to announce `metadata:reloaded` — + // was never touched. + expect(h.kernelEvents).toEqual([]); + }); + + it('the `true` answer SAYS it is not exclusive, so a caller does not build the side effect one tier up', async () => { + const h = makeDispatcher({ cancel: true }); + const { response } = await h.dispatcher.handleAutomation( + CANCEL_PATH, 'POST', undefined, OPERATOR_CTX(), undefined, + ); + + expect(statusOf(response)).toBe(200); + expect(payloadOf(response).cancelled).toBe(true); + expect(payloadOf(response).notice).toMatch(/not exclusive/); + expect(payloadOf(response).notice).toMatch(/idempotency token/); + }); + }); + + describe('⛔ never a success that hides the condition (#13909\'s posture)', () => { + it('`cancelled: false` is a 200 per the contract, but names BOTH readings', async () => { + // The contract's `false` is "no suspended run under the id — + // idempotent success". An UNREADABLE durable store lands on the + // same `false`, and then the run may still be parked. Nothing above + // the engine can tell them apart, so the door says so instead of + // letting a bare `cancelled: false` read as a clean no-op. + const h = makeDispatcher({ cancel: false }); + const { response } = await h.dispatcher.handleAutomation( + CANCEL_PATH, 'POST', undefined, OPERATOR_CTX(), undefined, + ); + + expect(statusOf(response)).toBe(200); + expect(payloadOf(response).cancelled).toBe(false); + expect(payloadOf(response).notice).toMatch(/already terminal or unknown/); + expect(payloadOf(response).notice).toMatch(/could not be\s+read/); + expect(payloadOf(response).notice).toMatch(/may still be parked/); + }); + + it('the two `cancelled` arms carry DIFFERENT notices — the condition is never collapsed', async () => { + const yes = makeDispatcher({ cancel: true }); + const no = makeDispatcher({ cancel: false }); + const a = await yes.dispatcher.handleAutomation(CANCEL_PATH, 'POST', undefined, OPERATOR_CTX(), undefined); + const b = await no.dispatcher.handleAutomation(CANCEL_PATH, 'POST', undefined, OPERATOR_CTX(), undefined); + expect(payloadOf(a.response).notice).not.toBe(payloadOf(b.response).notice); + }); + }); +}); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 4f5478e39d..803bd86097 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -3,7 +3,8 @@ /** * `/automation` domain — extracted dispatcher body (ADR-0076 D11 step ③, * PR-6). Bridges to the `automation` service (flow CRUD, trigger/execute, - * runs history, pause/resume — ADR-0018/0019/0022 surfaces). Route-order + * runs history, pause/resume, and the two operator run-lifecycle verbs + * (cancel / restore-suspension, #13953) — ADR-0018/0019/0022 surfaces). Route-order * subtlety preserved verbatim: `/actions`, `/connectors` and `/_status` * MUST precede the `/:name → getFlow` catch-all, or a flow literally named * "actions"/"connectors" would shadow them. @@ -558,6 +559,425 @@ function refuseUngrantedFlowWrite( }; } +/** [#13953] The path segment naming the cancel door (ADR-0044's operator verb). */ +const RUN_CANCEL_SEGMENT = 'cancel'; +/** [#13953] The path segment naming the repair door (#13909's operator verb). */ +const RUN_RESTORE_SEGMENT = 'restore-suspension'; + +/** + * [#13953] The two OPERATOR RUN-LIFECYCLE doors — `POST /:name/runs/:runId/cancel` + * and `POST /:name/runs/:runId/restore-suspension`. + * + * Declared as ONE predicate for the reason {@link isRunStateRead} and + * {@link isFlowAuthoringWrite} are one predicate each: this domain gets one + * policy per data class, and a policy spelled at two call sites is two + * policies that happen to agree today. It is read TWICE — once by the gate + * below to decide the route is gated at all, once by each route arm to decide + * the arm fires — so the gate and the routes it guards cannot drift apart. A + * gate narrower than its route is a bypass; a gate wider than its route is an + * over-block. + * + * ⛔ `parts[0] === 'trigger'` is excluded, exactly as the toggle (#10243) and + * clone (#12156) arms exclude it, and the ROUTE ARMS carry the same exclusion + * so the two spellings stay byte-identical. `POST /automation/trigger/:name` + * is the LEGACY EXECUTION door, answered ABOVE the flow-scoped block, so for a + * flow literally named `runs` the path `/automation/trigger/runs/x/cancel` + * RUNS that flow. Gating it would over-block an execution door — the one thing + * the #10243 ruling did not do — and dispatching a cancel from it would be the + * mirror bypass. + * + * No upper bound on depth, for the reason the toggle arm documents: the arms + * below test `parts[3]` with no length check, so a predicate spelled + * `parts.length === 4` would leave `/…/cancel/anything` reaching the route + * with no gate in front of it. + */ +function isRunLifecycleWrite(parts: string[], method: string): boolean { + if (method !== 'POST') return false; + if (parts[0] === 'trigger') return false; + if (parts[1] !== 'runs' || !parts[2]) return false; + return parts[3] === RUN_CANCEL_SEGMENT || parts[3] === RUN_RESTORE_SEGMENT; +} + +/** [#13953] Refusal vocabulary for the run-lifecycle operator gate (ADR-0112: code AND status). */ +const RUN_LIFECYCLE_DENY_STATUS = 403; +const RUN_LIFECYCLE_DENY_CODE = 'PERMISSION_DENIED'; + +/** + * [#13953] The refusal sentence. It names the standing that would admit ANY + * caller and nothing about this one (#7450), and — like every refusal in the + * ADR-0126 §7 family — it names the sanctioned path a refused caller does + * have, because the commonest reason to arrive here is an end user trying to + * get their OWN paused run moving again, for which `resume` is the door. + */ +const RUN_LIFECYCLE_DENY_MESSAGE = + 'Cancelling an automation run, or restoring a consumed suspension, is a platform-operator verb: it ends or ' + + 're-arms a run for the whole environment, and a run belongs to the environment rather than to a user. It ' + + 'requires platform-operator standing (the unscoped `admin_full_access` grant, ADR-0068 D2). Resuming a run ' + + 'you are the declared authority for is a different question and stays open to you at ' + + '`POST /automation/:name/runs/:runId/resume`.'; + +/** + * [#13953] THE RUN-LIFECYCLE GATE: the platform operator, and only the + * platform operator. + * + * ## Why this is a THIRD policy on this domain rather than an arm of an + * existing one + * + * The card's own words are the reason: *"a repair verb re-arms a run the + * platform recorded as terminally failed, so 'who may do this' is a real + * question and not the same answer as 'who may resume'"*. Neither existing + * predicate answers it: + * + * - {@link isRunStateRead} / {@link refuseUngrantedRunRead} govern READS of + * `sys_automation_run`-class data. These verbs WRITE run lifecycle; the + * grant that lets support tooling look at a run is not the authority to end + * one or to re-arm one. + * - {@link isFlowAuthoringWrite} governs the flow DEFINITION (`manage_metadata`, + * the metadata plane). A run is not a definition, and the #10145 comment + * says in as many words why the execution surfaces are deliberately outside + * that set — sweeping a run surface into a metadata gate locks every + * ordinary user out of the flows built for them. + * + * ## The authority, and why it is spelled as the RUNG + * + * Maintainer ruling, 2026-09-05 (the #13953 fork, option A): both verbs are + * *"platform-operator verbs gated on the existing `platform_admin` position + * (no new permission type, no per-run ownership — a run belongs to the + * environment, not a user)"*. + * + * ⛔ [#15981] What that is READ as is the ADR-0095 D2/D3 posture RUNG + * (`posture === 'PLATFORM_ADMIN'`), NEVER + * `positions.includes('platform_admin')` — the same correction + * `./activation-gate.ts` carries, made here at birth rather than after a + * measurement. `positions[]` also carries ADR-0057 D4 `sys_user_position` + * names, and that table is `apiEnabled` with unconstrained `position` values, + * so a tenant can mint a row spelling the built-in and + * `resolveUserAuthzGrants` §4 pushes it onto the array. The rung is derived + * from the unscoped `admin_full_access` evidence and nothing else, so it is + * what the ruling MEANT, and it is byte-for-byte what + * `hasPlatformAdminStanding` returns. + * + * ## ⛔ Why it is NOT posture-conditional the way the activation gate is + * + * `refuseUngrantedActivationWrite` requires the operator only under + * `group`/`isolated`, and falls open under `single` — correctly, because a + * capability tier (`manage_metadata`) still gates it there, so `single` is not + * an ungated deployment. This door has no such tier in front of it, so the + * same conditionality would leave the two verbs open to any authenticated + * caller on every single-organization deployment. That is LOOSER than + * `resume`, which is fail-closed on the suspended node's declared + * `resumeAuthority` (#3801 / #5561) on every deployment, and the card's floor + * is that this door is at least as strict as `resume`'s. So the rung is + * required unconditionally. + * + * ## The two non-denials, each of which is a decision + * + * 1. **System context passes** (`isSystem`, never settable from the wire) — as + * at every neighbouring gate in this file and in `./activation-gate.ts`. The + * in-process owner the contract names, `plugin-approvals`' revise-window + * recall (ADR-0044), cancels on behalf of a decision it already authorized + * and recorded; it does not speak HTTP and never enters this handler. + * 2. **Nothing else passes.** An absent `executionContext`, an absent + * `posture`, or any other rung all fall through to the refusal. A + * deployment with no authorization system resolves no rung, so it has no + * platform operator to name — and answering an operator verb there would be + * inventing one. That direction is deliberate and it is the fail-closed + * one; the #5519 anonymous floor answers an unidentified caller 401 before + * this gate is reached at all. + * + * Returns a refusal to short-circuit on, `undefined` to proceed — the shape + * every gate in this family uses, so no route can consume a denial as a value. + * + * ⚠️ Callers MUST run this BEFORE the automation service is resolved and + * before any body validation, for the reasons {@link refuseUngrantedFlowWrite} + * documents: an unentitled caller must not learn from a 501-vs-403 whether + * this deployment mounts automation, nothing may be cancelled or re-armed + * before the refusal, and the body contract must not be enumerable by probing + * validation errors from outside the operator cohort. + * + * Synchronous: the rung rides the caller's own execution context, so nothing + * is resolved and no outage class exists here to absorb. + */ +function refuseUngrantedRunLifecycleWrite( + deps: DomainHandlerDeps, + context: HttpProtocolContext, +): HttpDispatcherResult | undefined { + const ec: any = context?.executionContext; + if (ec?.isSystem) return undefined; + if (ec?.posture === 'PLATFORM_ADMIN') return undefined; + + return { + handled: true, + response: deps.error(RUN_LIFECYCLE_DENY_MESSAGE, RUN_LIFECYCLE_DENY_STATUS, { + code: RUN_LIFECYCLE_DENY_CODE, + }), + }; +} + +/** + * [#13953] The CLOSED body envelope both lifecycle doors accept — exactly one + * optional key, `reason`. + * + * Shaped on the resume door's own envelope discipline (#8796 / #9416), for the + * same reason and with the same three refusals: the body itself must be a JSON + * object (a string / number / boolean / array body used to normalise to `{}` + * there and reach the engine as an empty signal, answered 200), an unknown + * top-level key is refused rather than dropped, and an accepted key carrying + * the wrong TYPE is refused rather than coerced. + * + * ⛔ `requestedBy` is deliberately NOT an accepted key, and refusing it is the + * point rather than an omission. The contract slot exists — `restoreConsumedSuspension` + * takes `options.requestedBy` and the implementation's trace records it — but a + * door that let the WIRE fill it would let an operator write somebody else's + * name into the record of who re-armed a terminally-failed run, which is the + * one field that record exists for. The door fills it from the caller's own + * authenticated identity instead (see the route arm), so a caller who spells it + * in the body gets a loud refusal rather than the silent impression that they + * set it. + * + * Returns `undefined` when the body is acceptable; a `HttpDispatcherResult` to + * short-circuit on otherwise — the guard-clause shape every refusal in this + * file uses. + */ +function refuseInvalidRunLifecycleBody( + deps: DomainHandlerDeps, + rawBody: unknown, + door: string, +): HttpDispatcherResult | undefined { + /** + * How the offending value is NAMED back to the caller. A second copy of + * the resume arm's one-liner rather than a hoist of it, DELIBERATELY: that + * arm's own note records the decision that it stays local to the arm it + * serves — *"it exists to make one refusal message readable, not to become + * a shared formatter for a vocabulary nobody has ruled on"* — and hoisting + * it here would overturn that decision as a side effect of adding a route. + */ + const jsonTypeOf = (v: unknown): string => + v === null ? 'null' : Array.isArray(v) ? 'an array' : `a ${typeof v}`; + + if (rawBody === undefined || rawBody === null) return undefined; + if (typeof rawBody !== 'object' || Array.isArray(rawBody)) { + return { + handled: true, + response: deps.errorFromThrown( + validationFailure( + `Invalid ${door} body — expected an object with an optional \`reason\`, received ` + + `${jsonTypeOf(rawBody)}`, + [{ field: '(body)', code: 'invalid_type', message: 'expected an object' }], + ), + VALIDATION_FAILED_STATUS, + ), + }; + } + + const unknownKeys = Object.keys(rawBody as Record).filter((k) => k !== 'reason'); + if (unknownKeys.length) { + return { + handled: true, + response: deps.errorFromThrown( + validationFailure( + `Unknown key${unknownKeys.length > 1 ? 's' : ''} ` + + `${unknownKeys.map((k) => `\`${k}\``).join(', ')} — the ${door} body accepts \`reason\`` + + (unknownKeys.includes('requestedBy') + ? '; `requestedBy` is filled from the authenticated caller and is not settable from the wire' + : ''), + unknownKeys.map((k) => ({ + field: k, + code: 'unrecognized_keys' as const, + message: `not a ${door} body key — the ${door} body accepts \`reason\``, + })), + ), + VALIDATION_FAILED_STATUS, + ), + }; + } + + const reason = (rawBody as { reason?: unknown }).reason; + if (reason !== undefined && typeof reason !== 'string') { + return { + handled: true, + response: deps.errorFromThrown( + validationFailure( + `Invalid ${door} body — \`reason\` must be a string, received ${jsonTypeOf(reason)}`, + [{ field: 'reason', code: 'invalid_type', message: 'expected a string' }], + ), + VALIDATION_FAILED_STATUS, + ), + }; + } + + return undefined; +} + +/** + * [#13953] The refusal-code → HTTP-status table for + * `restoreConsumedSuspension`, and the fail-closed answer for everything that + * is not in it. + * + * ⚠️ THIS SWITCH IS NON-EXHAUSTIVE BY CONSTRUCTION, and that is a property of + * the contract rather than a gap here. `IAutomationService` types the refusal + * as `refusal?: string` — a deliberate COVARIANT WIDENING of the engine's own + * closed eight-member `SuspensionRestoreRefusal` union (#16495 route (i)): the + * contract declines to keep an enumeration in step with an implementation's + * vocabulary, and the wider engine type satisfies the narrower contract one + * under `implements`. So this door is reading a `string` and any implementation + * may answer a code that did not exist when this table was written. + * + * ⛔ The vocabulary is NOT narrowed or extended here. Closing it is a + * `packages/spec` card; a call site that widened it would be exactly the + * "second consumer that needs the vocabulary itself" the contract's own + * docblock rules out. + * + * The eight rows are the engine's, mapped onto the statuses this same door + * already uses for the same conditions on `resume` — so one deployment cannot + * answer `RUN_NOT_FOUND` two ways depending on which verb asked: + * + * `RUN_NOT_FOUND` → 404, no record of the run at all (resume: 404) + * `STORE_UNAVAILABLE` → 503, the store is unreadable so existence is + * UNKNOWN and the same call is expected to work + * once it recovers (resume: 503) + * `RESUME_IN_PROGRESS` → 409, a resume holds this run right now + * (resume: 409) + * `RESTORE_IN_PROGRESS` → 409, a restore holds it — the same class + * `RUN_SUSPENDED` → 409, a live suspension already exists, so the + * run is resumable and there is nothing to repair + * `RUN_COMPLETED` → 409, the run finished + * `RUN_CANCELLED` → 409, somebody ended it on purpose (ADR-0044) + * `NO_CONSUMED_SUSPENSION` → 409, the run exists and holds no consumed + * suspension to put back + * + * The five 409s are one class stated five ways: the run's OWN STATE refuses + * the repair, the request was well-formed, and retrying it unchanged will + * answer the same. They are not collapsed at the source — the engine's + * `reason` sentence, which this door relays verbatim, is what tells "this run + * is fine" from "this run is beyond this verb", and the code itself rides + * `details.refusal`. + */ +const RESTORE_REFUSAL_STATUS: Readonly> = Object.freeze({ + RUN_NOT_FOUND: 404, + STORE_UNAVAILABLE: 503, + RESUME_IN_PROGRESS: 409, + RESTORE_IN_PROGRESS: 409, + RUN_SUSPENDED: 409, + RUN_COMPLETED: 409, + RUN_CANCELLED: 409, + NO_CONSUMED_SUSPENSION: 409, +}); + +/** + * [#13953] The fail-closed status for a refusal this door cannot classify — + * an unrecognised code, or a `restored: false` carrying no code at all. + * + * ⛔ NOT one of the 409s, and the choice is the whole point of the arm. A 409 + * would CLAIM a diagnosis this door did not make ("the run's state refuses + * this, retrying will not help"), and a caller — or an agent — reading that + * would stop, believing the platform had answered them. 500 says the true + * thing: the implementation refused, and this door does not know what it + * refused with, so nothing about the run's state has been established here. + * The refusal itself is never absorbed into a 200 either way, which is + * #13909's posture — ⛔ never a door that returns success while hiding the + * condition. + */ +const RESTORE_REFUSAL_UNKNOWN_STATUS = 500; + +/** + * [#13953] The ABSENT-MEMBER refusals — the half of the fail-closed promise + * the contract cannot keep on its own. + * + * Both verbs are OPTIONAL members of `IAutomationService` (the house + * convention: 13 of its 15 members are), and that is deliberate — cancelling + * or repairing a suspension is a capability of the flow-engine implementation, + * exactly like `resume`, and a script-runner slot never suspends and has + * nothing to cancel. The contract states the consequence and hands this door + * the job: *"A service that does not declare this member has NO operator door + * for it: a door MUST probe for presence and refuse fail-closed when it is + * absent — never answer success for a verb it could not dispatch."* + * + * ⇒ 501, in the shape `resume` already uses one arm up (`'Resume not + * supported'`), and ⛔ never `{ handled: false }`. The difference matters and + * is `./unavailable.ts`'s whole subject: a fall-through becomes the + * dispatcher's `404 ROUTE_NOT_FOUND` with the hint "check the API discovery + * endpoint", both halves of which are false here — a handler DID match, and + * discovery does not list the route — so an operator reads a routing bug that + * does not exist. `error.code` derives from the 501 as `NOT_IMPLEMENTED` + * (ADR-0112), which is the accurate one: the route is mounted, the + * implementation behind it is not. + * + * ⛔ And never a 200. That is the pin the whole fail-closed promise rests on: + * a door that answered `{ cancelled: false }` or `{ restored: false }` for a + * verb it never dispatched would be #13909's exact failure — success hiding + * the condition — and it would be indistinguishable, on the wire, from a real + * engine answering about a run it could not find. + */ +const RUN_CANCEL_UNSUPPORTED_MESSAGE = + 'Cancelling a run is not supported by the automation service this deployment mounts — it does not implement ' + + '`cancelRun`, an optional member of `IAutomationService`. No run was cancelled.'; +const RUN_RESTORE_UNSUPPORTED_MESSAGE = + 'Restoring a consumed suspension is not supported by the automation service this deployment mounts — it does ' + + 'not implement `restoreConsumedSuspension`, an optional member of `IAutomationService`. No suspension was ' + + 'restored.'; + +/** + * [#13953] The message for a refusal this door could not classify — no + * `reason` came back, so there is nothing of the implementation's to relay. + * Says what was established (nothing) rather than guessing at the run's state. + */ +const RUN_RESTORE_UNCLASSIFIED_MESSAGE = + 'The automation service refused to restore this run\'s consumed suspension and reported no reason this door ' + + 'recognises. Nothing has been established about the run\'s state, and no suspension was restored.'; + +/** + * [#13953] What the cancel door says on `true`. + * + * ⚠️ It exists to keep the non-exclusivity of `true` from being invisible on + * the wire. The engine has no cancel-side compare-and-set, so two overlapping + * cancels of one run can each answer `true` and each write the terminal log. + * This door keys nothing off it — but the door is not the last consumer, and a + * caller who reads a bare `cancelled: true` as "I, uniquely, ended this run" + * will build the once-only side effect the contract warns against one tier up + * instead. Saying it here costs one string. + */ +const RUN_CANCEL_TRUE_NOTICE = + 'A suspended run was cancelled and a terminal `cancelled` log recorded. ⚠️ This answer is not exclusive to ' + + 'this call: overlapping cancels of one run can each answer `true` and each record the terminal log, so do ' + + 'not use it as an idempotency token for a once-only side effect.'; + +/** + * [#13953] What the cancel door says on `false` — the two readings, both of + * them, because nothing above the engine can tell them apart. + * + * The contract's `false` is "no suspended run exists under the id, which + * callers treat as idempotent success". But an UNREADABLE durable store lands + * on the same `false`, and then the run may still be parked and resumable. The + * engine reports that path at `error` precisely because the caller cannot see + * it. A door that answered a bare `cancelled: false` would be reporting a + * clean idempotent no-op for a case where nothing is known — success hiding + * the condition, which is what #13909 exists to name. + */ +const RUN_CANCEL_FALSE_NOTICE = + 'No suspended run was cancelled. ⚠️ Two conditions answer this way and the platform cannot tell them apart ' + + 'from here: the run is already terminal or unknown (idempotent success), OR the durable store could not be ' + + 'read, in which case the run may still be parked and resumable — the implementation reports that second ' + + 'case in its own logs at `error`. Confirm the run\'s state before treating this as done.'; + +/** + * [#13953] Read the status for a refusal code, fail-closed. + * + * `Object.prototype.hasOwnProperty` rather than a bare index read, because the + * code is a `string` off the wire-facing contract and a lookup of + * `'constructor'` or `'__proto__'` on a plain object literal answers a + * FUNCTION, which would then be spread into an HTTP status. The table is + * frozen and null-prototype-free, so the own-property test is what makes the + * read total. + */ +function restoreRefusalStatus(refusal: unknown): number { + if (typeof refusal !== 'string') return RESTORE_REFUSAL_UNKNOWN_STATUS; + if (!Object.prototype.hasOwnProperty.call(RESTORE_REFUSAL_STATUS, refusal)) { + return RESTORE_REFUSAL_UNKNOWN_STATUS; + } + return RESTORE_REFUSAL_STATUS[refusal]; +} + /** * [#7968] The screen route's gate: **the run's own trigger identity, OR the * `sys_automation_run` read grant as an operator override.** @@ -1048,6 +1468,20 @@ function resumeFailureDetails(runId: string, result: AutomationResult): ResumeFa * `FLOW_FAILED` whose details carry the engine's * verdict — `status: 'stranded'` + `repairable` — * beside `errorMessage` / `summary`, #15221) + * POST /:name/runs/:runId/cancel → cancel a suspended run (ADR-0044, + * #13953). Body `{ reason? }`, closed. Answers + * 200 `{ runId, cancelled, notice }` both ways — + * `false` is idempotent success AND an + * unreadable store, and the notice says so + * ⚑ operator verb — the ADR-0095 PLATFORM_ADMIN + * rung, unconditionally (#13953) + * POST /:name/runs/:runId/restore-suspension → put back the suspension a + * failed resume consumed (#13909, #13953). Body + * `{ reason? }`, closed; `requestedBy` comes from + * the authenticated caller, ⛔ never the wire. + * Refusals are refusals (404/409/503; an + * unrecognised refusal code → 500), ⛔ never a 200 + * ⚑ operator verb — the same rung, same gate * GET /:name/runs/:runId/screen → the screen a paused run awaits * ⚑ run's trigger identity OR the * `sys_automation_run` grant (#7968) @@ -1141,6 +1575,23 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str if (refusal) return refusal; } + // [#13953] RUN-LIFECYCLE GATE — the two operator verbs (`cancel`, + // `restore-suspension`) need the platform operator, unconditionally. + // Placed with the three gates above and AHEAD of the service probe for + // their reason, read one more tier up: which standing a route requires + // must not vary with which automation service a deployment mounts, an + // unentitled caller must not learn from a 501-vs-403 whether automation is + // mounted here, and nothing may be cancelled or re-armed before the + // refusal — "cancel first, refuse second" is the worst shape a run + // lifecycle door can have. Ahead of the body checks too, so the envelope + // is not enumerable by probing 422s from outside the operator cohort. + // Which routes: `isRunLifecycleWrite` above, the SAME predicate the two + // route arms fire on. + if (isRunLifecycleWrite(parts, m)) { + const refusal = refuseUngrantedRunLifecycleWrite(deps, context); + if (refusal) return refusal; + } + const automationService = await deps.getService(context, CoreServiceName.enum.automation); // [#4058] Empty slot — or a slot filled by a self-declared non-handler // (`handlerReady: false`, ADR-0076 D12), which is the same amount of @@ -1821,6 +2272,155 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str return { handled: true, response: deps.error('Resume not supported', 501) }; } + // POST /:name/runs/:runId/cancel → cancel a suspended run (ADR-0044). + // + // [#13953] The FIRST of the two operator run-lifecycle doors the + // maintainer ruled (2026-09-05, option A). Both verbs existed on the + // engine with no way for an operator to reach them: no REST route, no + // CLI command, and — until #16563 — not on `IAutomationService` either. + // + // ⚑ Gated ABOVE, on the platform-operator rung and nothing else + // (`refuseUngrantedRunLifecycleWrite`). The arm repeats the same + // `isRunLifecycleWrite` predicate the gate fired on, `parts[0] !== + // 'trigger'` included, so route and gate cannot drift. + // + // Body: the closed `{ reason? }` envelope. `reason` is the operator's + // own words and is relayed VERBATIM — the engine lands it on the + // terminal `cancelled` log's `error`, and the contract calls it "why, + // in the operator's words", so this door does not decorate it with the + // caller's identity or anything else. ⚠️ Stated rather than hidden: + // `cancelRun` has no `requestedBy` slot (the repair verb does), so what + // the terminal record carries about WHO cancelled is whatever the + // operator wrote. Inventing a slot for it here would be a second name + // for a contract parameter that does not exist. + // + // ⛔ NO ONCE-ONLY SIDE EFFECT IS KEYED OFF THE RETURN VALUE, and that + // is a ruling this arm implements rather than a habit. The engine has + // no cancel-side compare-and-set: `cancelRun` does a + // `loadSuspendedRunStrict` then an unconditional delete-by-id, and only + // `resume` passes the `claimAdvance` compare-and-set through + // `forgetSuspendedRun`. So two cancels of one run overlapping in time + // each read the row, each delete, each record the terminal log, and + // EACH RETURN `true` — the contract now says so in terms ("a caller may + // not read `true` as sole authorship, nor use it as an idempotency + // token for a once-only side effect"). Whether the engine should grow + // the CAS is open and deliberately not decided here (#13953 dispatch + // ruling ①: an independent behaviour change that would also force + // rewriting the contract sentence #16563 just landed). ⇒ This arm + // calls the verb and answers; it fires no notification, writes no audit + // entry and announces no kernel event. A door that did any of those on + // `true` would fire them twice. Pinned in + // `automation-run-lifecycle-door.test.ts`. + // + // The answer is a 200 either way, because `false` is idempotent success + // per the contract — but it is NEVER a bare success. ⚠️ `false` is + // TWO conditions the caller cannot tell apart: "no suspended run under + // this id" (already terminal, or unknown) and "the durable store could + // not be READ, so the run may still be parked". Nothing above the + // engine can distinguish them — the engine reports the second at + // `error` for exactly that reason — so the door SAYS SO in the + // response rather than letting `cancelled: false` read as a clean + // no-op. #13909's posture, applied to the one verb that can hide a + // condition inside a success: ⛔ never a door that returns success + // while hiding the condition. + if (isRunLifecycleWrite(parts, m) && parts[3] === RUN_CANCEL_SEGMENT) { + if (typeof automationService.cancelRun !== 'function') { + return { handled: true, response: deps.error(RUN_CANCEL_UNSUPPORTED_MESSAGE, 501) }; + } + const bodyRefusal = refuseInvalidRunLifecycleBody(deps, body, 'cancel'); + if (bodyRefusal) return bodyRefusal; + const reason = (body as { reason?: string } | undefined | null)?.reason; + const cancelled = await automationService.cancelRun(parts[2], reason); + return { + handled: true, + response: deps.success({ + runId: parts[2], + cancelled, + notice: cancelled ? RUN_CANCEL_TRUE_NOTICE : RUN_CANCEL_FALSE_NOTICE, + }), + }; + } + + // POST /:name/runs/:runId/restore-suspension → put back the suspension + // a failed resume consumed (#13909). + // + // [#13953] The SECOND operator door. This is the verb that re-arms a + // run the platform recorded as TERMINALLY FAILED, which is why the card + // says "who may do this" is a real question and not the same answer as + // "who may resume" — see the gate above for the answer and why it is + // required unconditionally. + // + // ⭐ `requestedBy` comes from the AUTHENTICATED CALLER, never from the + // body. The implementation's trace records who asked and why (and + // writes `not recorded` when `requestedBy` is absent), and that record + // is the whole reason the optional parameters are on the signature + // rather than the ruling's `restoreConsumedSuspension(runId)` + // shorthand. A wire-settable `requestedBy` would let one operator write + // another's name into it; the body validator refuses the key by name so + // a caller who tries gets a loud refusal rather than the silent + // impression that it took. + // + // The result is NOT an `AutomationResult` — it is the narrower + // structural type the contract declares, and `refusal` on it is + // `string`, a covariant widening of the engine's closed eight-member + // union. `restoreRefusalStatus` above is therefore a NON-EXHAUSTIVE + // switch by construction and answers fail-closed (500) for anything it + // does not recognise, including a `restored: false` carrying no code at + // all. ⛔ The vocabulary is not narrowed or extended here — closing it + // is a spec card. + // + // Refusals are answered as refusals (4xx/5xx), never as a 200 carrying + // `restored: false`, which would read as "your repair ran and the run + // did not come back". The engine's own one-sentence `reason` is + // relayed as the message — it is what tells "this run is fine" from + // "this run is beyond this verb" from "I could not read the store" — + // and the code itself rides `details.refusal` rather than + // `details.code`, so `error.code` stays inside the ADR-0112 closed + // catalog (derived from the status) instead of minting eight + // unregistered members at a call site. + if (isRunLifecycleWrite(parts, m) && parts[3] === RUN_RESTORE_SEGMENT) { + if (typeof automationService.restoreConsumedSuspension !== 'function') { + return { handled: true, response: deps.error(RUN_RESTORE_UNSUPPORTED_MESSAGE, 501) }; + } + const bodyRefusal = refuseInvalidRunLifecycleBody(deps, body, 'restore-suspension'); + if (bodyRefusal) return bodyRefusal; + const reason = (body as { reason?: string } | undefined | null)?.reason; + const requestedBy = (context as any)?.executionContext?.userId; + const result = await automationService.restoreConsumedSuspension(parts[2], { + ...(typeof requestedBy === 'string' && requestedBy ? { requestedBy } : {}), + ...(reason !== undefined ? { reason } : {}), + }); + if (result?.restored === true) { + // The runId answered is the one this door was ASKED about (the + // path's `:runId`), for the reason `resumeFailureDetails` + // documents for its own: it is what the door knows, and + // echoing a service's own copy of it would relay a + // disagreement instead of reporting one. + return { + handled: true, + response: deps.success({ runId: parts[2], restored: true, reason: result.reason }), + }; + } + const status = restoreRefusalStatus(result?.refusal); + const message = typeof result?.reason === 'string' && result.reason + ? result.reason + : RUN_RESTORE_UNCLASSIFIED_MESSAGE; + return { + handled: true, + response: deps.error(message, status, { + runId: parts[2], + restored: false, + // The implementation's own code, relayed for an operator to + // act on. ⛔ Deliberately NOT `details.code`: that key is + // PROMOTED into `error.code`, which ADR-0112 closes to + // `StandardErrorCode` ∪ the registered ledger, and none of + // the engine's eight are members. `error.code` is derived + // from the status instead. + ...(typeof result?.refusal === 'string' ? { refusal: result.refusal } : {}), + }), + }; + } + // GET /:name/runs/:runId/screen → the screen a paused run awaits // (refresh-safe re-fetch for the UI flow-runner). // diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 44d599757d..ea5d6b0379 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -384,6 +384,10 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'POST /automation/:name/runs/:runId/resume', domain: '/automation', disposition: 'sdk', client: 'automation.resume', note: "generic, so the SUSPENDED NODE gates it (#3801): a pause whose descriptor declares resumeAuthority:'service' — today `approval` / `approval_revise` — answers 403 here and continues only through its owning service (ApprovalService.decide), which authorizes and records the decision first. A node type that declares NO resumeAuthority answers 403 too, fail-closed since #5561: this door is an opt-in a descriptor states with 'any'. Screen/wait pauses are unaffected because they declare it; this route is the screen-flow runner's door" }, { route: 'GET /automation/:name/runs/:runId/screen', domain: '/automation', disposition: 'sdk', client: 'automation.getScreen' }, + { route: 'POST /automation/:name/runs/:runId/cancel', domain: '/automation', disposition: 'server-only', + note: "[#13953] Cancel a suspended run (ADR-0044) — the maintainer ruling of 2026-09-05 (option A) gave the engine's two operator run-lifecycle verbs a door, because until #16563 neither was reachable by an operator at all: no REST route, no CLI command, and not on `IAutomationService`. Body is the closed `{ reason? }` envelope, relayed VERBATIM to the engine, which lands it on the terminal `cancelled` log's `error`. ⚑ ONE authority tier and it is the strictest one this domain has: the ADR-0095 D2/D3 posture RUNG (`posture === 'PLATFORM_ADMIN'`, #15981 — NEVER `positions.includes('platform_admin')`, which `sys_user_position` lets a tenant mint), required UNCONDITIONALLY. ⛔ Not posture-conditional like the ADR-0126 §5 activation gate: that one falls open under `single` because `manage_metadata` still gates it there, and this door has no capability tier in front of it, so the same conditionality would open an operator verb to any authenticated caller on every single-organization deployment — looser than `resume`, whose `resumeAuthority` gate is fail-closed on every deployment. Fail-closed by construction: an absent executionContext, an absent posture or any other rung all reach the refusal, 403 `PERMISSION_DENIED` (ADR-0112); only `isSystem` bypasses, which is how plugin-approvals' in-process revise-window recall keeps working. The #5519 anonymous floor answers an unidentified caller 401 first. WHICH routes is one predicate, `isRunLifecycleWrite` in `domains/automation.ts`, read by the gate AND by both route arms so they cannot drift; it excludes `parts[0] === 'trigger'` so a flow literally NAMED `runs` keeps its legacy execution door. Answers 200 both ways — `cancelled: false` is idempotent success per the contract — but ⛔ never a BARE success: `false` is also what an UNREADABLE durable store answers, so the response carries a `notice` naming both readings (#13909's posture: never a door that returns success while hiding the condition). The `true` notice states that `true` is NOT exclusive — the engine has no cancel-side compare-and-set, so overlapping cancels each answer `true` and each record the terminal log; this door keys no once-only side effect off it and says so on the wire. A service not declaring `cancelRun` (an OPTIONAL member) answers 501 `NOT_IMPLEMENTED`, ⛔ never a 200 and ⛔ never `{ handled: false }`. NOT JS-SDK surface on this leg, and that is stated rather than left as an open gap: the ruling charters a REST door for a platform operator holding only HTTP and explicitly declines a CLI command for want of pull, so this card declares no client method and implies none; adding one reclassifies this row to `sdk`. Pinned in `domains/automation-run-lifecycle-door.test.ts`" }, + { route: 'POST /automation/:name/runs/:runId/restore-suspension', domain: '/automation', disposition: 'server-only', + note: "[#13953] Put back the suspension a failed resume consumed (#13909) — the repair verb for `AutomationResult.status: 'stranded'`, re-arming a run the platform recorded as terminally failed. Same gate, same predicate and same fail-closed absent-member 501 as the cancel row above; see it for the authority and why the rung is unconditional. The card's own reason this needed a permission model rather than a line of routing: a repair verb re-arms a terminally-failed run, so 'who may do this' is a real question and NOT the same answer as 'who may resume'. Body is the closed `{ reason? }` envelope; ⭐ `requestedBy` is filled from the AUTHENTICATED CALLER and is refused BY NAME in the body, so no operator can write another's name into the trace that records who re-armed the run. Refusals are answered as refusals — `RUN_NOT_FOUND` 404, `STORE_UNAVAILABLE` 503, and the five run-state conflicts (`RESUME_IN_PROGRESS`, `RESTORE_IN_PROGRESS`, `RUN_SUSPENDED`, `RUN_COMPLETED`, `RUN_CANCELLED`, `NO_CONSUMED_SUSPENSION`) 409 — matching the statuses this same door already answers those conditions with on `resume`, ⛔ never a 200 carrying `restored: false`. ⚠️ The contract types the refusal as `refusal?: string`, a covariant widening of the engine's closed eight-member union (#16495 route (i)), so that mapping is a NON-EXHAUSTIVE string switch by construction: an unrecognised code — or a `restored: false` carrying none — answers 500, ⛔ deliberately not one of the 409s, which would claim a diagnosis this door did not make. ⛔ The vocabulary is neither narrowed nor extended at this call site; closing it is a `packages/spec` card. The engine's code rides `details.refusal`, ⛔ never `details.code`, which would promote an unregistered member into the ADR-0112-closed `error.code`. NOT JS-SDK surface on this leg, for the cancel row's reason; adding a client method reclassifies this row to `sdk`. Pinned in `domains/automation-run-lifecycle-door.test.ts`" }, { route: 'GET /automation/:name/runs/:runId', domain: '/automation', disposition: 'sdk', client: 'automation.getRun' }, { route: 'GET /automation/:name/runs', domain: '/automation', disposition: 'sdk', client: 'automation.listRuns' }, { route: 'GET /automation/:name', domain: '/automation', disposition: 'sdk', client: 'automation.get' }, From c9c1c6d2bca96060b123bd9ec4d3a6ed8937d486 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 03:23:54 +0000 Subject: [PATCH 2/4] fix(runtime): keep tracker ids out of ledger prose; record the new elevation read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:doc-authoring` refuses tracker ids inside runtime prose strings — a route ledger `note` reaches operators and generated surfaces, none of whom can resolve `#NNNN`. The ids move to adjacent `//` comments, where the reader who can resolve them already is. `check:check-system-context-census` requires every `isSystem` read to carry an anchor on the census page. The new gate's `isSystem` bypass is anchored on the automation row and the page's seven census-derived counts move with it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- content/docs/permissions/system-context.mdx | 18 +++++++++--------- packages/runtime/src/route-ledger.ts | 17 +++++++++++++++-- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 1227e7a146..c7fc4a6155 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -9,9 +9,9 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a service self-write, a migration. This page is **the authority** for what that flag actually does. It exists -because the flag is not one concept: it is a single boolean read at **106 +because the flag is not one concept: it is a single boolean read at **107 distinct sites across 20 packages**, and knowing three of those behaviours gives -no hint that the other hundred-and-three exist. Every documented app-side bug +no hint that the other hundred-and-four exist. Every documented app-side bug traced to `isSystem` had the same shape — the metadata was complete and correct, and the gap was observable only by querying the resulting rows. @@ -131,7 +131,7 @@ that silently does not happen. ### 3. Sharing (`plugin-sharing`) -The largest single consumer — **17 of the 106 sites**. +The largest single consumer — **17 of the 107 sites**. | # | Behaviour when `isSystem` | What you get / what you lose | Anchor | |:--|:---|:---|:---| @@ -172,7 +172,7 @@ The largest single consumer — **17 of the 106 sites**. | 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `packages/rest/src/package-routes.ts#refusePackageRequest` | | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` | | 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` | -| 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#refuseUnrelatedScreenRead` | +| 57 | Automation run-state read, flow-authoring write, unrelated-screen read and the two operator run-lifecycle writes all pass | runtime | Get: run state, flow writes, screen reads with no grant — and cancelling or restoring a suspension without the platform-operator rung. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#refuseUnrelatedScreenRead`, `#refuseUngrantedRunLifecycleWrite` | | 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertTenantAdmin` | | 59 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-provenance.ts#bindEmailTemplateProvenanceStamp`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | | 60 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `packages/services/service-automation/src/runtime-identity.ts#stampSystemInsertOwner`, called from `packages/services/service-automation/src/builtin/crud-nodes.ts#registerCrudNodes` | @@ -277,7 +277,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are independent decisions, and a seed loader plausibly wants the first two but not the third. The concept is nevertheless **staying as one boolean**: -- **Shipped semantics.** `isSystem` is a published contract with 106 read sites +- **Shipped semantics.** `isSystem` is a published contract with 107 read sites in 20 packages. Splitting it is a breaking contract change across all of them. (The ruling was taken when the census read 80 sites in 18 packages; the count has grown, which strengthens rather than weakens the argument.) @@ -334,16 +334,16 @@ still holds equal to the census on every pull request: | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | | — parsed as a declaration | 22 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 112 | ✅ | +| — parsed as a property **read** | 113 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | | Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | -| Of those reads: reads of `ExecutionContext.isSystem` | **106** | ✅ | -| — behaviour-bearing (rows 1–62 above) | 102 | ✅ | +| Of those reads: reads of `ExecutionContext.isSystem` | **107** | ✅ | +| — behaviour-bearing (rows 1–62 above) | 103 | ✅ | | — carry the flag onward only (rows 63–66 above) | 4 | ✅ | | Packages containing at least one elevation read | **20** | ✅ | | Files containing at least one elevation read | 45 | ✅ | -| — the distinct symbols those reads live in — what this page anchors | 89 | ✅ | +| — the distinct symbols those reads live in — what this page anchors | 90 | ✅ | | — of those files, the ones holding more than one read in one symbol | 9 | ✅ | The six rows marked — are a **dated decomposition, not a live claim**: they were diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index ea5d6b0379..5bb59dee73 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -384,10 +384,23 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'POST /automation/:name/runs/:runId/resume', domain: '/automation', disposition: 'sdk', client: 'automation.resume', note: "generic, so the SUSPENDED NODE gates it (#3801): a pause whose descriptor declares resumeAuthority:'service' — today `approval` / `approval_revise` — answers 403 here and continues only through its owning service (ApprovalService.decide), which authorizes and records the decision first. A node type that declares NO resumeAuthority answers 403 too, fail-closed since #5561: this door is an opt-in a descriptor states with 'any'. Screen/wait pauses are unaffected because they declare it; this route is the screen-flow runner's door" }, { route: 'GET /automation/:name/runs/:runId/screen', domain: '/automation', disposition: 'sdk', client: 'automation.getScreen' }, + // [#13953] Cancel a suspended run (ADR-0044) — the maintainer ruling of 2026-09-05 + // (option A) on the two operator run-lifecycle verbs. The engine has carried both for + // as long as either has existed with no way for an operator to reach them: no REST + // route, no CLI command, and until the contract half landed (PR #16563, card #16495) + // not on `IAutomationService` either. #15981 is the correction this gate is built on + // at birth: the platform-operator test is the ADR-0095 rung, never the `positions[]` + // name. #13909 is the parent card the repair verb belongs to; #10243 / #12156 are the + // toggle and clone arms whose `trigger` exclusion this predicate copies; #5519 is the + // anonymous floor that answers first. { route: 'POST /automation/:name/runs/:runId/cancel', domain: '/automation', disposition: 'server-only', - note: "[#13953] Cancel a suspended run (ADR-0044) — the maintainer ruling of 2026-09-05 (option A) gave the engine's two operator run-lifecycle verbs a door, because until #16563 neither was reachable by an operator at all: no REST route, no CLI command, and not on `IAutomationService`. Body is the closed `{ reason? }` envelope, relayed VERBATIM to the engine, which lands it on the terminal `cancelled` log's `error`. ⚑ ONE authority tier and it is the strictest one this domain has: the ADR-0095 D2/D3 posture RUNG (`posture === 'PLATFORM_ADMIN'`, #15981 — NEVER `positions.includes('platform_admin')`, which `sys_user_position` lets a tenant mint), required UNCONDITIONALLY. ⛔ Not posture-conditional like the ADR-0126 §5 activation gate: that one falls open under `single` because `manage_metadata` still gates it there, and this door has no capability tier in front of it, so the same conditionality would open an operator verb to any authenticated caller on every single-organization deployment — looser than `resume`, whose `resumeAuthority` gate is fail-closed on every deployment. Fail-closed by construction: an absent executionContext, an absent posture or any other rung all reach the refusal, 403 `PERMISSION_DENIED` (ADR-0112); only `isSystem` bypasses, which is how plugin-approvals' in-process revise-window recall keeps working. The #5519 anonymous floor answers an unidentified caller 401 first. WHICH routes is one predicate, `isRunLifecycleWrite` in `domains/automation.ts`, read by the gate AND by both route arms so they cannot drift; it excludes `parts[0] === 'trigger'` so a flow literally NAMED `runs` keeps its legacy execution door. Answers 200 both ways — `cancelled: false` is idempotent success per the contract — but ⛔ never a BARE success: `false` is also what an UNREADABLE durable store answers, so the response carries a `notice` naming both readings (#13909's posture: never a door that returns success while hiding the condition). The `true` notice states that `true` is NOT exclusive — the engine has no cancel-side compare-and-set, so overlapping cancels each answer `true` and each record the terminal log; this door keys no once-only side effect off it and says so on the wire. A service not declaring `cancelRun` (an OPTIONAL member) answers 501 `NOT_IMPLEMENTED`, ⛔ never a 200 and ⛔ never `{ handled: false }`. NOT JS-SDK surface on this leg, and that is stated rather than left as an open gap: the ruling charters a REST door for a platform operator holding only HTTP and explicitly declines a CLI command for want of pull, so this card declares no client method and implies none; adding one reclassifies this row to `sdk`. Pinned in `domains/automation-run-lifecycle-door.test.ts`" }, + note: "Cancel a suspended run (ADR-0044) — the maintainer ruling of 2026-09-05 (option A) gave the engine's two operator run-lifecycle verbs a door, because until the contract half landed neither was reachable by an operator at all: no REST route, no CLI command, and not on `IAutomationService`. Body is the closed `{ reason? }` envelope, relayed VERBATIM to the engine, which lands it on the terminal `cancelled` log's `error`. ⚑ ONE authority tier and it is the strictest one this domain has: the ADR-0095 D2/D3 posture RUNG (`posture === 'PLATFORM_ADMIN'` — NEVER `positions.includes('platform_admin')`, which `sys_user_position` lets a tenant mint), required UNCONDITIONALLY. ⛔ Not posture-conditional like the ADR-0126 §5 activation gate: that one falls open under `single` because `manage_metadata` still gates it there, and this door has no capability tier in front of it, so the same conditionality would open an operator verb to any authenticated caller on every single-organization deployment — looser than `resume`, whose `resumeAuthority` gate is fail-closed on every deployment. Fail-closed by construction: an absent executionContext, an absent posture or any other rung all reach the refusal, 403 `PERMISSION_DENIED` (ADR-0112); only `isSystem` bypasses, which is how plugin-approvals' in-process revise-window recall keeps working. The anonymous floor answers an unidentified caller 401 first. WHICH routes is one predicate, `isRunLifecycleWrite` in `domains/automation.ts`, read by the gate AND by both route arms so they cannot drift; it excludes `parts[0] === 'trigger'` so a flow literally NAMED `runs` keeps its legacy execution door. Answers 200 both ways — `cancelled: false` is idempotent success per the contract — but ⛔ never a BARE success: `false` is also what an UNREADABLE durable store answers, so the response carries a `notice` naming both readings (never a door that returns success while hiding the condition). The `true` notice states that `true` is NOT exclusive — the engine has no cancel-side compare-and-set, so overlapping cancels each answer `true` and each record the terminal log; this door keys no once-only side effect off it and says so on the wire. A service not declaring `cancelRun` (an OPTIONAL member) answers 501 `NOT_IMPLEMENTED`, ⛔ never a 200 and ⛔ never `{ handled: false }`. NOT JS-SDK surface on this leg, and that is stated rather than left as an open gap: the ruling charters a REST door for a platform operator holding only HTTP and explicitly declines a CLI command for want of pull, so this card declares no client method and implies none; adding one reclassifies this row to `sdk`. Pinned in `domains/automation-run-lifecycle-door.test.ts`" }, + // [#13953] The repair verb's door — the exit from the `'stranded'` state #13937 + // shape 4 named and #13909 exists to measure. Its result is the inline structural + // type PR #16563 landed in spec (card #16495 route (i)), whose `refusal?: string` is + // the covariant widening the non-exhaustive status switch is a consequence of. { route: 'POST /automation/:name/runs/:runId/restore-suspension', domain: '/automation', disposition: 'server-only', - note: "[#13953] Put back the suspension a failed resume consumed (#13909) — the repair verb for `AutomationResult.status: 'stranded'`, re-arming a run the platform recorded as terminally failed. Same gate, same predicate and same fail-closed absent-member 501 as the cancel row above; see it for the authority and why the rung is unconditional. The card's own reason this needed a permission model rather than a line of routing: a repair verb re-arms a terminally-failed run, so 'who may do this' is a real question and NOT the same answer as 'who may resume'. Body is the closed `{ reason? }` envelope; ⭐ `requestedBy` is filled from the AUTHENTICATED CALLER and is refused BY NAME in the body, so no operator can write another's name into the trace that records who re-armed the run. Refusals are answered as refusals — `RUN_NOT_FOUND` 404, `STORE_UNAVAILABLE` 503, and the five run-state conflicts (`RESUME_IN_PROGRESS`, `RESTORE_IN_PROGRESS`, `RUN_SUSPENDED`, `RUN_COMPLETED`, `RUN_CANCELLED`, `NO_CONSUMED_SUSPENSION`) 409 — matching the statuses this same door already answers those conditions with on `resume`, ⛔ never a 200 carrying `restored: false`. ⚠️ The contract types the refusal as `refusal?: string`, a covariant widening of the engine's closed eight-member union (#16495 route (i)), so that mapping is a NON-EXHAUSTIVE string switch by construction: an unrecognised code — or a `restored: false` carrying none — answers 500, ⛔ deliberately not one of the 409s, which would claim a diagnosis this door did not make. ⛔ The vocabulary is neither narrowed nor extended at this call site; closing it is a `packages/spec` card. The engine's code rides `details.refusal`, ⛔ never `details.code`, which would promote an unregistered member into the ADR-0112-closed `error.code`. NOT JS-SDK surface on this leg, for the cancel row's reason; adding a client method reclassifies this row to `sdk`. Pinned in `domains/automation-run-lifecycle-door.test.ts`" }, + note: "Put back the suspension a failed resume consumed — the repair verb for `AutomationResult.status: 'stranded'`, re-arming a run the platform recorded as terminally failed. Same gate, same predicate and same fail-closed absent-member 501 as the cancel row above; see it for the authority and why the rung is unconditional. The card's own reason this needed a permission model rather than a line of routing: a repair verb re-arms a terminally-failed run, so 'who may do this' is a real question and NOT the same answer as 'who may resume'. Body is the closed `{ reason? }` envelope; ⭐ `requestedBy` is filled from the AUTHENTICATED CALLER and is refused BY NAME in the body, so no operator can write another's name into the trace that records who re-armed the run. Refusals are answered as refusals — `RUN_NOT_FOUND` 404, `STORE_UNAVAILABLE` 503, and the run-state conflicts (`RESUME_IN_PROGRESS`, `RESTORE_IN_PROGRESS`, `RUN_SUSPENDED`, `RUN_COMPLETED`, `RUN_CANCELLED`, `NO_CONSUMED_SUSPENSION`) 409 — matching the statuses this same door already answers those conditions with on `resume`, ⛔ never a 200 carrying `restored: false`. ⚠️ The contract types the refusal as `refusal?: string`, a covariant widening of the engine's closed eight-member union, so that mapping is a NON-EXHAUSTIVE string switch by construction: an unrecognised code — or a `restored: false` carrying none — answers 500, ⛔ deliberately not one of the 409s, which would claim a diagnosis this door did not make. ⛔ The vocabulary is neither narrowed nor extended at this call site; closing it is a `packages/spec` card. The engine's code rides `details.refusal`, ⛔ never `details.code`, which would promote an unregistered member into the ADR-0112-closed `error.code`. NOT JS-SDK surface on this leg, for the cancel row's reason; adding a client method reclassifies this row to `sdk`. Pinned in `domains/automation-run-lifecycle-door.test.ts`" }, { route: 'GET /automation/:name/runs/:runId', domain: '/automation', disposition: 'sdk', client: 'automation.getRun' }, { route: 'GET /automation/:name/runs', domain: '/automation', disposition: 'sdk', client: 'automation.listRuns' }, { route: 'GET /automation/:name', domain: '/automation', disposition: 'sdk', client: 'automation.get' }, From 0765b14363e01730071fcac0455e84d760505a48 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 09:13:15 +0000 Subject: [PATCH 3/4] fix(runtime): mount the two operator run-lifecycle verbs the ledger already declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../dogfood/test/authz-conformance.matrix.ts | 2 +- .../test/authz-probe-blind-spot.census.ts | 13 +++++--- packages/runtime/src/dispatcher-plugin.ts | 33 +++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 2c82d52f44..9af91ee746 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -25,7 +25,7 @@ // dispatcher domain files. // // The population comes from `packages/rest/src/rest-route-ledger.ts` (94 rows -// / 19 families) and `packages/runtime/src/route-ledger.ts` (80 rows / 21 +// / 19 families) and `packages/runtime/src/route-ledger.ts` (82 rows / 21 // domains) because those two are enumerated from a RUNNING server and guarded // in both directions by their own conformance tests — so a new family or // domain cannot be silently absent from them, and therefore cannot be silently diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index d34e04f115..070eba3ac3 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -106,7 +106,7 @@ // `RestServer.getRoutes()` on a booted server and guarded per route by // `rest-route-ledger.conformance.test.ts`. It reaches all 17 registrars; // this table reaches 1. -// `packages/runtime/src/route-ledger.ts`: 80 rows over 21 domains. Its +// `packages/runtime/src/route-ledger.ts`: 82 rows over 21 domains. Its // machine contract is DOMAIN-level, by live registry introspection // (`domainRegistry.list()`), the per-route rows being documentation. It // covers all 15 `async handle*(` methods in `http-dispatcher.ts` and all @@ -295,11 +295,16 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ kinds: ['ROUTE_ENUMERATION'], probes: 1, keys: 21, - population: 80, - reachable: 80, + // [#13953] 80 -> 82: the two operator run-lifecycle rows + // (`POST /automation/:name/runs/:runId/cancel` and `.../restore-suspension`). + // Both carry `domain: '/automation'`, an EXISTING key, so `reachable` moves + // with `population`, `blindSpot` stays 0 and `keys` stays 21 — a population + // that grows inside an already-classified domain mints nothing new. + population: 82, + reachable: 82, blindSpot: 0, populationRule: 'ledger rows inside ROUTE_LEDGER; reachable = rows carrying a `domain` (each distinct value mints a key)', - controls: { "route: '": 80, "domain: '": 80, RouteLedgerEntry: 2 }, + controls: { "route: '": 82, "domain: '": 82, RouteLedgerEntry: 2 }, note: 'The dispatcher half. Its machine contract is DOMAIN-level by live registry introspection ' + '(domainRegistry.list()), guarded in BOTH directions by route-ledger.conformance.test.ts: every ' + diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 5b79d6cdf4..4ae5006242 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1553,6 +1553,39 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu } }); + // [#13953] The two OPERATOR RUN-LIFECYCLE verbs — cancel a + // suspended run (ADR-0044) and put back the suspension a failed + // resume consumed (#13909). LITERAL registrations, one per verb, + // for the reason `resume` above is one: nothing else registered + // on this router answers a 5-segment POST under `/automation`, + // so a ledger row with no `server!.post` here answers Hono's + // `notFound` at runtime while every ledger-reading guard passes + // it — the exact class the route-ledger ↔ live-mount parity gate + // (#7526) exists to catch, and the class it caught this in. + // + // ⛔ These arms carry NO authority logic. The gate is one + // predicate in `domains/automation.ts` (`isRunLifecycleWrite`, + // the ADR-0095 posture rung, unconditional) read by the gate and + // by both route arms there; a second spelling here would be a + // second policy that happens to agree today. + server!.post(`${base}/automation/:name/runs/:runId/cancel`, async (req: any, res: any) => { + try { + const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/runs/${req.params.runId}/cancel`, req.body, req.query, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + + server!.post(`${base}/automation/:name/runs/:runId/restore-suspension`, async (req: any, res: any) => { + try { + const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/runs/${req.params.runId}/restore-suspension`, req.body, req.query, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + server!.get(`${base}/automation/:name/runs/:runId/screen`, async (req: any, res: any) => { try { const result = await dispatcher.dispatch('GET', `/automation/${req.params.name}/runs/${req.params.runId}/screen`, undefined, req.query, { request: req }); From 34feecba75aea132ba43345dac3539e007200059 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 11:11:24 +0000 Subject: [PATCH 4/4] docs(dogfood): refresh the last stale runtime-ledger population claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `authz-ledger-population.baseline.ts`'s header still stated the runtime route ledger at 80 rows. The number moved to 82 with the two operator run-lifecycle rows; the date the measurement was taken is kept, because the key arithmetic the sentence goes on to state (40 minted, 6 classified, 34 baselined) is the part that did not move — both new rows sit under the already-classified `/automation` domain. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- packages/qa/dogfood/test/authz-ledger-population.baseline.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/qa/dogfood/test/authz-ledger-population.baseline.ts b/packages/qa/dogfood/test/authz-ledger-population.baseline.ts index 8bc75e6933..50a0fb76a7 100644 --- a/packages/qa/dogfood/test/authz-ledger-population.baseline.ts +++ b/packages/qa/dogfood/test/authz-ledger-population.baseline.ts @@ -59,7 +59,9 @@ * Ledger-sourced population keys with no classifying matrix row. * * MEASURED 2026-08-31 against `rest-route-ledger.ts` (94 rows / 19 families) - * and `route-ledger.ts` (80 rows / 21 domains): 40 keys minted, 6 classified + * and `route-ledger.ts` (80 rows / 21 domains — 82 since the two operator + * run-lifecycle rows landed, both under the already-classified `/automation` + * domain, so the key arithmetic below is unmoved): 40 keys minted, 6 classified * by rows that already pin the same surface through the probe table, 34 here. * * ⛔ SHRINK-ONLY. See rules 1–4 above; the test enforces all four.