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