Docs/Ledger APIv0.0.1-preview

Holds — reserve, commit, rollback

Two-phase value movement with first-class holds that cannot orphan, leak, or silently double-settle.

On this page
  1. The lifecycle
  2. Why holds can’t leak
  3. Batch settlement
  4. Reservation expiry
  5. Tiered holds

An authorization that captures later. An escrow that releases on delivery. A payout staged before a compliance check. These are all the same shape: hold the funds now, decide later — and it’s the shape most often improvised badly with a status column and a cleanup job.

In TulaDB the hold lifecycle is part of the engine’s contract.

The lifecycle

Transition Debit side Credit side Phase after
reserve Hold placed — available balance drops Nothing posted PENDING
commit Hold released, debit posts Credit posts POSTED
rollback Hold released Nothing posted VOIDED

A reserve is submitted exactly like a transfer — same fields, same idempotency tuple:

r = c.reserve(TransferCreate(
    source_id=1, ledger_id=1, currency=840,
    debit_account_id=1001, credit_account_id=1002,
    amount=5_000, txn_id="auth-7781",
))
# r.phase == PENDING; debit account's available_balance is down 5000,
# posted_balance unchanged.

Then settle it — by recomputing identity from the tuple, so the settling service doesn’t need to have seen the reserve’s response, or by the transfer_id from the result:

c.commit_by_key(1, 1, 840, "auth-7781")
# or release it:
c.rollback_by_key(1, 1, 840, "auth-7781")
# or, holding the reserve's result: Python carries r.transfer_id as an int,
# so pass its 32 wire bytes (little-endian)
c.commit_transfer_id(r.transfer_id.to_bytes(32, "little"))

Why holds can’t leak

Everything that makes hand-rolled holds dangerous is closed off structurally:

  • A hold is engine state, not a flag. Available balance already reflects it; no query can accidentally “forget the WHERE clause” and overstate spendable funds.
  • Transitions are validated. Committing a rolled-back hold or rolling back a committed one returns ERR_WRONG_PHASE; settling the same hold the same way twice returns the original result — never a second application. Under concurrent commit-vs-rollback races, exactly one wins, durably, and the loser gets ERR_WRONG_PHASE. That status is classed retryable because it is also the answer to a duplicate still in flight; a retry is safe but cannot change a finished lifecycle, so read the record (get_transaction_by_key) to see whether the hold ended POSTED or VOIDED.
  • Transitions are idempotent. Retrying a commit returns the commit’s original outcome (OK_DUPLICATE), so at-least-once delivery on your settlement queue is safe.
  • Pending holds are enumerable. list_pending_reservations pages through every open hold in commit order — your reconciliation job reads the engine’s truth instead of reconstructing it.
pending = q.list_pending_reservations(limit=256)

Batch settlement

Commit and rollback batch operations settle up to 1,024 holds per frame with per-item outcomes — built for capture sweeps and end-of-day settlement runs:

ids = [c.derive_transfer_id_bytes(1, 1, 840, f"auth-{n}") for n in batch]
results = c.commit_batch(ids)

Reservation expiry

By default, holds live until you settle them — there is no implicit timeout silently releasing funds. For deployments that want a bound, reservation expiry is a per-ledger policy: a ledger’s reserve_ttl_ns (0 = no expiry, the default) is set when the ledger is created, or changed later by an operator (tulactl ledger-set-expiry --ledger N --ttl-seconds S, or ledger_set_expiry in the Python SDK). The engine capability behind it is always on in this release; nothing expires unless a ledger sets a TTL.

The leader stamps each reserve with a deadline when it is taken — reserve time plus the ledger’s TTL in force at that moment — and expiry itself is a replicated operation the leader commits, so every node expires the same hold at the same log position. A hold still pending at its deadline moves to phase EXPIRED and releases the debit side; a late commit returns ERR_RESERVE_EXPIRED — a durable business outcome, not a mystery.

A TTL change applies to new reserves only: a reserve taken before it keeps its own deadline (or none). If your design depends on expiry, set the TTL on the ledger before go-live and treat ERR_RESERVE_EXPIRED as a first-class outcome in the settlement path.

Tiered holds

Ledgers in position–settlement mode get the same lifecycle across four accounts at once — tiered_reserve, then tiered_commit_transfer_id / tiered_commit_by_key or tiered_rollback_transfer_id / tiered_rollback_by_key (and the tiered_*_batch forms) in the Python SDK — with the same atomicity: all legs hold together, settle together, or release together. An explicit reject is never a hold: a reserve or tiered reserve carrying the reject flag is refused as malformed (ERR_WIRE_PARSE) rather than placed.