Found while implementing #15389 (PR #15966); out of scope there and deliberately not fixed in it. No assignee, no lane label — left for triage.
Measured
Real AutomationEngine + real ApprovalService, one live process, in-memory ObjectQL double for the approvals tables only. A flow parks at an approval node; the reject branch's downstream node throws. The submitter then recalls the request, which resumes the run down the reject edge — and that resume strands it.
What ApprovalService.recall answers:
| read |
result |
| the call |
no throw — returns normally |
result.resumed |
false |
result.resumeError |
resume of run 'run_2cfd…' failed: downstream blew up — prose only |
result.request.status |
recalled — the withdrawal is durable |
strandedDecisionDetails(result) |
null — no machine-readable envelope at all |
engine.hasSuspendedRun(runId) |
false — the run really is stranded |
engine.restoreConsumedSuspension(runId) |
restored: true — and it really is repairable |
Control, same harness, same strand, different door: the identical downstream failure reached through decide throws RESUME_FAILED carrying { finalized: true, decision, runId, repairable: true } (this is #13807's batch #37 landing, pinned in decision-strand-envelope.test.ts). So the difference is the door, not the strand.
The mechanism
decide / sendBack / resubmit route their resume through ApprovalService.resumeRecordedOutcome, which reads the engine's AutomationResult.status discriminator and derives repairable from status === 'stranded'.
recall does not. It calls serviceResume directly inside its own try / catch (approval-service.ts, the non-revise-window arm of recall), and that catch keeps only err.message — the resumeCode / resumeStatus the error already carries are discarded one line before the result is built. So the repairability signal has a producer and, on this door, no consumer — the same shape #13807 found and fixed one door over.
Why it matters more now than when recall's tolerance was written
recall's decision not to throw is correct and should stay: the withdrawal and the record-lock release are the point, and they have already landed. This is not a request to make recall fail.
What changed is that the information is now actionable. Before #15389 there was nothing an operator could do with "this run is repairable" for an approval suspension — restoreConsumedSuspension re-armed the pause and no approvals door could continue it. PR #15966 adds that issuer (continueRestoredRun), so repairable: true now names a repair that exists and completes. On the recall path a caller still cannot learn it, and the run's downstream work is silently abandoned.
⚠️ Contract adjacency — flagged, not decided
The natural repair is for ApprovalRecallResult to carry the discriminator beside resumeError. That is a public result-shape change, and it sits close to the open maintainer ruling on #15556 about whether a resume failure must reach the caller in an actionable shape (options A/B/C/D). ⛔ This card does not pick a shape and should not be actioned as an ordinary lane fix without that ruling being consulted — it may simply be an instance of it. A same-shape alternative that avoids the contract question is to log the discriminator at error the way resumeRecordedOutcome already does, which would at least make the fact recoverable from the trace; that is stated as an option, not a recommendation.
Not a duplicate
Reproduction harness: extend packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts's fixture and call recall on the pending request with a throwing mark_rejected.
Found while implementing #15389 (PR #15966); out of scope there and deliberately not fixed in it. No assignee, no lane label — left for triage.
Measured
Real
AutomationEngine+ realApprovalService, one live process, in-memory ObjectQL double for the approvals tables only. A flow parks at anapprovalnode; the reject branch's downstream node throws. The submitter then recalls the request, which resumes the run down therejectedge — and that resume strands it.What
ApprovalService.recallanswers:result.resumedfalseresult.resumeErrorresume of run 'run_2cfd…' failed: downstream blew up— prose onlyresult.request.statusrecalled— the withdrawal is durablestrandedDecisionDetails(result)null— no machine-readable envelope at allengine.hasSuspendedRun(runId)false— the run really is strandedengine.restoreConsumedSuspension(runId)restored: true— and it really is repairableControl, same harness, same strand, different door: the identical downstream failure reached through
decidethrowsRESUME_FAILEDcarrying{ finalized: true, decision, runId, repairable: true }(this is #13807's batch #37 landing, pinned indecision-strand-envelope.test.ts). So the difference is the door, not the strand.The mechanism
decide/sendBack/resubmitroute their resume throughApprovalService.resumeRecordedOutcome, which reads the engine'sAutomationResult.statusdiscriminator and derivesrepairablefromstatus === 'stranded'.recalldoes not. It callsserviceResumedirectly inside its owntry/catch(approval-service.ts, the non-revise-window arm ofrecall), and that catch keeps onlyerr.message— theresumeCode/resumeStatusthe error already carries are discarded one line before the result is built. So the repairability signal has a producer and, on this door, no consumer — the same shape #13807 found and fixed one door over.Why it matters more now than when
recall's tolerance was writtenrecall's decision not to throw is correct and should stay: the withdrawal and the record-lock release are the point, and they have already landed. This is not a request to makerecallfail.What changed is that the information is now actionable. Before #15389 there was nothing an operator could do with "this run is repairable" for an
approvalsuspension —restoreConsumedSuspensionre-armed the pause and no approvals door could continue it. PR #15966 adds that issuer (continueRestoredRun), sorepairable: truenow names a repair that exists and completes. On therecallpath a caller still cannot learn it, and the run's downstream work is silently abandoned.The natural repair is for
ApprovalRecallResultto carry the discriminator besideresumeError. That is a public result-shape change, and it sits close to the open maintainer ruling on #15556 about whether a resume failure must reach the caller in an actionable shape (options A/B/C/D). ⛔ This card does not pick a shape and should not be actioned as an ordinary lane fix without that ruling being consulted — it may simply be an instance of it. A same-shape alternative that avoids the contract question is to log the discriminator aterrorthe wayresumeRecordedOutcomealready does, which would at least make the fact recoverable from the trace; that is stated as an option, not a recommendation.Not a duplicate
restoreConsumedSuspensionre-arms the pause but the approvals door cannot continue it — a restored approval suspension can only be cancelled, never decided #15389 — that is the missing issuer for a restored approval suspension (fixed by fix(approvals): give a restored approval suspension an issuer, so it can be decided and not only cancelled #15966). This is a door that never reports the run is repairable in the first place. Different half of the same recovery story: approvals:restoreConsumedSuspensionre-arms the pause but the approvals door cannot continue it — a restored approval suspension can only be cancelled, never decided #15389 asks "can it be continued", this asks "is the operator ever told it can be".bubbleToParentfailure is swallowed, so an approval decision answers 200resumed: truewhile the run behind it is stranded — #13807's three-outcome shape, one level up #15556 — that is the subflow up-bubble the decision door is told nothing about. This is a direct, non-subflow strand where the engine DID reportstrandedand this door dropped it.POST /api/v1/approvals/requests/{id}/rejectreturns 500 while its effect lands AND strands the workflow run — three inconsistent outcomes from one call #13807 — that ruling landed the envelope onresumeRecordedOutcome's doors.recallwas not among them, and no card records that gap.Reproduction harness: extend
packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts's fixture and callrecallon the pending request with a throwingmark_rejected.