feat(core): prove the accounting laws (Kani + Lean); fix three engine gaps
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
This commit is contained in:
1 parent
d5c4e38b18
commit
3609bf065e
31 files changed
+2682
-131
No files matched your search
@@ -57,7 +57,7 @@ The core is a pure domain library and stays that way.
|
||||
| `ledger-store` | Journal (hash-chained JSONL) + SQLite projection, blob store | `ledger-core` |
|
||||
| `ledger-import` | QuickBooks CSV read-only import (bank transactions, chart of accounts) | `ledger-core` |
|
||||
| `ledger-export` | Format renderers: CSV, XLSX, IIF journal entries (QuickBooks Desktop) | `ledger-core` |
|
||||
| `ledger-oracle` | Diff harness: our engine vs GnuCash on fixture books | `ledger-core`, `ledger-import` |
|
||||
| `ledger-oracle` | Cross-check harness: our engine vs other implementations (GnuCash first) on fixture books; diffs are adjudicated, not obeyed | `ledger-core`, `ledger-import` |
|
||||
|
||||
Dependency direction is always inward toward `ledger-core`. Nothing in the
|
||||
core may depend on a future crate. UI concerns (formatting, locale, widgets)
|
||||
|
||||
+41
-11
@@ -9,15 +9,33 @@ our oracle is *semantic*, not visual.
|
||||
|
||||
1. **Fixture books.** A canonical set of journal entries exercising a feature
|
||||
(the January 2026 fixture company in `tests/invariants.rs` is the first).
|
||||
2. **Two engines.** The same fixtures go through our engine and through
|
||||
**GnuCash** — the open-source, double-entry reference implementation with
|
||||
open file formats. GnuCash is both the spec and the oracle.
|
||||
2. **More than one engine.** The same fixtures go through our engine and
|
||||
through independent implementations: **GnuCash** (open source, open file
|
||||
formats), the old Google Sheet, and later QBO/Xero exports. These are
|
||||
*cross-checks*, not authorities (see "Who is right" below).
|
||||
3. **Exact diff.** `reports::snapshot()` renders trial balance, P&L, and
|
||||
balance sheet as canonical JSON. The outputs must be **identical** —
|
||||
decimal strings, no floats, no locale, no epsilon. Where storytold allows
|
||||
2/255 of perceptual slop, we allow zero: accounting has no "close enough".
|
||||
|
||||
## The three oracle layers (today → future)
|
||||
## Who is right
|
||||
|
||||
No other program is the spec. GnuCash, the Sheet, QuickBooks: each is
|
||||
*an* oracle, and each can be wrong. The order of authority:
|
||||
|
||||
1. **Accounting principles, stated as proofs.** The laws in
|
||||
`docs/verification.md`, proved in Lean and model-checked by Kani. They
|
||||
do not depend on any other implementation's behavior.
|
||||
2. **Golden files recomputed by hand** from those principles (Layer 1).
|
||||
3. **External oracles** (GnuCash, the Sheet, QBO/Xero exports). A
|
||||
disagreement with one is a *finding*: work out from principles which
|
||||
side is wrong and write the reasoning down. Never change our output just
|
||||
to match theirs. If theirs is wrong, add a test that pins our correct
|
||||
behavior, and note their bug in `docs/parity.md`.
|
||||
|
||||
Nothing in this repo may rest on an external oracle's behavior alone.
|
||||
|
||||
## The oracle layers (today → future)
|
||||
|
||||
**Layer 1 — Golden files (live now).** `tests/golden/*.json` hold
|
||||
hand-verified expected reports for canonical fixtures. The golden test fails
|
||||
@@ -53,16 +71,28 @@ always balances exactly), `fuzz_op_sequence` (arbitrary op sequences via
|
||||
`arbitrary`; the trial balance always balances). 11.5M execs, zero crashes
|
||||
as of 2026-10-10. Corpora persist in `fuzz/corpus/` for regression.
|
||||
|
||||
**Layer 5 — GnuCash diff harness (roadmap phase 3).** A `ledger-oracle` crate
|
||||
will run fixture books through our engine and through GnuCash headless, then
|
||||
diff `snapshot()` output automatically — `cargo xtask oracle`. This is the
|
||||
direct analog of storytold's `cargo xtask corpus` / `oracle_diff`. Note the
|
||||
distinction: GnuCash is the *behavioral* oracle, not a file-format target
|
||||
**Layer 4½ — Formal proofs (live now).** The accounting arithmetic lives
|
||||
in one generic kernel (`kernel.rs`). Kani model-checks it exhaustively
|
||||
within bounds, Lean 4 proves the laws for books of any size (trial
|
||||
balance, accounting equation, reversals, posted-is-forever, frozen closed
|
||||
periods, closing to net income), and a property test bridges the two.
|
||||
`just prove`; see `docs/verification.md`. Its first pass found three
|
||||
engine bugs that Layers 1–4 had all missed.
|
||||
|
||||
**Layer 5 — Cross-implementation diff harness (roadmap phase 3).** A
|
||||
`ledger-oracle` crate will run fixture books through our engine and through
|
||||
other implementations (GnuCash headless first), then diff `snapshot()`
|
||||
output automatically: `cargo xtask oracle`. This is the direct analog of
|
||||
storytold's `cargo xtask corpus` / `oracle_diff`, with one difference: a
|
||||
diff does not mean we are wrong. Each one is adjudicated from principles
|
||||
(see "Who is right"). Note also: GnuCash is *a* behavioral cross-check, not
|
||||
a file-format target
|
||||
(Stephen: "for file format import/export I could not give a single shit
|
||||
about GnuCash XML — QBO is all that matters"). The harness feeds fixtures to
|
||||
GnuCash via whatever it imports reliably (QIF/OFX) or its Python bindings;
|
||||
the exact mechanism is decided at implementation time. Until the harness
|
||||
exists, Layers 1 and 2 are the oracle, and they are not optional.
|
||||
the exact mechanism is decided at implementation time. The proofs and
|
||||
Layers 1–4 are the foundation and are not optional. The harness adds
|
||||
evidence. It never overrides them.
|
||||
|
||||
## What the oracle does NOT cover (yet)
|
||||
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ No other statuses.
|
||||
|
||||
**Floor: 10 done** (2026-10-10; fixed-asset register added).
|
||||
|
||||
| Area | GnuCash reference | Status | Notes |
|
||||
| Area | GnuCash counterpart (a cross-check, not the spec) | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| Chart of accounts (5 types, codes, hierarchy) | Accounts tab | done | `account.rs`; placeholder support in |
|
||||
| Double-entry validation (balanced, ≥2 lines) | Journal validation | done | `journal.rs::validate` |
|
||||
@@ -44,7 +44,7 @@ No other statuses.
|
||||
| Payout gross-up (deposit → revenue, fees, adjustments) | Bank deposit with fee lines; Xero/QBO Stripe & Shopify integrations | partial | hand-verified fixture incl. classes and a negative payout; no statement parsers yet |
|
||||
| 1099 vendor tracking + year-end worksheet | Vendors → Track payments for 1099; 1099 report | partial | hand-verified fixture (bill payments, card exclusion, reversal, year boundary); no golden file yet |
|
||||
| Prepaid expense + deferred revenue amortization | Amortization schedules (Xero/QBO add-ons) | partial | hand-verified fixture, to the cent; no golden file yet |
|
||||
| Automated GnuCash diff harness | — (our `xtask oracle`) | todo | roadmap phase 3 |
|
||||
| Automated cross-implementation diff harness (GnuCash first) | — (our `xtask oracle`) | todo | roadmap phase 3 |
|
||||
|
||||
Workflow/UI parity (QuickBooks/Xero reference) gets its own catalog when the
|
||||
egui phase starts; semantics come first.
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
# 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`
|
||||
|
||||
## 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.
|
||||
Reference in new issue
Block a user