Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Getting started with the library

librotenado is the reference library in Rust (MIT). It implements the parts of the specification marked as implemented: base types, events and merging, the canonical encoding, the form of the chart of accounts, and documents. The full API is in the API documentation.

The crate is not yet published on crates.io; use it from Git:

[dependencies]
librotenado = { git = "https://github.com/librotenado/librotenado", tag = "v0.1.0" }

Two devices, one set of books

A laptop and a phone each record a booking. They exchange their events – in any order, as often as they like – and end up with the same books. Each event is identified by the hash of its canonical encoding, so every implementation, in any language, computes the same identifier.

use librotenado::{Booking, Clock, DeviceId, Event, EventSet, Money, Payload, Posting};

// The organisation or person whose books these are, and the accounts – all identified by UUIDs. What an account is
// follows from the chart, its name from translation data.
const OWNER: &str = "0192f1e4-0000-7000-8000-0000000000a1";
const CASH: &str = "0192f1e4-0000-7000-8000-000000000001";
const OFFICE: &str = "0192f1e4-0000-7000-8000-000000000002";
const TRAVEL: &str = "0192f1e4-0000-7000-8000-000000000003";

/// A booking of `cents` from cash to an expense account – money counts the currency's smallest unit.
fn booking(text: &str, account: &str, cents: i128) -> Booking {
    let money = |base_units: i128| Money::new(base_units, "EUR".parse().unwrap()).expect("within range");
    Booking {
        date: "2026-10-24".parse().unwrap(),
        text: text.into(),
        postings: vec![
            Posting {
                owner: OWNER.parse().unwrap(),
                account: account.parse().unwrap(),
                amount: money(cents),
                note: None,
            },
            Posting {
                owner: OWNER.parse().unwrap(),
                account: CASH.parse().unwrap(),
                amount: money(-cents),
                note: None,
            },
        ],
        documents: vec![],
    }
}

/// Appends a booking to the chain of `device` in `books`.
fn record(books: &mut EventSet, device: &DeviceId, clock: &mut Clock, wall_ms: u64, booking: Booking) {
    booking.validate().expect("debit and credit balance");
    let (seq, prev) = books.chain_head(device);
    let event = Event { time: clock.now(wall_ms), seq, prev, payload: Payload::BookingRecorded { booking } };
    books.insert(event);
}

fn main() {
    let (mut laptop, mut phone) = (EventSet::new(), EventSet::new());
    let (laptop_id, phone_id): (DeviceId, DeviceId) = ("laptop".parse().unwrap(), "phone".parse().unwrap());
    let (mut laptop_clock, mut phone_clock) = (Clock::new(laptop_id.clone()), Clock::new(phone_id.clone()));

    record(&mut laptop, &laptop_id, &mut laptop_clock, 1_000, booking("Office paper", OFFICE, 1240));
    record(&mut phone, &phone_id, &mut phone_clock, 1_500, booking("Train ticket", TRAVEL, 2990));

    // Synchronisation is a union of sets: order and repetition do not matter. Events travel as their canonical
    // bytes and are hashed as received.
    for (hash, _) in phone.ordered() {
        laptop.receive(phone.canonical(&hash).expect("in the set")).expect("an event");
    }
    phone.merge(&laptop);
    assert_eq!(laptop, phone);
    assert!(laptop.verify().is_empty(), "no forks, gaps or broken links");

    // Each event is identified by the SHA-256 hash of its canonical encoding (RFC 8785).
    for (hash, _) in laptop.ordered() {
        let canonical = laptop.canonical(&hash).expect("in the set");
        println!("{hash}\n{}\n", String::from_utf8_lossy(canonical));
    }
}

Run it from a checkout:

cargo run --example quickstart

It prints each event’s hash and its canonical encoding:

sha256:ee872f6362d30fc9f5a605100f42f88d9a73dc15b6769d766435fe161a7022e7
{"booking":{"date":"2026-10-24","postings":[{"account":"0192f1e4-0000-7000-8000-000000000002","amount":{"base_units":"1240","currency":"EUR"},"owner":"0192f1e4-0000-7000-8000-0000000000a1"},{"account":"0192f1e4-0000-7000-8000-000000000001","amount":{"base_units":"-1240","currency":"EUR"},"owner":"0192f1e4-0000-7000-8000-0000000000a1"}],"text":"Office paper"},"kind":"booking_recorded","prev":null,"seq":1,"time":{"counter":0,"device":"laptop","wall_ms":1000},"type":"event"}

What to know

  • Money counts the base unit. Money is an integer number of cents – the smallest unit of its currency –, a string in JSON: {"currency": "EUR", "base_units": "1240"} is 12.40 euros. How a currency divides is reference data (CurrencyDefinition with subdivisions: [100] for EUR, [17, 29] for Galleon, Sickle and Knut), so the UI formats it; names come from translation data by the key <code>/<stage>. Codes are ISO 4217 (EUR) or begin with x- (x-btc). Prices and rates with more places are Decimal.
  • One value, one form. Amounts and decimal numbers have exactly one written form ("1240", "1.5"); other forms are rejected, so the same booking always has the same hash.
  • Objects say what they are. A root object carries its type ("event", "account", …), nested objects do not; kind names the variety ("booking_recorded", "asset"). Write and read root objects with to_root and from_root.
  • Accounts are UUIDs. What an account is follows from the chart (Chart) or from account_opened events; EventSet::review_accounts reports postings on unknown accounts instead of rejecting them.
  • Documents are records of their own. A Document has its own identity and data – kind, number, date – and points to the file it came as (Original), an artefact checked by size and hash. Without a file there is no document; a posting’s note says why. EventSet::review_documents reports bookings on unrecorded documents.
  • Books belong to someone. Organisation and Person are described by events; owner_added binds them to the books. EventSet::review_parties reports books without owners, EventSet::state the state of the books that filings record.
  • Balances are derived. EventSet::balances(until) sums the postings per owner, account and currency; EventSet::trial_balance(chart, until) rolls them up the hierarchy. CurrencyTable::v0_1() says how each currency divides, for formatting.
  • Postings are signed and owned. Every posting names its owner. Positive is debit, negative is credit; a booking balances when the postings of each owner sum to zero in every currency (Booking::validate).
  • Nothing is changed or deleted. A mistake is corrected by a counter-booking (Payload::BookingReversed).
  • Events travel as received. Pass on EventSet::canonical(hash) and read with EventSet::receive(bytes): the hash is taken over what was received, so members and payload types of later versions survive unchanged.
  • Merging never guesses. EventSet::merge is a union; EventSet::verify reports forks, gaps and broken links in a device’s chain instead of resolving them.