Three ideas carry the whole design
A customer's balance is a liability, not an asset
A customer's balance is a liability, not an asset
When someone funds a virtual account, Arc gains an asset, float sitting at a bank or on-chain, and simultaneously owes that person the same amount. Those are the two sides of one journal.Keeping them paired is what makes “do we actually hold what we owe?” an answerable question rather than a hope. A system that stores a customer balance as a number has no way to ask it.
Balances are derived, never stored
Balances are derived, never stored
A balance is a fold over entries. Replaying the entry log from the beginning must reproduce the same figures.If it does not, the entries are right and the cached number is wrong. That ordering is not arbitrary: it is the reason the log is the record and the balance is a projection.
Entries are append-only
Entries are append-only
A correction is a new, opposing journal. Never an edit, never a delete.This keeps the record of what happened separate from the record of what was meant to happen, which is the property an auditor actually cares about, and the property that makes an incident reconstructable.
The model
Account codes are structured strings, not opaque ids:asset.float.bank.EUR, liability.customer.va_1.KES. A journal is therefore legible in a log or a psql session without joining anything, which matters at 3am far more than it matters at design time.
Account types and direction
Debits increase assets and expenses. Credits increase liabilities, equity and revenue.
The same entry moves two accounts in opposite senses: a €10 debit raises a bank-float asset and lowers a customer liability.
That mapping lives in exactly one place, a
NORMAL_BALANCE table, and a single function, entrySign(), is the only thing that reads it. Duplicating it would guarantee that the two copies eventually disagree and the ledger silently mis-signs balances. It returns bigint rather than number so it can be multiplied directly by a minor-unit amount without a cast.
Two system accounts worth knowing
liability.in_transitFunds committed to a live transfer but not yet paid out. Its overdraft floor is zero: driving it negative would mean paying out money nobody put in, and the posting engine rejects it.equity.fx_positionThe bridge between the currency halves of an FX journal. The pair of position accounts is where an unhedged exposure becomes visible: sold EUR for USDC and not covered, and it shows as offsetting balances here.The central invariant
In every currency it touches, a journal’s debits equal its credits. Exactly. Not within a tolerance.
This is the reason money is an integer count of minor units. With floats an epsilon would be unavoidable here, and a ledger with an epsilon is not a ledger: “how far off is acceptable?” has no defensible answer, and the drift grows with volume.
Per currency, independently. A journal converting EUR to USDC has two halves and each must close on its own. Offsetting a EUR debit against a USDC credit would be adding quantities of different things: numerically possible, economically meaningless. The FX position accounts are what close each half.
assertBalanced checks three things in order:
1
At least two entries
One entry can never balance.
2
Every amount is positive and non-zero
amount is always positive; direction carries the sign. Allowing negative amounts would give two representations of one fact, a −€10 debit and a +€10 credit, and two representations is how ledgers drift.3
Every currency balances exactly
Currencies are returned sorted, so error messages and test assertions are deterministic.
Worked example
€1,000.00 from Germany to a Kenyan mobile-money wallet. Three journals; every one balances in every currency it touches.- 1 · The sender commits
- 2 · Convert to KES
- 3 · Pay out
kind: transferThe customer’s liability falls by €1,000: Arc owes them less. €990 moves into in-transit; €10 becomes revenue, split into two named fee accounts rather than one blended line.
Rounding residuals
A 1.5% fee on €33.33 is €0.49995, not representable in cents. Round it to €0.49 and €0.00995 has to go somewhere, or the journal will not balance.The residual becomes a number someone can look at, rather than drift nobody can explain.
The cent that vanished →
divResidual() returns the exact leftover from any rounded division precisely so it can be posted like this.Balance-or-reject
PostingEngine.post() validates in full before writing anything. A rejection leaves no trace: there is no path that writes some entries and then discovers a problem.
The order is deliberate: cheapest and most fundamental first. Balance is pure and needs no I/O, so a malformed journal never reaches the database at all. Account resolution is one round trip. Overdraft checking needs current balances, so it is last.
Available versus posted
posted would let them spend money already committed elsewhere. A quote reserves; execution captures; expiry releases.
Holds are modelled, not yet wired. The table and the projection exist; the saga currently debits directly rather than reserving at quote time. Wiring them belongs with the transfer API.There was a race here, closed in Phase 6.5: between the balance read and the append, a concurrent journal could spend the same funds. The database’s balance trigger does not catch it, because it enforces that a journal balances, not that an account stayed above its floor.
LedgerStore now exposes withAccountLocks, and PrismaLedgerStore implements it as a transaction opening with SELECT … FOR UPDATE on the touched rows. Account codes are sorted by the posting engine so concurrent posts acquire in the same order and cannot deadlock. Two integration tests pin it: two concurrent €80 spends from €100 leave exactly one winner, and eight concurrent €30 spends leave exactly three.Enforced twice, on purpose
The posting engine refuses to write an unbalanced journal. The database refuses too, independently. This is deliberate duplication. An invariant that depends on every future developer remembering to go through the right class is not an invariant: it is a convention, and conventions erode.DEFERRABLE INITIALLY DEFERRED is what makes the balance check workable: it runs at COMMIT, not per row, so a journal can be inserted one entry at a time and is judged only once complete.
Each of these was attempted through raw SQL against a live database and rejected:
A transaction that leaves any journal unbalanced in any currency cannot commit: not from the application, not from a migration script, not from a
psql session during an incident.Reversal
Arc undoes things by posting the opposite journal, never by deleting. Two properties make this load-bearing:- If a journal balances, its reversal balances. Flipping every direction preserves the equality, so unwinding a failed transfer is safe by construction rather than by careful coding. That matters because the unwind path runs when something has already gone wrong.
- It is an involution. Reversing twice returns the original.
What the tests actually prove
These were mutation-checked. Three deliberate defects were introduced to confirm the suite is load-bearing rather than decorative:
A suite that passes when the code is broken proves nothing. These do not.
What the tests prove →
What is not here yet
- Holds at quote time, as described above.
- Balance snapshots. Every balance is a full fold over entries. Correct, and fine at this scale; real volume wants periodic snapshots to fold forward from.
- The int64 ceiling. Amounts are Postgres
BIGINT, so the limit is 9.2e18 minor units — ample for 6-decimal assets, but an 18-decimal balance above ~9.2 tokens would exceed it and needsNUMERIC(78,0).
Next: the chain layer
Five chains with genuinely different physics, behind one interface.
Walk a transfer
The same €1,000, followed step by step through every context.