Reproduction steps:
- Build a workflow that fires an irreversible effect and then fails on a later
node:
node 1: an effect with action_class "external_send" (an SMS/message — by the
registry's own definition, "irreversible: no compensator"), which
commits;
node 2: any node that fails (e.g. an http node with no allow_domains policy
raises HttpPolicyError), triggering the saga rollback.
- Inspect the signed workflow receipt.
- Run repro_receipt_compensated_true_while_effect_stands_v099.py against a clean
v0.99 extraction. Output:
trace: [('effect','sms'), ('http','boom'), ('retry','boom'), ('rollback',None)]
outcome=ROLLED_BACK ; receipt.compensated=True ; SMS committed=True
SMS node action_class=external_send
Expected:compensated: true on a receipt should mean the committed effects were actually
reversed. An external_send effect has no compensator (it cannot be reversed), and
a compensator can fail its retries (the saga records it as an "orphan fault"), so
those effects STAND after the rollback. The receipt must not claim they were
compensated.
Actual:
run_workflow derives the flag purely from the outcome (workflow_engine.py ~1091):
"compensated": (outcome == "ROLLED_BACK")
and outcome is set to "ROLLED_BACK" on ANY node exception (~1078), regardless of
whether compensation reversed anything. The saga's compensation loop correctly
SKIPS a no-compensator step (if comp is None: continue) and records a FAILED
compensator as an orphan — but neither feeds back into compensated. So a run
that sent an SMS (external_send, un-reversible) and then failed sealscompensated: True; an auditor reading the receipt sees a fully compensated run
while a real irreversible outbound action was delivered and never reversed. The
same overstatement occurs whenever a compensator fails after its retries (the
effect stands as an orphan) — compensated still reads True.
Distinctness: this is broader than, and different from, the persist-failure saga
finding (which needs a DB persist failure to SKIP compensation). Here the saga
works perfectly and correctly cannot reverse an external_send — no failure of any
kind is required; every workflow that mixes an external_send (or reversible+later
failure with a flaky compensator) with a subsequent error mislabels its receipt.
Suggested fix:
Derive compensated from the saga's ACTUAL result, not the outcome. The saga
already knows: it skipped no-compensator steps and recorded orphans for failed
ones. Report compensated: true ONLY when every committed step has
compensation_status == "ok" and there were no orphans AND no committed
external_send (no-compensator) step; otherwise report compensated: "partial" (or
false) and list the effects that still stand (labels from the saga's orphan /
skipped set). A signed receipt must not assert a clean rollback over an
un-reversed irreversible effect.