Expand description
An immutable, tamper-evident double-entry bookkeeping engine.
A ledger is one entity’s books, kept as one append-only RFC 6962 SHA-256 Merkle log of typed records: a genesis record, account events, currency declarations, policy events, period definitions, entries, clearings and seals. Every record has exactly one canonical byte form, so a position in the log, a leaf hash and a proof mean the same thing to every implementation.
Every write is decided by one pure function, the rule core: it takes
the draft and a read-only view of the ledger at the position the record
will be appended, and returns either the record to write or one
Rejection with a stable code. No façade and no store implements a
rule; Journal::audit and Ledger::audit replay the whole log
through the same core.
§Where things are
- The crate root — what bookkeeping code names: the two façades with
the same methods,
Journal(sync, in memory) andLedger(async, over a store); the oneErrorand itsRejection; the values (exactAmounts,Currencycodes, validated names such asAccountPathandLabel, the derivedEntryId); theEntrydraft;BalanceQuery; theTreeHeadproofs verify against. currency— the currency constants (EUR,JPY, …).report— what the reads return:TrialBalance,Statement,Rollup,OpenItems and balance assertions.export— the GoBD export view: verifiable flat rows.error— the payloadsError’s variants carry.log— proofs and the RFC 6962 hashing an independent verifier re-implements;record— the eight record kinds and their canonical bytes.prelude— the everyday vocabulary in one import.store— where a ledger is kept: theStorecontract,MemoryStore, the SQL store, and the rule core’s protocol for implementers of another store.transparency— C2SP checkpoints, witness requests and replies, policies, the auditor’s verifier, proof bundles and tiles.testing(featuretesting) — the conformance suite every store passes.
The user guide covers the same ground: Start, Concepts, Reporting, Operating and Assurance.
§Example
use doubleentry::{AccountKind, Entry, Journal, LedgerId, Rejection, amount, date, currency::EUR};
let mut books = Journal::new(LedgerId::new("example.com/acme")?).as_actor("setup");
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.open("Assets:Cash", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;
books.define_period("2026-03", date!("2026-03-01"), date!("2026-03-31"))?;
let invoice = Entry::new("invoice-2026-0001", date!("2026-03-15"))
.debit("Assets:Cash", amount!("1190.00"), EUR)
.credit("Income:Sales", amount!("1190.00"), EUR);
let recorded = books.record(invoice.clone())?;
assert!(!recorded.replayed);
assert!(books.record(invoice)?.replayed); // a retry changes nothing
// An unbalanced draft is refused with a stable code.
let refused = books.record(
Entry::new("invoice-2026-0002", date!("2026-03-16"))
.debit("Assets:Cash", amount!("10.00"), EUR)
.credit("Income:Sales", amount!("9.00"), EUR),
);
assert_eq!(refused.err().as_ref().and_then(|e| e.rejection()).map(Rejection::code), Some(106));
books.seal_period("2026-03")?;
books.audit()?;§A guide
- Set up the books —
declare_currency(with its exponent),define_months(ordefine_period),openaccounts by path, and optionallyset_limit. Every setup call is idempotent — repeating it with the same definition writes nothing, a different one is refused — so a service runs it on every start. - Draft and record —
Entrybuilders are infallible; a malformed draft is refused byrecordwith aRejectionand a stable code. The idempotency key is the entry’s identity: a retry of the same draft returns the originalRecordedwithreplayed == true.record_batchrecords many drafts atomically, in order, in one transaction. - Read —
balanceand the reports take aBalanceQuery(a log prefix, a date window by booking or value date, a period, dimension filters); both façades answer them alike. - Close and prove —
seal_periodcommits every balance through the period’s end; periods are sealed in calendar order, andseal_throughseals every open period up to one in a single call;prove_entry,prove_consistencyandprove_balancegive proofs that verify against aTreeHeadalone;transparencysigns heads and lets independent witnesses cosign them. - Keep it durable — the same calls on a
Ledgerover astore(MemoryStore, SQLite, PostgreSQL), each.awaited; the store runs the rule core inside its write transaction.auditreplays the log and compares.
§Errors
Every operation of either façade returns Result, whose error is the
one Error. Match on its variant to decide what to do:
| Error | is_transient | may_have_applied | What the caller does |
|---|---|---|---|
Rejected(r) | false | false | show r and its code; fix the input; never retry unchanged |
BatchRejected { index, rejection } | false | false | nothing of the batch was written; fix draft index (or drop it) and record the batch again |
Value(e), Amount(e) | false | false | fix the value or the arithmetic at the call site (never from a ledger operation) |
Query(e) | false | false | fix the question: the entry, period, window or position asked for |
Transparency(e) | false | false | the artefact is refused: a forged, stale or foreign checkpoint, bundle or reply; never retry it unchanged |
Integrity(e) | false | false | stop writing; investigate position e.position(); restore from a backup and audit |
Backend(b), transient, definite | true | false | retry later with the same draft |
Backend(b), outcome unknown | either | true | retry with the same draft: it applies once, or comes back with replayed == true |
Backend(b), permanent, definite | false | false | do not retry automatically: an operator problem (configuration, permissions) |
Config(e) | false | false | the store holds another ledger, scale or schema: fix the URL, schema name, ledger id or P — the data is fine |
{:?} on an Error prints its Display line, stable code included, so
fn main() -> doubleentry::Result<()> reports rejected: [427] ….
use doubleentry::prelude::*;
match books.record(entry) {
Ok(recorded) if recorded.replayed => {} // a retry: nothing written
Ok(_) => {}
Err(Error::Rejected(rejection)) => eprintln!("refused: {rejection}"),
Err(error) if error.is_transient() => return Err(error), // retry later, same draft
Err(error) => return Err(error),
}§Feature flags
All features are off by default.
-
serde— Enable serdeSerialize/Deserializeon all public types.Serde is a transport concern only. The canonical encoding used for hashing is independent of it and is never routed through serde.
-
postgres—SqlStore<Postgres>, on sqlx. Enables no TLS stack: for TLS, addsqlx = { version = "0.9", features = ["tls-rustls-aws-lc-rs"] }(ortls-rustls-ring-webpki,tls-native-tls) to your application. One schema,doubleentry/1; no migrations. -
sqlite—SqlStore<Sqlite>, on sqlx, with SQLite bundled. One schema,doubleentry/1; no migrations. -
witness— Ed25519 fortransparency:LogKeysigns checkpoints; cosignatures and policies are verified;WitnessSessionandVerifierwork. The C2SP text formats parse and render without it. No HTTP: requests and replies are bytes. -
testing—doubleentry::testing: the public conformance suite forStoreimplementations and, withwitness,TestWitness. Addstokio. -
tracing—tracingspans per façade operation and per SQL store write. Never an amount, memo or account path.
Modules§
- currency
- The currency codes as constants —
use doubleentry::currency::JPY;. Thepreludebrings the five most common; any other ISO 4217 code isCurrency::newor thecurrency!literal. - error
- The payloads
Error’s variants carry. What each variant means and whether to retry it: the crate-level table. - export
- The GoBD export view: the books as verifiable flat rows (§ 147 Abs. 6 AO, GoBD Rz. 94, 165–167).
- log
- The log as an independent verifier sees it: RFC 6962 SHA-256 hashing,
inclusion and consistency proofs against a
TreeHead, the sealBalanceTrees and their proofs, and the leaves a journal is replayed from. Bookkeeping code rarely names these: the façades’prove_*methods return the proofs, which verify themselves. - prelude
- The everyday vocabulary in one import: the façades, the one error, the values and drafts of drafting, recording and reading, the five common currencies and the two literal macros.
- record
- The log’s record kinds and their canonical byte form — what an exporter or an independent verifier decodes; bookkeeping code rarely names them.
- report
- Every read, once: balances, the trial balance, statements, the rollup, open items and balance assertions.
- store
- Where a ledger is kept: the
Storecontract, the in-memoryMemoryStoreand the one SQL implementationSqlStore. - testing
testing - The public conformance suite (feature
testing): whether aStorekeeps the contract is decided here, not by a reviewer — with the canaries ofBrokenStore(reads ignoringthrough, a missing open-holds read, stale values, …) each caught by some check, andindependent_handlesfor a lock that must hold across processes. - transparency
- Checkpoints others can cosign and auditors can check: the C2SP transparency formats over the ledger’s RFC 6962 log.
Macros§
- amount
- A compile-checked
Amountliteral;Pis inferred from context. - currency
- A compile-checked
Currencycode. - date
- A
Datefrom aYYYY-MM-DDliteral, checked at compile time.
Structs§
- Account
- An account’s master data in force: what
Journal::accountandLedger::accountreturn. The latest version of each field — the window, limit and class every later entry is checked against. - Account
Handle - An account’s number in its ledger: the count of registrations before it.
- Account
Path - An account’s place in the account tree: 1–16
:-separated segments. - Account
Spec - An account to register: path, kind and first open day, with optional class, last open day and balance limit. Built infallibly; the path and class are validated when the account is registered.
- Amount
- An exact amount of money: an
i128count of 10⁻ᴾ units. - Audit
Report - What a passing audit covered.
- Balance
- The gross T-account of one (account, currency, layer): debits and credits kept apart, and the number of postings.
- Balance
Query - The question every balance read answers: which postings count.
- Capture
Time - When the caller says an entry was captured: Unix seconds and the UTC offset in minutes — a caller’s claim, never read from a clock.
- Currency
- A currency code: 1–16 characters, each
A–Zor0–9. - Date
- The calendar date every booking date, value date and effective date is
(re-exported from
time, sodate!needs no dependency of yours). Date in the proleptic Gregorian calendar. - Dimensions
- At most eight
axis → valuelabels on a posting, in ascending axis order. - Document
- A source document an entry is based on (an invoice, a receipt, a bank statement line).
- Entry
- An entry draft: what a caller submits to be recorded.
- EntryId
- An entry’s identity: 16 bytes derived from the ledger and the entry’s
IdempotencyKey, never generated. - Entry
Record - Record
0x06: a committed entry. Built only throughEntryRecord::new, which applies the decoder’s checks, so every value has one canonical form. - Hold
State - A hold and where it stands, derived from its records.
- Idempotency
Key - The caller’s identity for an entry — typically the source transaction id: 1–128 bytes of UTF-8 without control characters, compared byte-exactly (never normalised).
- Journal
- A ledger kept in memory and written synchronously: the rule core over an in-memory state.
- Label
- A short name: 1–64 Unicode scalar values in NFC, no whitespace at either
edge, no control character and no invisible character
(
ValueError::InvisibleCharacter). - Ledger
- A ledger kept in a
Storeand written asynchronously: the rule core run by the store inside its write transaction, under the ledger’s lock. - Ledger
Id - A ledger’s identity: 1–255 bytes, each printable ASCII (
0x21–0x7E) except+. - Ledger
Policy - The ledger-wide policy: dimensions every posting must carry, and how far a value date may drift from the booking date.
- Memo
Salt - The 16-byte salt of a memo commitment, kept beside the leaf with the text.
- Page
- One page of a paged read.
- Page
Read - A page request: an exclusive cursor, a limit and an optional bound.
- Posting
- One movement in a draft: an account, a side, a positive amount, a currency and optional dimensions.
- Posting
Ref - One posting of one entry: the entry’s id and the posting’s index.
- Rate
- An exchange rate recorded on an entry:
mantissa × 10^exponentunits ofquoteper unit ofbase. Data only — the crate converts nothing. - Recorded
- The receipt of a recorded entry.
- Seal
Receipt - The receipt of a sealed period.
- Tree
Head - A log’s size and root: the only thing proofs verify against.
Enums§
- Account
Kind - What an account is, fixed at registration.
- Account
Ref - How a draft names an account: by path, or by handle.
- Balance
Limit - Which balance an account may not reach.
- Error
- Everything a ledger operation can fail with.
- Hold
Status - Where a hold stands.
- Layer
- The balancing layer beside the currency: settled movements, or pending ones.
- Post
Amount - How much of a hold a
HoldAction::Postsettles. - Rejection
- Why the rule core refused a write.
- Resolution
Kind - How a hold was resolved.
- Rounding
- How a result that falls between two representable amounts is rounded.
- Side
- The side of a posting. Magnitudes are positive; the side is never a sign.
Functions§
- validate
- Every structural violation of a draft, in precedence order — what a form would list before submitting. Reads no state; an empty list does not mean the draft will be recorded. A memo without a salt is not listed: the façade that records the draft supplies the salt.
Type Aliases§
- Hold
Cursor - A hold page cursor: (expiry date, position); the date is
NoneforHoldRead::OpenByAccount. - Period
Id - A period’s id (
"2026-03","FY2026"): aLabel. - Result
- The crate’s result type.