๐ doubleentry
An immutable, tamper-evident double-entry bookkeeping engine. Balanced by construction. Exact integer money. Zero I/O. No async. No chart of accounts.
doubleentry is a calculation library, not a platform. It enforces the invariants of
double-entry bookkeeping inside the engine โ and then lets you prove to a third party
that it did.
๐ Documentation ยท ๐ฆ API reference ยท ๐ Changelog
โจ Why
Most ledger libraries reduce to CRUD rows and leave the invariants to the application. The ones that don't usually still can't answer the question an auditor actually asks: how do I know this record wasn't changed after the fact?
| Guarantee | How |
|---|---|
| Balanced by construction | An entry reaches a persistable state only through validation, and the validated type has no other constructor |
| Exact | Money is a scaled i64 with compile-time precision. Every fallible operation returns Result โ no floats, no panics, no wrapping |
| Deterministic | No clock, no RNG, no hash-map iteration order. Identical inputs produce identical bytes |
| Verifiable | Entries are leaves in an append-only Merkle log with O(log n) inclusion and consistency proofs; closed periods are sealed and chained, and a sealed balance can be proven and named without disclosing the rest of the books |
| Gross-preserving | Balances carry debit and credit totals, so turnover survives netting |
| Bounded | An account can be forbidden from crossing zero, checked inside the write against the balance the entry would leave โ so concurrent draws cannot together overdraw it |
๐ Quick start
use LedgerId;
use ;
use date;
type Eur = ;
// A journal is one entity's books: its accounts, its calendar, its policy, its
// entries, and the Merkle log that commits to them.
let mut journal = new;
// Accounts are paths in a hierarchy you define. Only leaves are postable.
let cash = journal.accounts_mut.register_path?;
let revenue = journal.accounts_mut.register_path?;
let recorded = journal.record?;
// Prove the entry is committed to, without revealing any other entry.
let head = journal.head;
let proof = journal.prove_inclusion?;
assert!;
# Ok::
record validates the draft against this journal's accounts, calendar and policy, then
appends it. There is no separate context to build and no second object to keep in step โ the
things validation consults and the thing that stores the result are the same thing.
โ Getting started
๐ Balanced by construction
Entry carries a type-state parameter. A draft proves nothing; sealing it runs every
invariant and yields Entry<Balanced, P> โ a type with private fields, no public
constructor, and marker types behind a sealed trait.
# use ;
# use LedgerId;
# use date;
# type Eur = ;
# let mut journal = new;
# let cash = journal.accounts_mut.register_path?;
# let revenue = journal.accounts_mut.register_path?;
let errors = new
.debit
.credit
.seal
.unwrap_err;
// Every violation is reported at once, not one round trip at a time.
assert!;
# Ok::
What this claims. Whether a set of postings balances is a property of runtime values, so no type system short of dependent types decides it at compile time. What the type state gives you is that an unbalanced entry cannot be represented as a validated one โ every API that persists, exports, or commits to an entry accepts only the balanced form.
๐งพ Money
Amount<P> is a scaled i64 with the precision fixed at compile time. One value has exactly
one representation, which is what makes hashing a monetary amount meaningful.
# use ;
type Eur = ;
// Splitting is exact: the parts always re-sum to the whole.
let parts = parse?.distribute?;
assert_eq!;
assert_eq!;
// Proportional splits use largest-remainder, with ties broken deterministically.
let split = parse?.allocate?;
assert_eq!;
// Excess precision is refused rather than silently rounded.
assert_eq!;
// Arithmetic is total: overflow is a value, not a panic.
assert_eq!;
# Ok::
If your application does proportional division itself, the ledger eventually goes off by a cent and nobody can say which entry did it. That is why splitting lives here.
โ Money ยท Debits, credits and gross totals
๐ง Balance limits
Accounts are unconstrained by default. Where the books would be wrong rather than merely surprising if a balance crossed zero, say so and the engine enforces it:
# use BalanceLimit;
# use LedgerId;
# use ;
# use date;
# type Eur = ;
# let mut journal = new;
# let wallet = journal.accounts_mut.register_path?;
# let cash = journal.accounts_mut.register_path?;
// A customer wallet may not be drawn beyond what was funded.
journal.accounts_mut.set_limit?;
let overdraw = new
.debit
.credit;
assert!;
# Ok::
Checked against the balance the whole entry would leave behind, per currency and per layer, so the answer never depends on the order the postings were listed in. Both SQL backends enforce it inside the append transaction โ a limit checked before the write reads a pre-image that two concurrent appends both see, each fitting it and together breaching it.
โ Accounts
๐ Proofs
The log follows the append-only Merkle tree of RFC 6962 / RFC 9162, with BLAKE3 in place of SHA-256 and a domain separation tag on every node.
- Inclusion โ this entry sits at this index under this root, in
O(log n)hashes, revealing nothing else. A chained hash cannot do this. - Consistency โ the earlier log is a prefix of the later one. Not merely linked: provably append-only.
# use MerkleLog;
# use Hash;
#
let mut log = new;
for i in 0..1024
let snapshot = log.head;
for i in 1024..2048
// The published snapshot is provably a prefix of what the log holds now.
let proof = log.consistency_proof?;
assert!;
# Ok::
Leaf hashes depend only on their own entry, so writers never contend on shared hash state โ a chained-hash log serialises every append; this one does not.
โ Proofs
๐งท Period seals
Closing a period commits to which entries it contains, what they add up to, and which accounts those totals are for โ all three as Merkle roots. Seals chain, so removing or reordering a sealed period breaks every seal after it.
A seal carries its LedgerId inside the preimage, so it attests to one entity's books or it
does not verify at all. A sealed period is terminal: a correction books into a later open
period carrying the original date, which is the only treatment compatible with a log that has
already been committed to.
# use ;
# use ;
# use TrialBalanceCommitment;
# use date;
# type Eur = ;
# let mut journal = new;
# let cash = journal.accounts_mut.register_path?;
# let revenue = journal.accounts_mut.register_path?;
let march = new?;
journal.define_period?;
# journal.record?;
// Stop postings first, so verification runs against a set that cannot grow.
journal.transition_period?;
let seal = journal.seal_period?;
// An auditor holding only the seal can be shown one closing balance โ and be
// told which account it is โ without seeing any other account or entry.
# let closing = journal.trial_balance_through_date?;
# let key = BalanceKey ;
let balance = of.prove.expect;
let binding = journal.accounts.prove_binding.expect;
assert!;
assert_eq!;
# Ok::
The trial-balance root is keyed on account handles โ dense integers, cheap to compare.
accounts_root is what says which account each handle is, so renumbering the registry after
the fact cannot leave a seal verifying while its balances quietly mean something else.
๐พ Persistence
The engine keeps no storage of its own. LedgerStore defines what a backend must do, and the
conformance suite โ twenty executable checks โ is what decides whether an
implementation of it is correct.
| Backend | Feature | Verified against |
|---|---|---|
| In-memory | always on | the conformance suite |
| SQLite | sqlite |
a real database, in-process โ no server, no container |
| PostgreSQL | postgres |
a real database, via testcontainers |
All of them run the same suite โ PostgreSQL runs it twice, once per sequencing mode โ and a test asserts they agree: same log indices, same content hashes, same tree root, same trial balance for the same operations. An abstraction that only one implementation satisfies is not an abstraction.
let store = connect.await?;
store.migrate.await?;
store.append.await?;
โ Persistence ยท Cold tier
๐ฆ Features
| Feature | Effect |
|---|---|
serde |
Serialize / Deserialize on public types. Transport only โ the canonical encoding used for hashing is independent of it |
sqlite |
A SQLite-backed LedgerStore on sqlx, plus the reference schema |
postgres |
A PostgreSQL-backed LedgerStore on sqlx, plus the reference schema |
iceberg |
An Apache Iceberg cold tier for sealed periods |
Every validated type round-trips through its own constructor, so an invariant that holds
for a constructed value also holds for one read back. Deserialising an entry yields a
Draft, never a Balanced entry โ a witness that can be read off a wire is not a witness.
โ Features and serialisation
๐งฑ Design boundaries
Not an ERP, an ORM, a reporting engine, a chart of accounts, a payments library, a policy engine, or a distributed system. It does not convert currencies, name your reporting axes, or read a clock. It produces validated, balanced, provable entries and leaves every domain decision to you.
Two structural rules are enforced, because no downstream layer can repair them:
- Only leaves are postable. Posting to both a node and its descendants makes every rollup double-count.
- Postings fall inside the account's open window.
And one you opt into per account, for the same reason โ an overdrawn cash account is not something a report can repair either: a balance limit, checked against the balance an entry would leave behind.
๐งช Testing
Invariants are covered by property tests over generated inputs, not hand-picked cases. Beyond that: randomised simulation with every invariant re-checked after each step; committed golden vectors for the canonical encoding, the seal preimage and the Merkle log; robustness tests asserting no input can panic a parser; a cost guard on the shape of the curve; the conformance suite; and real databases โ SQLite in-process, PostgreSQL in a throwaway container, nothing mocked.
cargo test # everything but the databases
cargo test --features sqlite # adds SQLite; no server needed
cargo test --features postgres # adds PostgreSQL; needs Docker
cargo test --features iceberg # adds the cold tier; writes to a temp dir
cargo clippy --all-targets --all-features
The crate forbids unsafe_code, and denies arithmetic_side_effects, indexing_slicing,
unwrap_used, expect_used, and panic in library code. A CI job greps the engine for
clocks, I/O, async and unsafe, so determinism is checked rather than trusted.
โ Design boundaries and testing
๐ License
Licensed under either of Apache-2.0 or MIT at your option.