doubleentry 0.4.0

An immutable, tamper-evident double-entry bookkeeping engine. Balanced-by-construction entries, exact integer money, deterministic identity, and an append-only Merkle log with inclusion and consistency proofs. Zero I/O. No async. No chart of accounts.
Documentation

๐Ÿ“’ doubleentry

Crates.io Docs.rs License MSRV

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 doubleentry::period::LedgerId;
use doubleentry::{Amount, Currency, Entry, EntryId, IdempotencyKey, Journal};
use time::macros::date;

type Eur = Amount<2>;

// 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 = Journal::<2>::new(LedgerId::new("acme-gmbh")?);

// Accounts are paths in a hierarchy you define. Only leaves are postable.
let cash    = journal.accounts_mut().register_path("Assets:Cash",  date!(2026-01-01))?;
let revenue = journal.accounts_mut().register_path("Income:Sales", date!(2026-01-01))?;

let recorded = journal.record(
    Entry::new(
        EntryId::generate(),
        IdempotencyKey::new(b"invoice-2026-0001".to_vec())?,
        date!(2026-03-15),
    )
    .debit(cash,     Eur::parse("1190.00")?, Currency::EUR)
    .credit(revenue, Eur::parse("1190.00")?, Currency::EUR),
)?;

// Prove the entry is committed to, without revealing any other entry.
// Verification takes the whole head โ€” size and root โ€” so the position the
// proof names is checked rather than taken on the prover's word.
let head  = journal.head();
let proof = journal.prove_inclusion(recorded.require_index()?)?;
assert!(proof.verify(&recorded.content_hash, &head));
# Ok::<(), Box<dyn std::error::Error>>(())

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 doubleentry::{Amount, Currency, Entry, EntryId, IdempotencyKey, Journal, ValidationError};
# use doubleentry::period::LedgerId;
# use time::macros::date;
# type Eur = Amount<2>;
# let mut journal = Journal::<2>::new(LedgerId::new("acme-gmbh")?);
# let cash = journal.accounts_mut().register_path("Assets:Cash", date!(2026-01-01))?;
# let revenue = journal.accounts_mut().register_path("Income:Sales", date!(2026-01-01))?;
let errors = Entry::new(
    EntryId::generate(),
    IdempotencyKey::new(b"k".to_vec())?,
    date!(2026-03-15),
)
.debit(cash,     Eur::parse("100.00")?, Currency::EUR)
.credit(revenue, Eur::parse("99.00")?,  Currency::EUR)
.seal(&journal.context())
.unwrap_err();

// Every violation is reported at once, not one round trip at a time.
assert!(errors.any(|e| matches!(e, ValidationError::Unbalanced { .. })));
# Ok::<(), Box<dyn std::error::Error>>(())

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.

โ†’ Entries and validation


๐Ÿงพ 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 doubleentry::{Amount, MoneyError};
type Eur = Amount<2>;

// Splitting is exact: the parts always re-sum to the whole.
let parts = Eur::parse("100.00")?.distribute(3)?;
assert_eq!(parts.len(), 3);
assert_eq!(Eur::checked_sum(parts.iter().copied())?, Eur::parse("100.00")?);

// Proportional splits use largest-remainder, with ties broken deterministically.
let split = Eur::parse("10.00")?.allocate(&[1, 4])?;
assert_eq!(split, vec![Eur::parse("2.00")?, Eur::parse("8.00")?]);

// Excess precision is refused rather than silently rounded.
assert_eq!(Eur::parse("1.234"), Err(MoneyError::PrecisionLoss { scale: 2 }));

// Arithmetic is total: overflow is a value, not a panic.
assert_eq!(Eur::MAX.checked_add(Eur::from_minor(1)), Err(MoneyError::Overflow));
# Ok::<(), Box<dyn std::error::Error>>(())

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 doubleentry::account::BalanceLimit;
# use doubleentry::period::LedgerId;
# use doubleentry::{Amount, Currency, Entry, EntryId, IdempotencyKey, Journal, JournalError};
# use time::macros::date;
# type Eur = Amount<2>;
# let mut journal = Journal::<2>::new(LedgerId::new("acme-gmbh")?);
# let wallet = journal.accounts_mut().register_path("Liabilities:Wallet", date!(2026-01-01))?;
# let cash = journal.accounts_mut().register_path("Assets:Cash", date!(2026-01-01))?;
// A customer wallet may not be drawn beyond what was funded.
journal.accounts_mut().set_limit(wallet, BalanceLimit::NoDebitBalance)?;

let overdraw = Entry::new(
    EntryId::generate(),
    IdempotencyKey::new(b"withdrawal-1".to_vec())?,
    date!(2026-03-15),
)
.debit(wallet, Eur::parse("50.00")?, Currency::EUR)
.credit(cash, Eur::parse("50.00")?, Currency::EUR);

assert!(matches!(
    journal.record(overdraw),
    Err(JournalError::LimitBreached { .. })
));
# Ok::<(), Box<dyn std::error::Error>>(())

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 doubleentry::merkle::MerkleLog;
# use doubleentry::Hash;
# fn leaf(i: u64) -> Hash { let mut b = [0u8; 32]; b[..8].copy_from_slice(&i.to_le_bytes()); Hash::from_bytes(b) }
let mut log = MerkleLog::new();
for i in 0..1024 { log.append(leaf(i)); }

let snapshot = log.head();
for i in 1024..2048 { log.append(leaf(i)); }

// The published snapshot is provably a prefix of what the log holds now.
let proof = log.consistency_proof(snapshot.size)?;
assert!(proof.verify(&snapshot, &log.head()));
# Ok::<(), Box<dyn std::error::Error>>(())

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 doubleentry::{Amount, BalanceKey, Currency, Entry, EntryId, IdempotencyKey, Journal, Layer};
# use doubleentry::period::{LedgerId, Period, PeriodId, PeriodState};
# use doubleentry::seal::TrialBalanceCommitment;
# use time::macros::date;
# type Eur = Amount<2>;
# let mut journal = Journal::<2>::new(LedgerId::new("acme-gmbh")?);
# let cash = journal.accounts_mut().register_path("Assets:Cash", date!(2026-01-01))?;
# let revenue = journal.accounts_mut().register_path("Income:Sales", date!(2026-01-01))?;
let march = PeriodId::new("2026-03")?;
journal.define_period(Period::new(march.clone(), date!(2026-03-01), date!(2026-03-31))?)?;
# journal.record(
#     Entry::new(EntryId::generate(), IdempotencyKey::new(b"e1".to_vec())?, date!(2026-03-15))
#         .debit(cash, Eur::parse("1190.00")?, Currency::EUR)
#         .credit(revenue, Eur::parse("1190.00")?, Currency::EUR))?;

// Stop postings first, so verification runs against a set that cannot grow.
journal.transition_period(&march, PeriodState::Closing)?;
let seal = journal.seal_period(&march)?;

// 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(date!(2026-03-31))?;
# let key = BalanceKey { account: cash, currency: Currency::EUR, layer: Layer::Settled };
let balance = TrialBalanceCommitment::of(&closing).prove(&key).expect("cash was posted to");
let binding = journal.accounts().prove_binding(cash).expect("registered");

assert!(balance.verify_naming(&binding, &seal));
assert_eq!(binding.account().path.to_string(), "Assets:Cash");
# Ok::<(), Box<dyn std::error::Error>>(())

The trial balance is keyed on account handles โ€” dense integers, cheap to compare. Seal::accounts 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. Both are stored as Merkle heads, size and root together, because a proof is checked against both.

โ†’ Periods and seals


๐Ÿ’พ 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 = PostgresStore::<2>::connect(&url, LedgerId::new("acme-gmbh")?).await?;
store.migrate().await?;
store.append(&EntryBatch::single(entry)).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.