Skip to content

Commit 6a7910a

Browse files
os-trumpclaude
andauthored
docs(runtime,automation): document the run-lifecycle doors, and turn two ledger notes into a decision (#17038)
content/docs/automation/flows.mdx and packages/services/service-automation/README.md list the run family's REST doors without POST .../cancel and .../restore-suspension, which PR #16755 shipped behind the PLATFORM_ADMIN posture rung (isSystem bypass). Both tables gain a row: the gate, the closed { reason? } body, and the 501-when-absent shape. route-ledger.ts's two notes carried two loose ends the contract review (comment 5587229770 on #16755) carried forward as non-blocking findings: - Both arms dispatch the verb with parts[2] (the run id) alone and never check the run belongs to flow parts[0] -- the existing, deliberate convention resume / screen / getRun already use on this domain. One clause per note says so, so the flow-scoped URL shape is not read as a scoping guarantee this door enforces. - Both notes said "adding one reclassifies this row to `sdk`" as open scope prose. The seat ruled on #16896 (comment 5595099733): client.automation.cancelRun / restoreSuspension stay unadded -- no operator has asked, and an inert SDK method is worse than an absent one (ADR-0078) -- reopening only when an operator or app repo asks for programmatic cancel/restore. Both notes now cite that decision by URL instead of restating the open question. No behaviour change; no new exported symbol, published-payload key, or spec path. Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 041d9fd commit 6a7910a

3 files changed

Lines changed: 6 additions & 2 deletions

File tree

content/docs/automation/flows.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1734,6 +1734,8 @@ curl -b cookies.txt -X POST \
17341734
| `GET /api/v1/automation/:name/runs/:runId` | One run's detail (404 `Execution not found`). Requires read on `sys_automation_run` — see [Observing runs](#observing-runs) |
17351735
| `POST /api/v1/automation/:name/runs/:runId/resume` | Resume a paused run — body `{ inputs, output, branchLabel }` |
17361736
| `GET /api/v1/automation/:name/runs/:runId/screen` | The pending screen of a screen-flow run |
1737+
| `POST /api/v1/automation/:name/runs/:runId/cancel` | Cancel a suspended run (ADR-0044) — `PLATFORM_ADMIN` posture only (`isSystem` bypass); body `{ reason? }`. `501 NOT_IMPLEMENTED` when the service does not declare the verb |
1738+
| `POST /api/v1/automation/:name/runs/:runId/restore-suspension` | Re-arm a run stranded by a failed resume — same gate as `cancel`; body `{ reason? }`. `501 NOT_IMPLEMENTED` when the service does not declare the verb |
17371739

17381740
**Failures answer real HTTP status codes.** A flow that does not run is not a
17391741
`200`. The route classifies five outcomes, and it reads the engine's own verdict

packages/runtime/src/route-ledger.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -409,13 +409,13 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
409409
// toggle and clone arms whose `trigger` exclusion this predicate copies; #5519 is the
410410
// anonymous floor that answers first.
411411
{ route: 'POST /automation/:name/runs/:runId/cancel', domain: '/automation', disposition: 'server-only',
412-
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`" },
412+
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. ⚠️ `:name` is READ, never VERIFIED — the existing, deliberate convention `resume` / `screen` / `getRun` already use on this domain: both arms dispatch the verb with `parts[2]` (the run id) alone and never check that the run recorded under it belongs to flow `parts[0]`, so any flow name reaches any run id by design, not by omission; the flow-scoped URL shape is not a scoping guarantee this door enforces. 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. ⚑ DECIDED, not merely deferred: https://github.com/objectstack-ai/objectstack/issues/16896#issuecomment-5595099733 rules that `client.automation.cancelRun` stays unadded because no operator has asked and an SDK method is a published surface that cannot be withdrawn cheaply — an inert method is worse than an absent one (ADR-0078) — reopening only when an operator or app repo asks for programmatic cancel, at which point this row reclassifies to `sdk`. Pinned in `domains/automation-run-lifecycle-door.test.ts`" },
413413
// [#13953] The repair verb's door — the exit from the `'stranded'` state #13937
414414
// shape 4 named and #13909 exists to measure. Its result is the inline structural
415415
// type PR #16563 landed in spec (card #16495 route (i)), whose `refusal?: string` is
416416
// the covariant widening the non-exhaustive status switch is a consequence of.
417417
{ route: 'POST /automation/:name/runs/:runId/restore-suspension', domain: '/automation', disposition: 'server-only',
418-
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`" },
418+
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. Same `:name` convention too — this arm dispatches with `parts[2]` alone and never checks the run against flow `parts[0]`; not a defect, the same deliberate `resume` / `screen` / `getRun` shape the cancel row's note explains. 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. ⚑ DECIDED there too: https://github.com/objectstack-ai/objectstack/issues/16896#issuecomment-5595099733 rules `client.automation.restoreSuspension` stays unadded until an operator or app repo asks for programmatic restore — only then does this row reclassify to `sdk`. Pinned in `domains/automation-run-lifecycle-door.test.ts`" },
419419
{ route: 'GET /automation/:name/runs/:runId', domain: '/automation', disposition: 'sdk', client: 'automation.getRun' },
420420
{ route: 'GET /automation/:name/runs', domain: '/automation', disposition: 'sdk', client: 'automation.listRuns' },
421421
{ route: 'GET /automation/:name', domain: '/automation', disposition: 'sdk', client: 'automation.get' },

packages/services/service-automation/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,8 @@ GET /api/v1/automation/:name/runs # list runs
295295
GET /api/v1/automation/:name/runs/:runId # run detail
296296
GET /api/v1/automation/:name/runs/:runId/screen # screen spec of a parked run
297297
POST /api/v1/automation/:name/runs/:runId/resume # resume a parked run
298+
POST /api/v1/automation/:name/runs/:runId/cancel # cancel a suspended run — PLATFORM_ADMIN only (isSystem bypass), body { reason? }; 501 if unimplemented
299+
POST /api/v1/automation/:name/runs/:runId/restore-suspension # re-arm a run stranded by a failed resume — same gate; body { reason? }; 501 if unimplemented
298300
POST /api/v1/automation/trigger/:name # legacy execute shape
299301
```
300302

0 commit comments

Comments
 (0)