Filed from #13807's measurement round (measurement only, no PR). ⛔ Not a duplicate of #15222: that one is the engine's own nesting limit (a stranded child's cascade-failed ancestors carry no snapshot). This one is the approvals seam — the restore succeeds completely, and the continuation it tells the operator to re-issue is then refused.
Measured
Real AutomationEngine + real ApprovalService (built dists at 65846bc46, harness outside the repo). A flow pauses at an approval node; the reject branch's node throws; service.decide(..., 'reject') strands the run exactly as #13807 reports. Then:
| step |
result |
engine.restoreConsumedSuspension(runId, {requestedBy:'ops'}) |
restored: true, reason "the run is resumable again; re-issue the continuation" |
engine.hasSuspendedRun(runId) |
true — the pause really is back |
service.decide(requestId, {decision:'reject'}) — re-issue the continuation |
❌ INVALID_STATE: request is rejected |
service.recall(requestId, ...) |
❌ INVALID_STATE: request is rejected |
generic door engine.resume(runId, {branchLabel:'reject'}) |
❌ PERMISSION_DENIED — "paused at a 'approval' node, which only its owning service may resume" |
engine.cancelRun(runId) |
✅ true |
Reverse control (same harness, downstream node does not throw): decide returns resumed: true, the run records completed, and restoreConsumedSuspension refuses with RUN_COMPLETED — so the probes above measure rather than return constants.
Why the three refusals compose into a dead end
restoreConsumedSuspension's own contract says the resume signal is deliberately not replayed: the continuation must be re-issued. For an approval suspension the only issuers are decide / recall / sendBack / resubmit, and every one of them guards on a non-terminal request (approval-service.ts:2451, :2915, :3340). The row is already rejected — written by the very call that stranded the run. The generic REST resume door cannot stand in either, because the approval node declares resumeAuthority: 'service' (approval-node.ts:126) and the #3801 gate refuses a raw resume.
So the operator exit lands the run in a state where the only remaining verb is cancelRun: the decision stays recorded and mirrored onto the business record, and the reject branch's downstream work never happens. That is better than terminal-failed — the run becomes visible and cancellable — but it is not a path to completing the flow.
Scope note
This is deliverable 3's residual, not #13807's open deliverable 2 (the atomicity posture, which is the maintainer's to decide). Whichever posture is chosen for the door going forward, the runs already in this state need a completion path, and today there is none through approvals. ⛔ No assignee, no lane label — left for PM triage.
Related: #13807 (the reject-door divergence) · #13909 (the strand, closed) · #15222 (the nested-run restore limit) · #4420 (the ancestor) · #3801 (the resumeAuthority gate).
Filed from #13807's measurement round (measurement only, no PR). ⛔ Not a duplicate of #15222: that one is the engine's own nesting limit (a stranded child's cascade-failed ancestors carry no snapshot). This one is the approvals seam — the restore succeeds completely, and the continuation it tells the operator to re-issue is then refused.
Measured
Real
AutomationEngine+ realApprovalService(built dists at65846bc46, harness outside the repo). A flow pauses at anapprovalnode; the reject branch's node throws;service.decide(..., 'reject')strands the run exactly as #13807 reports. Then:engine.restoreConsumedSuspension(runId, {requestedBy:'ops'})restored: true, reason "the run is resumable again; re-issue the continuation"engine.hasSuspendedRun(runId)true— the pause really is backservice.decide(requestId, {decision:'reject'})— re-issue the continuationINVALID_STATE: request is rejectedservice.recall(requestId, ...)INVALID_STATE: request is rejectedengine.resume(runId, {branchLabel:'reject'})PERMISSION_DENIED— "paused at a 'approval' node, which only its owning service may resume"engine.cancelRun(runId)trueReverse control (same harness, downstream node does not throw):
decidereturnsresumed: true, the run recordscompleted, andrestoreConsumedSuspensionrefuses withRUN_COMPLETED— so the probes above measure rather than return constants.Why the three refusals compose into a dead end
restoreConsumedSuspension's own contract says the resume signal is deliberately not replayed: the continuation must be re-issued. For anapprovalsuspension the only issuers aredecide/recall/sendBack/resubmit, and every one of them guards on a non-terminal request (approval-service.ts:2451,:2915,:3340). The row is alreadyrejected— written by the very call that stranded the run. The generic REST resume door cannot stand in either, because the approval node declaresresumeAuthority: 'service'(approval-node.ts:126) and the #3801 gate refuses a raw resume.So the operator exit lands the run in a state where the only remaining verb is
cancelRun: the decision stays recorded and mirrored onto the business record, and the reject branch's downstream work never happens. That is better than terminal-failed — the run becomes visible and cancellable — but it is not a path to completing the flow.Scope note
This is deliverable 3's residual, not #13807's open deliverable 2 (the atomicity posture, which is the maintainer's to decide). Whichever posture is chosen for the door going forward, the runs already in this state need a completion path, and today there is none through approvals. ⛔ No assignee, no lane label — left for PM triage.
Related: #13807 (the reject-door divergence) · #13909 (the strand, closed) · #15222 (the nested-run restore limit) · #4420 (the ancestor) · #3801 (the
resumeAuthoritygate).