Reservation protocol and the posting lifecycle
- Status: accepted
- Authors: Cesar Rodas
- Date: 2026-06-29
- Targeted modules:
kuatia-types (PostingStatus, ReservationId),
kuatia-storage, kuatia (saga)
- Associated tickets/PRs: N/A
Context and Problem Statement
A commit must consume input postings exactly once, even when many commits run
concurrently and the commit itself is a multi-step saga (ADR-0002) over a dumb
store (ADR-0003) with no global transaction. Two sagas must never both spend
the same posting, and a posting reserved by one saga must not be finalized or
released by another. How is exclusive, recoverable ownership of inputs achieved
without locking account balances?
Decision Drivers
- Double-spend safety: a posting can be consumed at most once,
unconditionally.
- No hot-row locking: preserve the UTXO concurrency of ADR-0001; do not
serialize on an account balance.
- Survives the saga's lifetime: a reservation must hold across reserve →
validate → finalize and across a crash + recovery.
- Ownership: only the saga that reserved a posting may finalize or release
it.
- Fits dumb storage: expressible as single atomic conditional updates that
return affected-row counts (ADR-0003).
Considered Options
Option 1: Database row locks (SELECT … FOR UPDATE) per posting
Lock the posting rows for the duration of the commit.
Pros:
- Good, because it gives strict mutual exclusion within one database
transaction.
Cons:
- Bad, because it requires a transaction spanning the whole commit, which the
saga model deliberately avoids (ADR-0002/0003).
- Bad, because held locks block other workers and do not survive a crash
(the lock is gone, but no record says the posting was claimed).
- Bad, because it ties the design to a locking, transactional store.
Option 2: Optimistic balance CAS per account
Guard each commit with a compare-and-set on the account balance.
Pros:
- Good, because it avoids long-held locks.
Cons:
- Bad, because it serializes on a per-account balance (the hot row ADR-0001 set
out to avoid) and conflates "did this posting get spent" with "did the
balance change."
- Bad, because it does not, by itself, record exclusive ownership of specific
inputs for recovery.
Option 3: A three-state posting lifecycle with a reservation token
A posting is Active → PendingInactive → Inactive.
reserve_postings(ids, rid) flips Active → PendingInactive and stamps each
with a ReservationId, as a single atomic conditional update
(… WHERE status = Active). release_postings reverts
PendingInactive → Active for the owning rid; finalize flips
PendingInactive (owned by rid) → Inactive. The reservation id is durable
(persisted with the write-ahead record, ADR-0003), and every later mutation is
conditioned on ownership.
Pros:
- Good, because reservation is a single atomic conditional update. Two sagas
cannot both move the same
Active posting to PendingInactive; the loser
sees zero rows affected. Double-spend safety is unconditional and lock-free.
- Good, because the
PendingInactive state plus ReservationId is durable
ownership that survives the multi-step saga and a crash, enabling recovery to
tell "reserved by us" from "taken by someone else."
- Good, because it expresses cleanly over dumb storage (counts, not locks) and
keeps balances out of the critical section.
- Good, because compensation is natural: release reverts the reservation.
Cons:
- Bad, because a posting carries lifecycle state and an optional reservation
column (more than an immutable UTXO).
- Bad, because a reservation orphaned by a crash must be resolved by recovery
(roll-forward) rather than by a lock simply being dropped. ADR-0003 handles
this.
Decision Outcome
Chosen option: Option 3: the three-state posting lifecycle with a durable
ReservationId, because it is the only option that gives unconditional,
lock-free double-spend safety and durable, recoverable ownership across a
multi-step saga, while expressing as the atomic, count-returning instructions
the dumb store provides.
Positive Consequences
reserve_postings is the concurrency gate; the saga reads its affected-row
count to know it won the reservation (ADR-0003's count interpretation).
- Recovery distinguishes "reserved by this saga" / "already finalized by us"
from "taken by another transfer," which is what makes phase-tracked
roll-forward safe (ADR-0003).
- Balances never enter the critical section. UTXO concurrency is preserved.
Negative Consequences
- Postings are not pure immutable UTXOs; they carry
status + reservation.
- Crash-orphaned reservations are resolved by recovery, not by lock release.
Links