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.
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 getsERR_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 endedPOSTEDorVOIDED. - 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_reservationspages 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.