Three numbers, not one
Wager, escrow, settle is this shape applied to a real game; the reference has the three calls.
balance | What the account holds |
held | What is reserved |
available | balance - held — what can still be spent |
A hold does not move the balance. It raises held, which lowers available — and the overdraft constraint is what makes that safe under any interleaving.
That is the whole idea: the stake is unspendable without having been spent, so the outcome can still go either way.
The shape
await ledger.hold("alice", "chips", 100, "bet:hand-9:alice");
// she lost → the stake is spent
await ledger.capture("bet:hand-9:alice");
// pushed → the stake returns
await ledger.release("bet:hand-9:alice");The ref is the handle. There is no separate hold id — the string you chose when you placed the bet is the string that settles it.
What each one does to the three numbers
balance | held | available | |
|---|---|---|---|
| Hold 100 | — | +100 | −100 |
| Capture | −100 | −100 | — |
| Release | — | −100 | +100 |
A capture moves both, which is why it is one atomic operation rather than a release followed by a debit — there is no instant at which the player could have spent those chips somewhere else.
The constraint that makes it safe
CHECK (balance >= 0 AND held >= 0 AND held <= balance)held <= balance is the half doing the work here. It is what stops a player reserving the same chips twice — a second hold that would push held past balance aborts, and surfaces ledger/insufficient_funds.
Including against a balance another concurrent operation just lowered, because the check runs at write time on the value being written.
Three states, and settling twice is caught by the state
A hold is open, then captured or released.
Both settlement calls match on status = "open", so a second capture finds nothing to capture and raises ledger/hold_not_open naming the current state.
That is a different guard from the idempotency ref, and deliberately: the ref already did its job when the hold was placed. What is being prevented at settlement is not a duplicate row but a second resolution of a decision already made.
Why not just debit and refund
Because the refund is a promise, and a promise is not a guarantee.
A debit-then-refund model spends the chips. Between those two moments the player’s balance genuinely does not contain them — so a concurrent read shows the wrong number, a concurrent debit succeeds against a balance that is about to be restored, and a crash in between leaves money that was taken and not given back with nothing in the schema saying so.
A hold is a row. It exists, it names its ref, it names its state, and an unsettled one is findable.
An unsettled hold is a question you can ask
open holds that have outlived their outcome are exactly the reconciliation query worth running: whose stake is still reserved for a hand that finished.
The ledger does not expire them for you, and that is on purpose — a hold that timed out on its own would release a stake the game may still be settling, which is the one thing worse than a stuck hold.
Composing with a game
Hold at the wager, capture or release at the result. The multiplayer session that owns the outcome is the thing that calls it, in-process, against its own DB binding.
No glue code between the two — the ledger is a primitive, and a game model calls it the way it calls anything else.