Fundamentals
1 · Why double-entry rather than a balance column?
1 · Why double-entry rather than a balance column?
UPDATE balance = balance - 1000 records that money left; it does not record where it went, so “do we hold what we owe?” is unanswerable.Follow-up: isn’t that just an audit log next to the balance? No: an audit log is written alongside the truth and can drift from it. Here the log is the truth and the balance is derived, so drift is impossible by construction rather than by discipline.2 · Why is a customer's balance a liability rather than an asset?
2 · Why is a customer's balance a liability rather than an asset?
asset.float.bank.EUR against the sum of EUR customer liabilities and you have “do we hold what we owe?” as a query.Follow-up: what happens if they diverge? That is a reconciliation break, and it should open a case rather than be silently corrected. The entries are the record; a mismatch means either an external movement was not recorded or a recorded movement did not happen. Both need a human.3 · Why must amounts be positive, with direction carrying the sign?
3 · Why must amounts be positive, with direction carrying the sign?
assertBalanced can sum debits and credits separately and compare, without normalising first.Follow-up: what enforces it? A CHECK (amount > 0) in the database, plus a check in assertBalanced that rejects zero and negative amounts. Two layers, because the application check protects the API and the database check protects against everything else.4 · Why can't you just fix a bad entry?
4 · Why can't you just fix a bad entry?
BEFORE UPDATE OR DELETE trigger on ledger_entry that raises: ledger_entry is append-only: post a reversing journal instead. It holds against raw SQL, not just against the application.Multi-currency
5 · Why does a journal balance per currency independently?
5 · Why does a journal balance per currency independently?
balanceByCurrency buckets entries by currency and each bucket must close on its own.Follow-up: then how does an FX conversion balance at all? It cannot be a two-legged journal. It needs four legs and a bridge: the EUR side closes against equity.fx_position.EUR, the USDC side against equity.fx_position.USDC. Neither half references the other.6 · What do the FX position accounts actually represent?
6 · What do the FX position accounts actually represent?
7 · A transfer converts EUR to KES via USDC. How many currencies must balance?
7 · A transfer converts EUR to KES via USDC. How many currencies must balance?
Dr expense.network_fee.ETH, Cr asset.float.chain.ETH.Follow-up: why is the gas journal separate? So it can be excluded from compensation. Gas was really spent; reversing it would misstate the expense. It balances on its own, so leaving it out of the unwind keeps the trial balance at zero.Exactness
8 · Why bigint minor units rather than a decimal type?
8 · Why bigint minor units rather than a decimal type?
0.1 + 0.2 !== 0.3, and the balance invariant would need an epsilon. A ledger with an epsilon is not a ledger: “how far off is acceptable?” has no defensible answer.The decisive one: doubles are exact only to 2^53. That is fine for cents and hopeless for an 18-decimal token, where a single balance routinely exceeds 1e18. For a chain-agnostic system that alone settles it.Follow-up: why not a decimal library? Viable, and widely used. Rejected because integers are faster on the hot path, map directly onto both ISO-4217 minor units and on-chain base units, and remove the question of where precision is configured, which is a setting that eventually exists in two places that disagree. The cost is ergonomics, and that cost was accepted.9 · A 1.5% fee on €33.33 is €0.49995. Where does the remainder go?
9 · A 1.5% fee on €33.33 is €0.49995. Where does the remainder go?
divRound returns the rounded value under an explicit mode; divResidual returns the exact leftover so it can be posted. The journal balances, the fee is €0.49, and €0.01 sits in revenue.rounding.EUR where someone can query it.Follow-up: why does it matter? Because the alternative is an unrecorded transfer of value. Rounding down means the customer keeps a fraction; rounding up means Arc takes one. Over volume, that is a policy nobody wrote, executing continuously, and drifting with traffic mix. The cent that vanished.10 · What property guarantees no cent appears or vanishes when splitting?
10 · What property guarantees no cent appears or vanishes when splitting?
Enforcement
11 · The engine already validates balance. Why also enforce it in the database?
11 · The engine already validates balance. Why also enforce it in the database?
psql session at 3am during an incident. The two layers also fail for different reasons, which is the property that makes redundancy worth its cost.Follow-up: how do you check balance in the database without rejecting every individual insert? DEFERRABLE INITIALLY DEFERRED on the constraint trigger. It runs at COMMIT, not per row, so a journal can be inserted one entry at a time and is judged only once complete. A transaction leaving any journal unbalanced in any currency cannot commit.12 · Your test suite passes. How do you know it would catch a real defect?
12 · Your test suite passes. How do you know it would catch a real defect?
entrySign ignoring account type failed 9.The valuable result was in the saga: compensating in forward order failed zero of sixteen tests, because reversals commute so balance cannot see ordering. Two tests asserting reversal order and account pairing were added; the mutant now fails.Follow-up: what did that teach you? That balance is necessary but not sufficient: an audit trail can be false while the arithmetic is true. More generally: ask what each assertion cannot see, and write a test for that. The full scenario.Questions to ask them back
Good candidates ask these. They also happen to be the questions whose answers tell you whether the team has done this before.- Are balances derived or stored? If stored, what reconciles them, and how often?
- Is the balance invariant enforced anywhere other than application code?
- What is your rounding policy, and where does the residual go?
- What happens to a journal that fails validation halfway through being written?