What a double-entry ledger can promise without a transaction
A double-entry ledger on a store with only conditional writes: a journal entry lands whole, account totals catch up, and retries recognise work already applied.

A double-entry posting is the example database textbooks reach for when they explain transactions. Debit cash, credit revenue, and if a crash falls between the two your books no longer balance. In August, working with coding agents, I built a double-entry ledger as one of the reference applications for an application framework I was developing, and the record store underneath it can’t do what the textbook asks. It offers a conditional write to one key and an atomic counter. Each account lives in its own record, so no single write can move cash and revenue together.
Without the transaction, the useful questions become which parts of a posting can still be all-or-nothing, which failure you would rather be left with when a process dies halfway, and how anyone finds out that it happened.
The part that can be all-or-nothing is the journal entry. All the legs of a posting go into one immutable record, written only if that key doesn’t exist yet, so an entry is either there in full or not there at all. Before it is written the debits and credits are summed, and an entry that doesn’t balance, or that carries a zero or negative amount, is refused. Amounts are 64-bit integers in minor units such as cents. They travel as decimal strings, because a JSON number may pass through a JavaScript double and those stop being exact at 2^53, and an amount with a decimal point in it is refused rather than rounded. A correction never edits the original. It is a new entry with the legs mirrored and a link between the two in each direction, and writing that link is the one mutation the journal permits. Reversing an entry twice is refused, and so is reversing a reversal.
The all-or-nothing stops at the account totals. Each account also carries a running total, a projection of the journal, and a posting has to update every account it touches with one conditional write per account. I ordered the work so that the journal always goes first: take a sequence number, land the entry, then move the totals. That leaves a record of why each total should change, even if updating it is interrupted.
That order has a price, and a small example shows it. Take a sale for 123.45, a debit to cash and a credit to revenue. The entry lands whole, the cash total moves, and the process dies before the revenue total does. The journal is now complete and correct, cash agrees with it, revenue is behind it, and the account totals are out of balance by exactly 12345 minor units. A reader who arrives between the two writes catches the same state for a moment without any crash at all, and that is what it means for totals to lag. It would be an unpleasant thing to discover at the end of the month, so the app recomputes the ledger’s invariant, that debits and credits across the accounts net to zero, on every read and publishes the result instead of leaving the check to a nightly job. A posting caught between two accounts shows up as a number anyone reading the ledger can see. One that died before moving either total doesn’t, because those accounts still balance and are merely behind the journal, so the invariant is a strong check and not a complete one.
The recovery path begins when the request is delivered again. The framework treats duplicate delivery as normal. Every action therefore carries an idempotency key and a durable result record, and a replay of a finished action gets the first answer back instead of deriving a new one. A replay of an unfinished posting retraces the same work. The digest of the action and its key finds the sequence number the first attempt took, because the allocation was remembered under that digest before the number was used. If two attempts race for a number, the store’s atomic counter gives them different ones and the loser abandons its own, which leaves a gap in the sequence rather than a duplicate. The journal write finds its entry already there and reads it back, and the saga engine re-enters at the first phase with no completion record.
That completion record is not what makes the retry safe. A phase’s effect and the record that it ran are separate writes, so a crash between them runs the phase a second time, and the phase has to recognise its own completed work. For the totals, the check is a stamp. Each account carries the identity of the last entry it applied, written in the same compare-and-swap as the new total. In the interrupted sale above, if no other posting has changed those accounts, the retry finds its own stamp on cash, leaves cash alone and goes on to revenue. The stamp only records the latest entry: once another posting replaces it, this check no longer recognises the earlier effect. The compare-and-swap loop makes eight attempts before reporting contention; if it stops partway through, the journal remains complete while some totals are still behind.
The runner only knows how to go forward. Once the journal entry exists, recovery has to bring the totals up to date; it does not undo the entry. A posting that turns out to be a mistake is a business event with an entry of its own.
Declaring the phases makes it possible to enumerate every boundary a crash can fall between. Each phase also declares the state left behind by a crash, so the order and its consequence can be read together. The reusable saga harness tests two windows: between phases, and after a phase’s effect but before its completion record. Both are needed, because only the second runs a phase twice, and a phase that cannot recognise its own completed work passes the first without trouble.
The journal records what the totals should reflect, and each entry lands as one balanced record. The totals are a separate projection: a partial update can make them disagree, while a posting that has updated none of them leaves them balanced but stale. Writing the journal first makes either state explainable. Repair also depends on retaining enough identity to tell an applied effect from an unapplied one, and a last-entry stamp answers that question only while the entry remains the latest update.
Chris Chabot, November 2026