The money arithmetic every invariant rests on now lives in one kernel,
ledger-core/src/kernel.rs: signing a line, totals, net, per-account
movements, reversal, normal balances, trial-balance columns, closing
lines, and the rounding plug for member splits. It is generic over a
checked `Amount` trait. The engine runs it on Decimal (validate,
movements, reverse_entry, account_balance, close_period_to, allocate,
trial_balance): there is one copy of the arithmetic, and it is the one
under proof.
Three layers, docs/verification.md maps every law to its proofs:
- Kani (src/proofs.rs): 17 harnesses prove the kernel at i64 for every
input up to 4 lines over 3 accounts. An entry is accepted exactly
when it is two or more positive, in-range lines whose debits equal
credits; accepted entries net to zero; grouping by account loses
nothing; a reversal mirrors and cancels its original; trial-balance
columns balance iff the books net to zero; closing lines zero their
accounts and a close balances however the net is split; the plug
makes parts sum to the whole. No panics, no overflow. kani::cover!
shows the proofs are not vacuous.
- Lean 4 (verify/lean): 24 headline theorems for books of any size. The
ledger is a state machine (add account, draft, post, reverse, open,
close), and in every reachable state the books net to zero, the trial
balance balances (as of any date), and the accounting equation holds.
Posting only appends ("posted is forever"), closed periods are
frozen, a close zeroes revenue and expenses and credits exactly the net
income, and period statements add up. Audit.lean shows standard axioms
only, no sorry.
- Bridge (tests/kernel_bridge.rs): the Decimal and i64 kernels agree.
What the proofs found (each one missed by every test, fuzzer, and
mutant):
- The engine accepted negative line amounts; only MCP refused them. Now
NegativeAmountLine: direction is the side's job.
- Summing lines near Decimal::MAX panicked. Lines are now capped at one
quadrillion and ten decimal places (AmountOutOfRange), which keeps
every realistic sum exact, and the kernel's sums are checked.
- FX conversion (foreign_amount * fx_rate) could panic the same way; it
is now a checked multiply.
- Kani itself: 0.68 mis-models symbolic-length copy_from_slice
(model-checking/kani#4904) and reported a reversal that doesn't net to
zero. The counterexample, replayed natively, passes, and a 15-line
standalone crate reproduces the bug. Harnesses avoid bulk copies, and
a should_panic canary will flag the upstream fix.
`just prove` runs it all (Kani memory- and time-capped, fails on an
unsatisfiable cover or a non-standard Lean axiom). AGENTS.md now makes
formal coverage part of every core change.
Also, docs: GnuCash is *an* oracle, never *the* oracle (Stephen). The
spec is accounting principles, proved. Golden files are recomputed from
principles. External implementations (GnuCash, the old Sheet, QBO/Xero)
are cross-checks whose disagreements get adjudicated, never obeyed.
docs/oracle.md gains "Who is right"; AGENTS, README, architecture,
roadmap, parity, and STATUS are reworded to match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
11 KiB
Formal verification: the accounting laws, proved
Tests check the examples someone thought of. Proofs check every case. wanderbooks proves the fundamental laws of double-entry bookkeeping three ways, each covering what the others can't:
| Layer | Tool | What it proves | Scope |
|---|---|---|---|
| Code | Kani (bounded model checking) | The engine's own Rust kernel is correct and never panics or overflows | Every input within the bounds: up to 4 lines, 3 accounts, amounts to 2^40 |
| Laws | Lean 4 (machine-checked proof) | The laws hold for books of any size, after any sequence of commands | Unbounded: any number of entries, accounts, periods, steps |
| Bridge | proptest | The kernel on Decimal (what the engine runs) agrees with the kernel on i64 (what Kani proves) |
2,000 random cases per property |
Run all of it with just prove (after mise install; see the end).
The kernel
Every invariant rests on a little arithmetic: signing a line, summing
sides, grouping by account, flipping for a reversal, reading a balance on
its normal side, splitting a movement into trial-balance columns, closing
an account, plugging the last share of an allocation. That arithmetic
lives in one place, crates/ledger-core/src/kernel.rs. Its functions
are small, pure, and generic over an Amount trait with checked
operations. The engine calls them on Decimal: JournalEntry::validate,
movements, reverse_entry, account_balance, account_movement,
period closing, periods::allocate, and the trial balance. There is no
second copy of the arithmetic for proofs to drift from.
Kani compiles the same functions at i64 (exact integer money in the
smallest unit) and explores every input in the bounds. (Grouping by
account is proved through movements_into, the allocation-free core
that movements wraps: symbolic heap growth is beyond what the model
checker explores in reasonable time.) The Lean model in
verify/lean restates each function over Int and proves the laws with
no bounds at all.
The laws
Each law, and what proves it. Kani harnesses are in
crates/ledger-core/src/proofs.rs; Lean theorems are in
verify/lean/Wanderbooks/.
Entries
- An entry is accepted exactly when it has two or more lines, every
amount is positive and in range, and debits equal credits, to the unit.
- Kani
check_lines_is_exact(both directions: no bad entry gets in, no good one is refused),out_of_range_is_rejected - Lean
Accepted
- Kani
- An accepted entry nets to zero.
- Kani
accepted_entry_nets_to_zero· LeanAccepted.net_zero
- Kani
- Grouping by account loses and invents nothing: each account's
movement is the net of its own lines, and the movements sum to the
entry's net.
- Kani
movements_are_per_account,movements_sum_to_the_net· Leansum_movements
- Kani
- Sums are exact and never overflow for in-range lines.
- Kani
sums_are_exact(every harness also fails on any panic or arithmetic overflow)
- Kani
Reversals
- A reversal is accepted exactly when the original is, and the two
together net every account to zero.
- Kani
reversal_mirrors,reversal_cancels,flip_negates - Lean
accepted_reversal_iff,movement_with_reversal
- Kani
Balances and statements
- Normal sides: a line on an account's normal side raises its
balance by the amount; the other side lowers it.
- Kani
normal_side_increases· Leanbalance_after_line
- Kani
- The trial balance balances, and it balances only if the books
net to zero: it is a complete check of the arithmetic.
- Kani
trial_balance_columns· Leantrial_balance_iff,Reachable.trial_balance,Reachable.trial_balance_through(as of any date)
- Kani
- The accounting equation: Assets = Liabilities + Equity + (Revenue −
Expenses), always and as of any date.
- Kani
normal_balance_is_signed(the fact it rests on, every type and amount) andaccounting_equation(end to end, up to three accounts: regrouping signed 64-bit sums is hard for a SAT solver) · Leanaccounting_equation,Reachable.accounting_equation,Reachable.accounting_equation_through(any number of accounts)
- Kani
- Period statements add up: a year's P&L is the sum of its months'.
- Lean
statements_add_up
- Lean
The books over time
The Lean model (Ledger.lean) describes the engine as a state machine:
add an account, start a draft, post, reverse, open a period, close one.
Each step carries the checks the engine makes before it mutates. These
theorems hold in every state reachable from empty books, by any
sequence of commands, of any length:
- The invariant holds: chart codes are unique, every posted entry is
acceptable and on known accounts, and no draft is dated in a closed
period. (That last one is why the store can journal a post before
applying it: a post can never then fail on a closed period.)
- Lean
Step.inv,Reachable.inv
- Lean
- The books always net to zero. · Lean
Reachable.books_net_zero - Posted is forever: posting only appends. After any sequence of
commands the old log is a prefix of the new one. Nothing is edited,
reordered, or removed.
- Lean
Step.extends,Steps.extends
- Lean
- Closed periods are frozen: once a period is closed, no sequence of
commands adds, removes, or changes an entry dated in it, and it stays
closed.
- Lean
Step.frozen,Steps.frozen
- Lean
Closing
- A closing line zeroes its account, and a closing entry balances
and zeroes every revenue and expense account through the period's
last day, whether the net goes to one account or is split among
members.
- Kani
closing_line_zeroes,credit_line_credits,closing_entry_balances - Lean
closingEntry_net,closingEntry_zeroes,closingEntry_accepted,close_zeroes
- Kani
- A close credits equity with exactly the net income: revenue minus
expenses through the period's last day.
- Lean
net_closings_is_net_income,close_credits_net_income
- Lean
- An allocation's parts sum to the whole, to the unit, however the
other shares were rounded, and only the last share changes.
- Kani
plug_last_sums_exactly· LeanplugLast_total,plugLast_keeps
- Kani
What the proofs found
The engine already had 190+ tests, property tests, mutation testing, and 11.5M fuzz executions with zero crashes. Writing the laws down precisely still turned up three gaps in it, now closed:
- Negative line amounts were accepted. Only the MCP layer refused
them. The engine now rejects them (
NegativeAmountLine): direction is the side's job. - A huge amount could crash the engine. Summing lines near
Decimal::MAXpanicked instead of returning an error. Lines are now limited to one quadrillion and ten decimal places (AmountOutOfRange), and the kernel's sums are checked. - Foreign-currency conversion could crash the same way
(
foreign_amount * fx_rate). It is now a checked multiply.
And three things besides bugs:
- A soundness bug in the verifier. Kani reported a reversal that doesn't net to zero. Tracing it took a native replay of the counterexample (it passed) and a 15-line repro crate: Kani 0.68 mis-models symbolic-length slice copies. See "Trusting the checker" below. Taking every red result seriously is the method.
- Simpler code.
credit_lineandclosing_lineare the same function. The model made that obvious, and the code now says so. - A load-bearing invariant, made explicit. No draft can exist in a
closed period (
Inv.draftsOpen). That is why the store can journal a post before applying it: the closed-period check can never fail after the journal write. It was true by accident of the checks' design. Now it is a theorem, and breaking it breaks the build.
Each of these shipped past every test we had. That is the case for
formal methods here: tests sample, proofs exhaust. AGENTS.md makes
growing this coverage part of every change.
Trusting the checker
A model checker is a program too. While building these proofs, Kani
reported that an entry plus its reversal could leave an account nonzero.
That was not a bookkeeping bug. Kani's own counterexample passes when
replayed natively. We narrowed it to a 15-line crate with no wanderbooks
code: Kani 0.68 (CBMC 6.11) mis-models copy_from_slice whenever the
length is symbolic. It is the known bug model-checking/kani#4904, closed
as a duplicate and unfixed in 0.68. It is unsound in both directions: it
can also prove false claims. So:
- No harness copies a slice of symbolic length. They write element by
element (
reversal_cancelssays why), and the kernel functions under proof contain no bulk copies. - A canary watches for the fix.
kani_symbolic_copy_canaryruns the faulty copy and is markedshould_panic. When a Kani upgrade fixes the bug, the canary starts failing, and the workaround can go. - The proofs are not vacuous.
kani::cover!statements make Kani show that the interesting cases are reachable: an accepted four-line entry, a reversal on one account with several lines, three distinct accounts, a balanced three-account chart, a close to two members.just provefails if any cover is unsatisfiable.
What is and isn't proved
Be precise about the claims:
- Kani is bounded. It proves the kernel for every input up to 4 lines
over 3 accounts and amounts to 2^40 units (the accounting equation end
to end: up to 3 accounts). Lean proves the laws for any
size, but of a model. The model is a direct restatement of the kernel,
and the bridge property test ties the
Decimalinstantiation to thei64one Kani checked. Decimalitself is trusted.rust_decimalis exact for these sizes: 96-bit integers scaled by a power of ten. With lines capped at one quadrillion and ten decimal places, every sum is exact while the total of all amounts stays below 7.9 × 10^18. Kani can't exploreDecimalarithmetic directly: a single symbolic add runs for more than ten minutes.- The state machine is a model of
Ledger's checks, not a proof aboutledger.rsline by line. Its correspondence to the code is checked by review and by the end-to-end property tests intests/properties.rs(trial balance, balance sheet, reversals, immutability) and the golden files. - Out of scope: currency (the ledger is single-currency, enforced
structurally), A/R and A/P documents, payroll, depreciation schedules
(those have their own property tests), and persistence (the journal
store's hash chain is tested in
ledger-store).
Running it
mise install # once: Kani, Lean, and the rest, pinned in mise.toml
just prove
just prove runs every Kani harness, builds the Lean proofs (seconds),
and fails if any headline theorem depends on anything beyond Lean's
standard axioms (propext, Quot.sound, Classical.choice). In
particular, sorryAx fails it. Kani downloads its pinned nightly and
CBMC into ~/.kani on first use.