Holds and settlement

Three numbers, not one

Wager, escrow, settle is this shape applied to a real game; the reference has the three calls.

balanceWhat the account holds
heldWhat is reserved
availablebalance - 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

balanceheldavailable
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.

ESC