0007-reversal-via-compensating-transfers.md 4.1 KB

Reversal via compensating transfers, not deletion

  • Status: accepted
  • Authors: Cesar Rodas
  • Date: 2026-06-29
  • Targeted modules: kuatia (ledger::reverse)
  • Associated tickets/PRs: N/A

Context and Problem Statement

Sometimes a committed transfer must be undone: a mistaken payment, a saga compensating a later failure. The ledger's whole value proposition is being auditable by replaying an append-only log (ADR-0001). How do we undo a transfer without breaking that property?

Decision Drivers

  • Audit integrity: history must never be rewritten; "what happened" includes the mistake and its correction.
  • Consistency with the model: an undo should use the same machinery as a normal commit, not a special back door.
  • Idempotency: undoing twice (e.g. a retried compensation) must not double-undo.
  • Composability: saga compensation needs a reliable, reusable undo.

Considered Options

Option 1: Delete or mutate the original postings/record

Physically remove the created postings (and restore consumed ones), or edit the transfer record to "undo" it.

Pros:

  • Good, because the post-undo state is "clean", as if it never happened.

Cons:

  • Bad, because it destroys history: the ledger is no longer reconstructible by replay, defeating ADR-0001.
  • Bad, because it is a privileged mutation path outside the normal commit model, with its own concurrency and crash hazards.

Option 2: A void/reversed flag on the transfer

Mark the original as voided rather than deleting it.

Pros:

  • Good, because the original row is preserved.

Cons:

  • Bad, because balances now depend on interpreting flags, not just summing postings: the projection is no longer "sum of Active postings."
  • Bad, because it adds mutable state to an otherwise append-only record and a second code path for "is this transfer effective?"

Option 3: A compensating transfer (an inverse envelope)

reverse(id) loads the original transfer and builds an inverse envelope: it consumes the original's created postings and recreates the original's consumed ones as new postings, then commits it through the normal commit_envelope path. Nothing is deleted or mutated; the reversal is itself a content-addressed transfer.

Pros:

  • Good, because history is fully preserved: both the original and its reversal appear in the log; balances remain "sum of Active postings."
  • Good, because it reuses the exact commit path (reserve → validate → finalize, recovery, idempotency) with no special undo machinery.
  • Good, because it is idempotent: the reversal envelope is deterministic and content-addressed, so committing it twice returns the same receipt.
  • Good, because it gives saga compensation a reliable, uniform primitive.

Cons:

  • Bad, because the post-undo state is not "as if it never happened": there are now two transfers (intended), which a naive reader might find noisy.
  • Bad, because reversing is a real commit and is subject to the same validation (e.g. it cannot reverse postings already consumed by a later transfer without that surfacing as a normal failure).

Decision Outcome

Chosen option: Option 3: reversal as a compensating transfer, because it is the only option that preserves the append-only, replayable audit log (ADR-0001) while reusing the normal, already-hardened commit and recovery path, and is idempotent by content-addressing.

Positive Consequences

  • reverse() is a thin wrapper over commit_envelope; saga finalize compensation uses it to undo a committed step.
  • The audit trail shows the original and the correction; balances stay a pure projection over Active postings.

Negative Consequences

  • Undo is visible as a second transfer (by design), not an erasure.
  • A reversal is subject to normal validation/availability: it can legitimately fail if the world moved on, surfacing as an ordinary error.

Links