Files
wanderbooks/docs/verification.md
T
swaitsandClaude Opus 5.5 fb3b4ab476
check / check (push) Canceled after 0s
closes: a run of members closes splits cumulatively
Each close into members' capital rounded on its own, ties to the
earlier member, so twelve monthly 50/50 closes of odd-cent income could
leave one member several cents ahead of half the year (docs/testing.md
#12, found with the members' equity fix).

- CloseTo::Members now splits cumulatively over a run (closes back to
  back with the same members and shares): each close gives a member
  allocate(run total so far) - allocate(run total before). Periods
  record their close order and a cumulative close's members and net
  (Period::close_seq, member_close) so the next close continues the run
  and replay recomputes it exactly. A close to anything else, or other
  shares, ends the run.
- Journaled "rounding": "cumulative". The per-close rule is kept as
  CloseTo::MembersPerClose for closes journaled "largest_remainder";
  older closes still replay with the last member absorbing it.
- Lean: runPart_telescopes (a member's total from a run is their
  allocation of the run's total) and closePart_total (each close hands
  out exactly its net), audited to the standard axioms.
- Tests: hand-computed three one-cent months (per close A 0.03 / B 0;
  cumulative A 0.02 / B 0.01), a run ending, a property that each member
  stays within a cent of their share (fails on the per-close rule), and
  a store contract that each rule journals and replays as itself.

Wanderland's books have no closes yet, so all of theirs use the new rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 18:58:21 -06:00

230 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](https://model-checking.github.io/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](https://lean-lang.org) (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
1. **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`
2. **An accepted entry nets to zero.**
- Kani `accepted_entry_nets_to_zero` · Lean `Accepted.net_zero`
3. **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` ·
Lean `sum_movements`
4. **Sums are exact and never overflow** for in-range lines.
- Kani `sums_are_exact` (every harness also fails on any panic or
arithmetic overflow)
### Reversals
5. **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`
### Balances and statements
6. **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` · Lean `balance_after_line`
7. **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` · Lean `trial_balance_iff`,
`Reachable.trial_balance`, `Reachable.trial_balance_through` (as of
any date)
8. **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) and `accounting_equation` (end to end, up to three
accounts: regrouping signed 64-bit sums is hard for a SAT solver) ·
Lean `accounting_equation`, `Reachable.accounting_equation`,
`Reachable.accounting_equation_through` (any number of accounts)
9. **Period statements add up**: a year's P&L is the sum of its months'.
- Lean `statements_add_up`
### 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**:
10. **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`
11. **The books always net to zero.** · Lean `Reachable.books_net_zero`
12. **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`
13. **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`
### Closing
14. **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`
15. **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`
16. **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` · Lean `plugLast_total`,
`plugLast_keeps`
17. **A run of member closes telescopes**: each member's total from a run
of cumulative closes is their allocation of everything the run closed
(so its rounding is one allocation's, never a sum of them), and each
close hands out exactly its net, for any allocation whose parts sum to
the whole (rules.md B-13).
- Lean `runPart_telescopes`, `closePart_total` · property
`each_member_stays_within_a_cent_of_their_share`
## 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::MAX` panicked 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_line` and `closing_line` are 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_cancels` says why), and the kernel functions under
proof contain no bulk copies.
- **A canary watches for the fix.** `kani_symbolic_copy_canary` runs the
faulty copy and is marked `should_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 prove` fails 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 `Decimal` instantiation to the
`i64` one Kani checked.
- **`Decimal` itself is trusted.** `rust_decimal` is 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 explore
`Decimal` arithmetic directly: a single symbolic add runs for more than
ten minutes.
- **The state machine is a model of `Ledger`'s checks**, not a proof
about `ledger.rs` line by line. Its correspondence to the code is
checked by review and by the end-to-end property tests in
`tests/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
```sh
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.