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
fix(automation,approvals): gate the generic run-resume route on the suspended node (#3801) (#3822)
The resume route forwarded a caller-supplied signal straight into
AutomationEngine.resume, which validated machine state only — nothing asked who.
Approval nodes resume through that mechanism, so a raw resume walked the approve
edge with no approver check, no sys_approval_action row and no status mirror.
The gate keys on what the run is parked on: ActionDescriptor.resumeAuthority
('any' | 'service'), a suspension that records its own node type, and an
unforgeable symbol marker the owning service stamps. It follows subflow pauses
down to the child the signal would reach, and refuses with code 'forbidden' →
403 without consuming the suspension.
Adjacent revise-window gap filed as #3823.
Copy file name to clipboardExpand all lines: content/docs/automation/flows.mdx
+26-2Lines changed: 26 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -389,11 +389,34 @@ POST /api/v1/automation/{flow}/runs/{runId}/resume
389
389
390
390
| Pausing node | Suspends until… | Resumed by |
391
391
| :--- | :--- | :--- |
392
-
|`approval`| a human decision | the approvals service (`POST /api/v1/approvals/requests/:id/approve\|reject`) — resumes down the matching `approve` / `reject` edge. **Always decide through the approvals API**, never by calling `resume` directly: a direct resume strands the pending `sys_approval_request`, so the record stays locked and later approvals on the same record hit `DUPLICATE_REQUEST`. |
392
+
|`approval`| a human decision | the approvals service (`POST /api/v1/approvals/requests/:id/approve\|reject`) — resumes down the matching `approve` / `reject` edge. **Decide through the approvals API**; the resume route above **refuses** an approval pause outright (see below). |
393
393
|`screen`| a user submits the form | the UI runner posting the collected `inputs`; a `paused` response carrying the next `screen` chains multi-step wizards under one stable `runId`|
394
394
|`wait` (timer) | an ISO-8601 duration elapses |**automatically** — a one-shot job resumes the run; after a cold boot the engine re-arms pending timers from the durable store (overdue timers resume immediately) |
395
395
|`wait` (signal) | a named external event | any caller invoking `resume(runId)`|
396
396
397
+
### Who may resume — the gate is the suspended node
398
+
399
+
The resume route is generic, so **the node the run is parked on** decides
400
+
whether a raw resume is a legitimate continuation. Every action descriptor
401
+
carries a `resumeAuthority`:
402
+
403
+
-**`'any'`** (the default — `screen`, `wait`, your own pausing nodes): the
404
+
caller supplies the continuation and the route is the intended door.
405
+
-**`'service'`** (`approval`): continuing is a *side effect* of a decision
406
+
that some service must authorize and record first, so only that service may
407
+
drive it. `ApprovalService.decide` enforces the approver slate, writes the
408
+
`sys_approval_action` row, mirrors the status field — **then** resumes.
409
+
410
+
A resume of a `'service'` pause through the route answers **403** and changes
411
+
nothing: the request stays pending and the run stays parked, so the real
412
+
decision can still land. (Before this gate a raw resume walked the `approve`
413
+
edge with no decision recorded, leaving the `sys_approval_request` row and the
414
+
run permanently disagreeing.) The gate follows a **subflow** pause down to the
415
+
child the signal would actually reach, so resuming the parent is no way around
416
+
it. Registering a pausing node of your own? Declare
417
+
`resumeAuthority: 'service'` on its descriptor when the decision to continue
418
+
belongs to your service rather than to whoever holds the run id.
419
+
397
420
### Parallel approvals — one aggregating node, not two pauses
398
421
399
422
"Finance **and** legal must both sign off, concurrently" is **one `approval`
@@ -469,7 +492,8 @@ the chain resolves from either end:
469
492
`${nodeId}.output` / `outputVariable` mapping as a synchronous subflow;
470
493
- resuming the **parent** run id (what a UI holds from the original launch)
471
494
**delegates** down to the suspended child — multi-screen child wizards keep
472
-
the parent run id stable.
495
+
the parent run id stable. The resume gate delegates with it: if the child is
496
+
parked on an `approval`, resuming the parent is refused too.
473
497
474
498
A child that fails terminally after the pause fails every waiting ancestor, so
475
499
no run is stranded as resumable-forever. See the worked example pair in the
|**supportsRetry**|`boolean`| ✅ | Supports retry on failure |
77
77
|**needsOutbox**|`boolean`| ✅ | Dispatch via service-messaging outbox (retry/idempotency/dead-letter) |
78
78
|**isAsync**|`boolean`| ✅ | Suspends the flow awaiting an external reply |
79
+
|**resumeAuthority**|`Enum<'any' \| 'service'>`| ✅ | Who may resume a run this node suspended: 'any' (the generic resume route) or 'service' (only the owning service, e.g. approvals) |
79
80
|**maturity**|`Enum<'ga' \| 'beta' \| 'reserved'>`| ✅ | Runtime maturity: ga (shipped), beta, or reserved (contract only — designers grey this out) |
0 commit comments