declareWorkflow hands a workflow context.activities and context.errors. It hands it nothing for the walk-back, so every saga writes its own — in btravstack/start's fulfillOrder, roughly half the workflow body is compensation plumbing.
Depends on btravstack/unthrown#268, which is where the primitive belongs: an undo stack unwound LIFO is a Result combinator, not Temporal's business.
What is Temporal's business, and the reason this is a separate issue rather than a call site of that one: which failures compensate.
A declared contract error is a permanent domain answer — compensate, then re-mint it against context.errors. ACTIVITY_ERROR_TAG and ACTIVITY_CANCELLED_TAG are not: an activity that failed unmodelled or was cancelled left state nobody can see, and un-deciding what you cannot see is a second bug. Today fulfillOrder states that in a comment and enforces it by hand, with one
.with(P.tag(ACTIVITY_ERROR_TAG), P.tag(ACTIVITY_CANCELLED_ERROR_TAG), (error) => ErrAsync(error))
arm per step — repeated, easy to omit, and invisible when omitted.
Effect's Workflow.withCompensation gets this wrong by default: it unwinds on any failure. Shipping the correct default is the differentiator here, so it must not be an option a caller has to go looking for.
Shape
A saga reachable from implementation (on context, or exported from @temporal-contract/worker/workflow), wrapping the unthrown primitive with this policy:
- compensate on a declared contract error;
- do not compensate on the two machinery tags — propagate untouched, so
propagateActivityFailure still re-raises the platform's original failure;
- an explicit opt-in for the workflow that genuinely wants to compensate on cancellation (a long-running step holding a reservation, say).
fulfillOrder then reads as three steps and two undos, and the machinery-tag arm disappears from every one of them.
Note for the implementation
Whatever this wraps must stay pure control flow — no timers, no Date.now, no randomness — or it breaks replay determinism inside the sandbox. Same constraint as unthrown#268, restated here because this is the package that knows the sandbox exists.
declareWorkflowhands a workflowcontext.activitiesandcontext.errors. It hands it nothing for the walk-back, so every saga writes its own — inbtravstack/start'sfulfillOrder, roughly half the workflow body is compensation plumbing.Depends on btravstack/unthrown#268, which is where the primitive belongs: an undo stack unwound LIFO is a
Resultcombinator, not Temporal's business.What is Temporal's business, and the reason this is a separate issue rather than a call site of that one: which failures compensate.
A declared contract error is a permanent domain answer — compensate, then re-mint it against
context.errors.ACTIVITY_ERROR_TAGandACTIVITY_CANCELLED_TAGare not: an activity that failed unmodelled or was cancelled left state nobody can see, and un-deciding what you cannot see is a second bug. TodayfulfillOrderstates that in a comment and enforces it by hand, with onearm per step — repeated, easy to omit, and invisible when omitted.
Effect's
Workflow.withCompensationgets this wrong by default: it unwinds on any failure. Shipping the correct default is the differentiator here, so it must not be an option a caller has to go looking for.Shape
A
sagareachable fromimplementation(oncontext, or exported from@temporal-contract/worker/workflow), wrapping the unthrown primitive with this policy:propagateActivityFailurestill re-raises the platform's original failure;fulfillOrderthen reads as three steps and two undos, and the machinery-tag arm disappears from every one of them.Note for the implementation
Whatever this wraps must stay pure control flow — no timers, no
Date.now, no randomness — or it breaks replay determinism inside the sandbox. Same constraint as unthrown#268, restated here because this is the package that knows the sandbox exists.