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.
Moneyis 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 (CurrencyDefinitionwithsubdivisions:[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 withx-(x-btc). Prices and rates with more places areDecimal. - 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;kindnames the variety ("booking_recorded","asset"). Write and read root objects withto_rootandfrom_root. - Accounts are UUIDs. What an account is follows from the chart (
Chart) or fromaccount_openedevents;EventSet::review_accountsreports postings on unknown accounts instead of rejecting them. - Documents are records of their own. A
Documenthas 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’snotesays why.EventSet::review_documentsreports bookings on unrecorded documents. - Books belong to someone.
OrganisationandPersonare described by events;owner_addedbinds them to the books.EventSet::review_partiesreports books without owners,EventSet::statethe 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 withEventSet::receive(bytes): the hash is taken over what was received, so members and payload types of later versions survive unchanged. - Merging never guesses.
EventSet::mergeis a union;EventSet::verifyreports forks, gaps and broken links in a device’s chain instead of resolving them.