ADR-026 — Compensation: undoing completed work by throw-triggered handlers¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-07-20 |
| Owner | Ruslan Gabitov |
| Refines | ADR-006 v.4 §2.2/§2.3 (the explicit deferral this ADR fulfills: the compensation-boundary eligibility window is decided there, the handling — throw resolution, reverse-order invocation, snapshot restore, presumed-abort, the compensate-on-terminate switch — is decided here), ADR-018 v.1 (the boundary attachment model; Compensation was deferred from its 0.1.0 trigger scope), ADR-023 v.2 (the execution-scope tree the completion ledger lives on), ADR-013 v.2 (the reserved Compensating slot this ADR populates), ADR-001 v.6 §4 (the single-writer loop that owns all compensation state) |
Compensation is the last event behavior of the events epic (#90): the mechanism by which a process undoes work that already completed successfully. It is the inverse of the error/escalation family — those react to something going wrong while work is in flight; compensation reaches back to work that finished, after the process decides its effects must be reverted.
1. Context & problem¶
The model layer already carries CompensateEventDefinition (an activityRef
and a wait flag), the Association artifact ("used to show the Activity used
for compensation" — kept in the executable subset for exactly that reason), and
the isForCompensation marker on activities. Nothing executes any of it: a
compensation throw drops onto the generic event bus with no subscriber, no
handler is ever recorded as eligible, and no completed activity's data survives
its scope.
Three prior decisions constrain this ADR:
- ADR-006 v.4 §2.3 owns the subscription-layer fact: a compensation
boundary handler is not a live subscription — it becomes eligible when
its guarded activity reaches
Completed(a data snapshot is captured at that moment) and stays eligible until the enclosing scope finishes. The handling is explicitly delegated to this ADR. - ADR-006 v.4 §2.2 fixes the terminate default: Terminate runs NO
compensation (§13.5.6 — "without compensation or event handling"); an
optional, off-by-default
compensate-on-terminateextension is to be decided here, including its activation scope (process-/instance-level, explicitly not a blunt engine-wide switch). - ADR-001 v.6 §4 / ADR-023 v.2: all instance state mutation happens on the single-writer loop, and the execution scope tree is the unit of containment, cancellation, and data visibility. Whatever records "what completed, in what order, with what data" belongs to the loop-owned scope state.
Object model (BPMN 2.0, verbatim from the vendored extract)¶
CompensateEventDefinitioncarriesactivityRef: Activity [0..1]andwaitForCompletion: boolean [1] = true(event-definitions.md §10.4.5).- Valid positions: Start (event sub-process only), IntermediateThrow, End, Boundary (conformance.md). There is no in-flow Compensation Intermediate Catch — the catch side exists only as a boundary or an event-sub-process start.
- A compensation handler is one of (compensation.md §13.5.5):
a boundary Compensation event on the activity, linked by an
Associationto a Compensation Activity markedisForCompensation = true; or a Compensation Event Sub-Process (start event carryingCompensateEventDefinition) inside a Sub-Process. - ADR-006 §2.1 classifies Compensation under the direct resolution reach strategy — aimed at a specific Process/Activity instance, never published, never broadcast through the hub.
2. Decision¶
2.1 The completion ledger — the scope remembers what finished¶
Each open execution scope keeps a completion ledger: an ordered record of
the activities inside it that completed successfully and are compensable —
an activity is compensable when a compensation handler is attached to it (a
Compensation boundary with its associated isForCompensation activity) or when
the scope carries a Compensation Event Sub-Process covering it.
An entry is appended at the activity's Completed transition and carries:
- the activity's identity and its completion ordinal (the ledger's order is the reverse-compensation order — §2.4);
- the data snapshot captured at that instant (§2.5);
- the resolved handler reference.
The ledger lives and dies with its scope for normal completion of the enclosing scope: per ADR-006 §2.3, eligibility ends when the enclosing scope finishes — at that point the ledger (and its snapshots) is discarded. While the scope that threw compensation is still open, the ledgers of its completed child scopes must still be reachable (a Sub-Process that completed inside the current scope is itself a compensable, ledgered entry) — so a child scope's ledger is folded into its parent's ledger entry when the child completes, rather than being dropped at child close. Activities that never completed (failed, canceled, interrupted) never enter a ledger — the presumed-abort principle (§13.5.5): only successfully completed work is compensable; anything else is presumed already undone.
Why a ledger and not a live subscription: ADR-006 §2.3 already decided a compensation boundary is not armed as a watcher — there is no live trigger to wait for. What the engine needs at throw time is a record of the past (what completed, in what order, with what data), and only the loop can own that record race-free (ADR-001).
2.2 Triggering — a throw event, targeted or scope-wide¶
Compensation is triggered only by a throw Compensation Event — an Intermediate Throw in normal flow or a Compensation End Event (§13.5.5; ADR-006 §2.2 keeps Terminate compensation-free). The throw resolves directly against the ledgers (the ADR-006 §2.1 direct-resolution strategy) — never through the event hub:
- Targeted (
activityRefset): compensate exactly that activity — look up its ledger entry in the throwing scope (or the entries folded from completed child scopes) and run its handler. If the referenced activity has no ledger entry (it never completed, or its scope already finished), the throw is a no-op for that target — logged, never silently dropped (§2.6). - Scope-wide (no
activityRef): compensate all ledgered activities of the throw's enclosing scope, in reverse completion order (§2.4). Per the spec's scoping rule, the default target context is the throw's own containing activity/process: a throw inside a Sub-Process's handler compensates that Sub-Process; a throw at process level compensates the process's completed activities.
waitForCompletion (spec default true) governs the throwing token:
true — the throw parks until every invoked handler completes, then
continues (End: then ends); false — fire-and-forget: handlers are launched
and the throwing token continues immediately. Either way the throw is
non-fault: like Escalation, compensation is a normal control-flow act, not
an error.
2.3 Handlers — how compensation work runs¶
- Boundary handler: the Compensation boundary on the completed activity
routes to its associated Compensation Activity (
isForCompensation = true, linked byAssociation). The compensation activity is a normal activity executed once per invocation, in a fresh track, but with the ledger entry's data snapshot as its read context (§2.5). It is excluded from normal token flow: the extract models it as a specialized "black-box" Compensation Activity reachable only through itsAssociation(§13.5.5), so the engine validates that anisForCompensationactivity carries no incoming/outgoing normal sequence flow — reject-at-registration, the fail-fast idiom. - Compensation Event Sub-Process: an event sub-process whose start carries
CompensateEventDefinitionhandles compensation of its enclosing Sub-Process as a whole — it is that Sub-Process's own compensation handler, invoked when the completed Sub-Process is compensated (targeted or swept by a scope-wide throw). It reuses the ADR-023 §2.10 handler machinery — but is never hub-armed (the Error/Escalation no-waiter pattern): it is recorded as the Sub-Process's handler in the parent's ledger entry at the Sub-Process's completion. - The
cancelActivitynotion does not apply to a Compensation boundary (there is nothing to interrupt — the activity already completed); the flag is meaningless and the model rejects a non-default value.
The compensable unit of this conception is an activity with its own handler — a leaf activity, or a Sub-Process with its own handler (boundary or compensation event sub-process). The spec's default/implicit compensation — a completed Sub-Process without its own handler recursively compensating its children in reverse order — is designed-for: the ledger's child-folding (§2.1) is shaped to carry it, and it completes this conception in a follow-up revision (§2.9). Until then, sweeping a handler-less completed Sub-Process is a logged no-op per §2.6.
2.4 Order — reverse completion, from the ledger¶
A scope-wide compensation runs handlers in reverse completion order — the
ledger walked backward (§13.5.5: compensation "in the reverse order" of forward
execution; sequence-flow and data dependencies invert). The completion ordinal
recorded at Completed time is the authority; the engine does not re-derive
graph dependencies at throw time. Handlers run sequentially in that reverse
order — the spec permits concurrency only where the
original activities were independent, and sequential-reverse is always a
conformant serialization of that partial order (an engine choice, revisitable
without a contract change).
2.5 The data snapshot — compensate against the past¶
At an activity's Completed transition, the engine captures a value-copy
snapshot of the data readable in the activity's context (§13.5.5: a snapshot
"is taken and kept for later use" and the handler sees "the original data
context restored"). When a handler runs, that snapshot is its read
surface — the handler sees the world as it was when the work finished, not
the current state. Handler writes go to the current live scope through
the normal commit path (compensation exists to change current state — e.g.
issue a refund record — while reading what the completed work knew).
Snapshots are per-ledger-entry; for a loop/MI activity each completed instance
snapshots separately (per §13.5.5's per-instance rule — MI compensation
specifics ride ADR-025 §2.10's deferral).
2.6 Unresolved compensation is logged, never silent, never a fault¶
A throw whose target has nothing to compensate — an activityRef that never
completed or whose scope already closed, or a scope-wide sweep over an empty
ledger — is a non-fault no-op that is always logged and emits an
Unresolved-phase fact. This is the engine's cross-cutting
uncaught-events-always-log rule (established for Escalation in ADR-006's §2.4
lineage): the spec is silent on observability here, and a compensation throw
that silently does nothing is a debugging trap. Execution continues per §2.2.
2.7 Lifecycle & observability¶
Compensation populates the state slot ADR-013 v.2 reserved:
- Activity-side transitions (§13.5.5):
Completed → Compensatingwhen its handler is invoked;Compensating → Compensatedon handler success;Compensating → Failedwhen the handler itself fails — the handler's failure is a real fault raised in the throwing scope's context and travels the ADR-006 §2.6 Error chain (a broken undo must not vanish);Compensating → Terminatedwhen an instance terminate interrupts an in-flight compensation (ADR-006 §2.2's mandatory default). - A dedicated Compensation fact kind with phases:
Thrown(at the throw, with target/scope attribution),Eligible(a ledger entry recorded atCompleted),Compensating/Compensatedper handler invocation, andUnresolved(§2.6, echoed at Warn). The Escalation kind's shape is the precedent;Compensating/Compensatedfill ADR-013's reserved names. - The ledger itself is observable — it is a new first-class runtime object
(§2.1), so its whole lifecycle emits facts, not only the handler firings.
Every transition an entry can take is covered: recorded (
Eligible, at the activity'sCompleted, carrying the completion ordinal), folded (Folded— a completed child scope's entries reparented into the enclosing scope's ledger, §2.1), consumed (Compensating/Compensatedabove), and discarded (Discarded— the enclosing scope finished and its ledger, with any never-compensated entries and their snapshots, is dropped; the normal end of the ADR-006 §2.3 eligibility window, echoed at Debug). With these, an operator can reconstruct at any moment what would compensate right now and in what order, and audit where every entry went — recorded → folded* → consumed | discarded. Fact details carry the scope path, the activity, and the ordinal, so the observer stream is the ledger's audit log.
2.8 compensate-on-terminate — decided, off by default¶
ADR-006 §2.2 delegated the optional extension to this ADR. Decision:
- Activation scope: a per-process registration option (declared when the process is registered, so every instance of that definition behaves alike) — honoring ADR-006's constraint that it is not an engine-wide switch. No per-instance override in the first design (YAGNI; revisit on demand).
- Semantics when enabled: a Terminate End Event (or instance terminate)
first runs a scope-wide compensation sweep (§2.2's broadcast, reverse
order,
waitForCompletion = truesemantics) of the terminating scope's ledger, then proceeds with the normal terminate cascade. A second terminate arriving during that sweep interrupts it (Compensating → Terminated) — terminate always wins. - Default: off — the conformant §13.5.6 behavior (no compensation on terminate) stays the out-of-the-box contract.
2.9 Designed-for & out of scope¶
- Transaction / Cancel-triggered compensation. A Transaction Sub-Process cancel "terminates all running Activities and compensates all successfully completed Activities" (§10.7 / the Cancel boundary rules). The Transaction type itself does not exist in the engine (ADR-023 keeps it designed-for under #91), and no runtime Cancel handling exists. Cancel-triggered compensation therefore rides the Transaction workstream, which will consume this ADR's ledger and sweep machinery as-is. Nothing here precludes it: a transaction-cancel is, mechanically, §2.2's scope-wide sweep triggered by Cancel instead of a Compensation throw.
- Call Activity. Compensating a completed Call Activity means re-entering a finished child instance — a cross-instance compensation protocol (the child's ledger died with the child). Explicitly out of scope; a Call Activity never enters a ledger in this design. Revisit with durable persistence (ADR-009's rehydration line).
- Error-driven default compensation. The presumed-abort principle's second half (§10.7): a Sub-Process with no error handler that suffers an error should, by default, auto-compensate its completed contained activities before the error propagates. Mechanically this is §2.2's scope-wide sweep fired from the Error path instead of a Compensation throw — another consumer of the ledger, like Transaction-cancel. Designed-for: this conception leaves the decided Error semantics (fault propagation up the scope chain, ADR-006 §2.6) unchanged; the auto-sweep joins the error path in a follow-up conception that must co-design with the Error chain's cancellation ordering. Until then the engine's error behavior remains the current conformant-subset one, called out as an engine note (§2.10).
- Recursive default compensation — designed-for in §2.3.
2.10 Engine notes (deviations & choices)¶
| Choice | Standard position | Engine choice |
|---|---|---|
| Sequential reverse-order sweep | permits concurrency between independent compensations (§13.5.5) | strictly sequential reverse completion order — a conformant serialization; simpler, deterministic (§2.4) |
| Unresolved throw is logged | silent no-op ("compensation is not performed" when nothing qualifies) | logged + Unresolved fact, execution continues — the cross-cutting always-observable rule (§2.6) |
| Handler reads snapshot, writes live scope | "original data context restored" (§13.5.5); silent on write targets | reads = snapshot; writes = normal commit into the live scope (§2.5) |
compensate-on-terminate |
terminate never compensates (§13.5.6) | conformant default kept; optional per-process-registration switch decided here, off by default (§2.8) |
| Compensable set | any completed activity | this conception: activities with their own handler; handler-less Sub-Process recursion designed-for (§2.3) |
| Error without an error handler | auto-compensates the Sub-Process's completed children (§10.7 presumed abort) | designed-for (§2.9) — errors keep the decided fault-propagation semantics meanwhile |
| Attribute name | waitForCompletion (§10.4.5) |
the model aligns on the spec's name — the stub's divergent field naming is corrected by the landing SRD |
3. Standard grounding¶
| Claim | Source |
|---|---|
Trigger = throw Compensation Intermediate/End; activityRef targeted vs default-context scope-wide |
compensation.md (§13.5.5); conformance.md positions row |
| No in-flow Compensation Intermediate Catch; catch = Boundary or Event-Sub-Process start only | conformance.md; the model stub's position notes |
Handler forms: boundary + Association + isForCompensation activity; compensation event sub-process |
compensation.md (§13.5.5, §13.2.2) |
Eligible at Completed, snapshot captured then; eligibility ends with the enclosing scope |
ADR-006 v.4 §2.3 (the compensation-boundary row); compensation.md |
| Reverse-order compensation; dependency inversion; MI parallel/sequential rules | compensation.md (§13.5.5); multi-instance.md §Compensation |
| Presumed abort — only completed work compensates; error-driven default compensation for handler-less Sub-Processes | compensation.md (§10.7) |
waitForCompletion: boolean = true |
event-definitions.md (§10.4.5) |
Terminate runs no compensation; terminate interrupts Compensating |
ADR-006 v.4 §2.2; event-handling.md (§13.5.6) |
| Cancel/Transaction: cancel compensates completed inner work; Cancel only in Transaction | event-handling.md (§10.7, boundary table); compensation.md |
Completed → Compensating → Compensated/Failed/Terminated lifecycle |
compensation.md (§13.5.5 state table); ADR-013 v.2 reserved slots |
| Direct-resolution reach (never hub-published) | ADR-006 v.4 §2.1 strategy table |
4. Alternatives considered¶
- Hub-delivered compensation (the Signal pattern). Rejected: ADR-006 §2.1 classifies Compensation as direct resolution — there is no waiting subscriber to deliver to; the catch side is a record of the past, not a live wait. A hub round-trip would add an async hop to what is a loop-local lookup.
- Arming the compensation boundary as a live watcher (the ADR-018 pattern). Rejected — contradicts ADR-006 §2.3's decided row (not a live subscription); a watcher over a completed activity has no window to watch.
- Deriving compensation order from the graph at throw time (invert sequence-flow/data dependencies). Rejected: the recorded completion order is simpler, race-free (single-writer ledger), and is itself the ground truth the spec's dependency rules approximate — what actually completed after what.
- Snapshot-free compensation (handlers read live data). Rejected — contradicts §13.5.5's explicit snapshot/restore semantics; a handler compensating against mutated state undoes the wrong thing.
- Engine-wide
compensate-on-terminateswitch. Rejected by ADR-006's constraint; per-process registration scope chosen (§2.8). - Ledger as a separate global registry keyed by activity id (instead of scope-owned with child folding). Rejected: eligibility is scope-bounded by ADR-006 §2.3 — a scope-owned ledger makes the "discard when the enclosing scope finishes" rule structural instead of policed.
5. Consequences¶
Positive: the last #90 event behavior lands on the same loop-owned, scope-structured machinery as Error/Escalation; the ledger + snapshot mechanism is exactly what the future Transaction (#91) and default-recursion work consume; ADR-013's reserved lifecycle slots get filled.
Negative / cost: the completion ledger and snapshots hold memory
proportional to completed compensable work for the life of the enclosing scope
— the price §13.5.5 demands; models without compensation handlers pay nothing
(no handler ⇒ no ledger entry). Snapshot capture adds a value-copy at
Completed for compensable activities only.
Follow-ups this conception sets up: default recursive compensation (§2.3),
compensate-on-terminate activation (§2.8), Transaction/Cancel (#91, §2.9), MI
compensation (ADR-025 §2.10).
Document History¶
| Version | Date | Author | Change |
|---|---|---|---|
| v.1 | 2026-07-20 | Ruslan Gabitov | Draft conception. Fulfills ADR-006 v.4 §2.2/§2.3's explicit deferral: compensation handling = a loop-owned, scope-structured completion ledger (ordered compensable completions + per-entry data snapshots captured at Completed, folded child→parent at scope completion, discarded when the enclosing scope finishes) resolved directly by a throw Compensation Event — activityRef-targeted or scope-wide in reverse completion order, waitForCompletion parking the thrower; handlers = the boundary + Association/isForCompensation pair or the compensation Event Sub-Process, never hub-armed; unresolved throws logged (never silent, never a fault); Completed → Compensating → Compensated/Failed/Terminated fills ADR-013's reserved slots (handler failure = a real Error-chain fault); the ledger's own lifecycle is observable (recorded/Eligible → Folded → consumed | Discarded), making the observer stream the ledger's audit log. Designed-for: recursive default compensation of handler-less Sub-Processes; error-driven default compensation (§10.7 presumed-abort auto-sweep on a handler-less Sub-Process error); compensate-on-terminate as a per-process registration option (off by default). Out of scope:* Cancel/Transaction (rides #91 on this machinery), Call Activity cross-instance compensation. Standard-grounded against §13.5.5/§10.7/§10.4.5/§13.5.6 via the vendored extract. Implementation rides the accompanying SRD. |