You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: content/docs/automation/flows.mdx
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1734,6 +1734,8 @@ curl -b cookies.txt -X POST \
1734
1734
|`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)|
1735
1735
|`POST /api/v1/automation/:name/runs/:runId/resume`| Resume a paused run — body `{ inputs, output, branchLabel }`|
1736
1736
|`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 |
1737
1739
1738
1740
**Failures answer real HTTP status codes.** A flow that does not run is not a
1739
1741
`200`. The route classifies five outcomes, and it reads the engine's own verdict
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`"},
413
413
// [#13953] The repair verb's door — the exit from the `'stranded'` state #13937
414
414
// shape 4 named and #13909 exists to measure. Its result is the inline structural
415
415
// type PR #16563 landed in spec (card #16495 route (i)), whose `refusal?: string` is
416
416
// the covariant widening the non-exhaustive status switch is a consequence of.
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`"},
Copy file name to clipboardExpand all lines: packages/services/service-automation/README.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -295,6 +295,8 @@ GET /api/v1/automation/:name/runs # list runs
295
295
GET /api/v1/automation/:name/runs/:runId # run detail
296
296
GET /api/v1/automation/:name/runs/:runId/screen # screen spec of a parked run
297
297
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
298
300
POST /api/v1/automation/trigger/:name # legacy execute shape
0 commit comments