librotenado
The open protocol for accounting data in Europe – one model for bookings, receipts, invoices, reports and filings that the existing European formats translate into and out of.
This is the documentation of version 0.1, a draft. Everything may change before 1.0.
What it is
- One harmonised model. Accounts, bookings, currencies, documents and filings are designed once. EN 16931 and Peppol, national e-invoicing systems, SAF-T, ISO 20022 (camt), XBRL and national charts of accounts become translations of it – unambiguous, reversible, versioned and tested per country.
- The model grows, translations do not bend. Where a country distinguishes more finely, the model is extended.
- Originals stay. A translated document never replaces the original; the original remains the receipt.
- Made for synchronisation. A set of books is a growing set of immutable events. Devices exchange events and merge them by union; what cannot be merged silently goes to a review list, never guessed.
- Open for everyone. The specification is CC BY 4.0, the reference library MIT. Anyone may implement it, including commercial vendors. The first implementation is spezado.
How to read it
| If you want to … | read |
|---|---|
| understand why the protocol exists | the whitepaper |
| know what an implementation must do | the specification, starting with 0001 and 0002 |
| build on the Rust library | Getting started and the API documentation |
| contribute | CONTRIBUTING.md |
The specification uses the key words MUST, SHOULD and MAY as defined in RFC 2119 and RFC 8174. Statements that are not yet verified against their source are marked OPEN.
librotenado specification
Version 0.1 – a draft, not yet stable. Normative statements will use the key words of RFC 2119 and RFC 8174.
| Part | Content | State |
|---|---|---|
| 0001 Base types | amounts, currencies, dates, accounts, bookings | draft, implemented |
| 0002 Events and synchronisation | events, hybrid logical clocks, chains per device, merging; the accounts of a set of books | draft, implemented |
| 0003 Canonical encoding | a byte-exact encoding every implementation can hash (RFC 8785); objects and their types | draft, implemented |
| 0004 Chart of accounts | the harmonised chart: hierarchy with attributes; national charts as translations | draft, implemented (form; the accounts themselves follow) |
| 0005 Documents | invoices, receipts, statements as records of their own; originals as artefacts | draft, implemented |
| 0006 Encryption | end-to-end encryption: an ordered list of members and keys, a books key per member, a chain of keys | draft, proposal |
| 0007 Filings | submissions to authorities: derived from a state of the books, never changed; adapters over HTTPS | draft, proposal |
| 0008 Parties | the organisations and persons books belong to: legal form, registers, tax numbers, roles and shares | draft, implemented |
The reasoning behind the protocol is in the whitepaper.
0001 Base types
Version 0.1, draft. Implemented in the reference library (crates/librotenado).
Money
Money (type money, part 0003) is an amount in a currency, counted in the currency’s smallest unit, its
base unit:
{"currency": "EUR", "base_units": "-1240"} is −12.40 euros.
base_unitsis an integer written as a string in decimal: an optional-, then0or a digit1–9followed by digits ("0","1240","-5"). Every amount has this one form; a reader MUST reject any other – leading zeros,+,"-0", a decimal point, a JSON number – so the same amount always has the same bytes and the same hash (part 0003).- It is a string because amounts in some currencies exceed what JSON numbers carry exactly (2^53 − 1, part 0003): one ether is 10^18 wei. An implementation MUST accept magnitudes up to 10^38 − 1 and MUST reject an amount it cannot represent rather than round it.
- Money MUST NOT be represented as a binary floating-point number. Sums are exact. Rounding to the base unit happens before an amount enters the books; how and when to round is a rule of the law that applies.
- Reading, checking and summing amounts does not need to know how the currency divides.
Currencies
A currency is identified by its code:
- three upper-case letters: a currency of ISO 4217 by its
alphabetic code (
"EUR","CHF","JPY"); x-followed by 1 to 62 characters ofa-z,0-9and-: a currency defined outside ISO 4217 – crypto assets, vouchers, bonus points, fictional currencies ("x-btc","x-galleon").
The two forms cannot collide. A code in common use that ISO 4217 has not assigned is written in the second form:
Bitcoin is x-btc, not BTC – ISO 4217 could assign BTC one day, as the first two letters of its codes are
usually the country (BT is Bhutan, whose currency is BTN). Whether a code denotes an existing currency is a question
of the reference data.
Definitions
How a currency divides into its base unit is reference data, published in versions with the protocol and not
part of any amount. A definition (type currency, part 0003) is {"code": …, "subdivisions": […]}: from the unit
down, how many of the next smaller stage one stage contains.
| Currency | subdivisions | Stages | Base units per unit |
|---|---|---|---|
EUR | [100] | euro, cent | 100 |
JPY | [] | yen | 1 |
x-btc | [100000000] | bitcoin, satoshi | 10^8 |
x-eth | [1000000000, 1000000000] | ether, gwei, wei | 10^18 |
x-galleon | [17, 29] | Galleon, Sickle, Knut | 493 |
- Every entry is an integer from 2 to 2^53 − 1, so it is exact as a JSON number (part 0003); a larger division needs an intermediate stage, as for ether. Base units per unit – the product of the entries – MUST NOT exceed 10^38, the range of amounts.
- An ISO 4217 currency is divided as ISO 4217 gives it (“minor unit”): once by 10^minor unit, or not at all if that is 0.
- Money converts to and from decimal numbers (below) when a unit is a power of ten base units – EUR, JPY, ether –, not for Galleons.
- Later versions may add other members to a definition. Software that does not understand a definition ignores it: it still reads, checks and sums amounts in the currency, but MUST NOT convert or format them by guessing; it shows the base units.
Names
Names are not part of a definition, as they differ by language. Stage n is the unit after n subdivisions:
stage 0 is the unit itself, the last stage the base unit. Translation data names stage n by the key <code>/<n>:
EUR/0 euro, EUR/1 cent; x-galleon/0 Galleon, x-galleon/1 Sickle, x-galleon/2 Knut. The / cannot occur
in a code, so keys are unambiguous.
The table of version 0.1
The definitions of version 0.1 are published as spec/data/currencies.json, a root object of
type currency_table with version, iso_4217 – the date of the ISO 4217 list it follows – and currencies, each
code once. It holds every currency of ISO 4217 list one with a minor unit, as published by its maintenance agency on
2026-09-17 (SIX, ISO 4217 list one),
and x-btc and x-eth. ISO 4217 gives the Malagasy ariary (MGA) and the Mauritanian ouguiya (MRU) two decimal
places, and so does the table – although each divides in theory into five subunits
(Wikipedia, ISO 4217).
OPEN:
- The divisions of bitcoin and ether are common usage, not yet sourced; further currencies outside ISO 4217.
- Units without a minor unit in ISO 4217 – precious metals such as gold (XAU), units of account such as the special drawing right (XDR), the codes for testing and for no currency (XTS, XXX) – are not in the table; and a validity period per definition (DEM, replaced by the euro).
- The form of translation data, and whether the names of ISO 4217 currencies and their subunits can be taken from Unicode CLDR, including plural forms.
Decimal numbers
Prices per unit, quantities, exchange and tax rates are not money and may have more places than a currency
(fuel at 1.799 euros per litre). They are decimal numbers m · 10^−s with an integer m and 0 ≤ s ≤ 18, written as
a string in their one form: the smallest s, an optional -, the integer part without leading zeros and, only if
s > 0, . and the places, the last of which is not 0 ("1.799", "0.19", "-2.5", "100"). A reader MUST
reject any other form ("1.50", "01.5", ".5", "-0"). Version 0.1 defines the type; later parts use it.
Dates
A date is a day of the Gregorian calendar written YYYY-MM-DD, years 0001 to 9999, without time or time zone.
Accounts
An account is identified by a UUID (RFC 9562) in its one form: lower-case
hexadecimal digits in groups of 8-4-4-4-12 ("0192f1e4-7a3c-7b1e-9c2d-5e8f0a1b2c3d"). A reader MUST reject
upper-case digits. The identity carries no meaning, so it never has to change: what an account is follows from the
chart of accounts (part 0004), its name from translation data. Any version of UUID is accepted; version 7 is
RECOMMENDED for new accounts.
Bookings
A booking (type booking) is an object with date, text, postings and, if it is based on documents,
documents – an array of document identities (part 0005), absent when empty, never an empty array (part 0003).
A posting (type posting) is {"owner": …, "account": …, "amount": Money} and, if there is something to
remark, note – for example why there is no document for it; absent if none, never empty. owner is the
organisation or person whose books the posting is in (part 0008); every posting has one. A positive amount is a
debit, a negative amount a credit.
A booking MUST have at least two postings, MUST NOT contain a posting of zero, and MUST balance for every owner in
every currency: the base_units of the postings of one owner in one currency sum to zero. Each owner’s books thus
stay balanced on their own, while one booking can concern several owners – an internet bill split between a business
and its owner’s private books, both resting on the same document. A booking that converts between currencies
therefore books each currency against an exchange account.
A booking is never changed. It is cancelled by a reversal: a booking with the same accounts and the opposite amounts (part 0002).
0002 Events and synchronisation
Version 0.1, draft. Implemented in the reference library (crates/librotenado).
Events
A set of books consists of events. An event is immutable; its identity is the SHA-256 hash of its canonical encoding
(part 0003), written sha256: followed by 64 lower-case hexadecimal digits, like every hash in the protocol. An
event is always a root object of type event:
{"type": "event", "kind": "booking_recorded", "time": {…}, "seq": 1, "prev": null, "booking": {…}}
Every event has:
| Member | Content |
|---|---|
type | event |
kind | what happened (below); the members that come with it stand next to the others |
time | a hybrid logical clock timestamp (type timestamp): wall_ms, counter, device |
seq | the event’s position in its device’s chain, starting at 1 |
prev | the hash of the device’s previous event; null for position 1, never left out |
Kinds in version 0.1:
kind | Members | What happened |
|---|---|---|
organisation_described | organisation | an organisation was described, or its description changed (part 0008) |
person_described | person | a person was described, or their description changed (part 0008) |
owner_added | owner | the books belong to this organisation or person, too (part 0008) |
owner_removed | owner | the books no longer belong to them (part 0008) |
chart_selected | chart, as name/version | the books use this harmonised chart of accounts (part 0004) |
account_opened | account | an account of the books’ own was opened, refining a known one (below) |
booking_recorded | booking | a booking (part 0001) was recorded |
booking_reversed | reverses, booking | the booking recorded by the event reverses was cancelled by booking, with the opposite amounts |
document_recorded | document | a document was recorded with its data and original (part 0005) |
Later versions add kinds and members. An implementation keeps, chains and passes on events of a kind or with members it does not know, unchanged (part 0003), but does not interpret what it does not know.
Devices and clocks
Every device that writes events has a name unique within the set of books: 1 to 64 characters of a-z, 0-9 and
-. Its timestamps come from a hybrid logical clock: milliseconds since 1970-01-01 UTC, a counter for events within
the same millisecond, and the device name. Timestamps are ordered by wall time, counter and device. A device MUST
make every new timestamp later than every timestamp it has made or seen.
Merging
Two copies of the same books merge by set union of their events. Merging MUST give the same result in any order and any number of times. Events are processed in the order of their timestamps, then their hashes – an order every device agrees on.
The state of the books
The state of a set of books is the SHA-256 hash of the canonical encoding (part 0003) of the array of all its
event hashes in the agreed order, written sha256:…. Two copies with the same events have the same state, whatever
order the events arrived in. Whatever is derived from the books – a filing (part 0007), a report – records the state
it was derived from.
Chains and problems
The events of one device form a chain: positions 1, 2, 3, … without gaps, each event pointing to its predecessor
with prev. An implementation MUST report, and MUST NOT silently resolve:
- Fork – two different events of one device at the same position.
- Gap – missing positions (events that have not arrived yet).
- Broken link – an event whose
previs not the device’s previous event.
Conflicts in the content of the books – such as reversing a booking twice or reversing one that does not exist – go to a review list in the same way; resolving them is a new event, never a change to an old one.
Accounts of the books
The books select the harmonised chart they use with chart_selected; more than one selected chart is for review.
Accounts of their own – each bank account, each customer and supplier – are opened with account_opened: an account
(part 0004) whose parent is required and is an account of the chart or another opened account, of the same
kind. An implementation MUST report, and MUST NOT silently resolve:
- Unknown account – a posting on an account that is neither in the chart nor opened (the event that opens it may not have arrived yet).
- Unknown parent – an opened account without a parent, or whose parent is neither in the chart nor opened.
- Kind mismatch – an opened account of another kind than its parent.
- Duplicate – an account opened twice, or opened with the identity of an account of the chart.
Parts 0005 and 0008 add problems of documents and of parties in the same way.
0003 Canonical encoding
Version 0.1, draft. Implemented in the reference library (canonicalize, to_canonical_vec, EventSet::receive).
Every implementation must compute the same hash for the same event. The protocol therefore defines one byte-exact encoding: the JSON Canonicalization Scheme (RFC 8785), restricted to what librotenado uses.
Rules
- The encoding is UTF-8 without a byte order mark and without whitespace between tokens.
- Object members are sorted by their names, compared as sequences of UTF-16 code units (RFC 8785, section 3.2.3).
- Strings are written as RFC 8785 requires:
"and\escaped; the control characters U+0008, U+0009, U+000A, U+000C and U+000D as\b,\t,\n,\f,\r; other control characters below U+0020 as\u00xxin lower case; every other character – including/and all non-ASCII characters – as is. - Strings are not normalised:
éwritten as one character (NFC) and asewith a combining accent (NFD) are different strings with different hashes, as in RFC 8785 – an event records what was entered. Software that writes events SHOULD write text in Unicode Normalization Form C. - An object MUST NOT have two members of the same name; a reader MUST reject such JSON rather than keep one of them (RFC 8785 requires I-JSON, RFC 7493).
- Numbers MUST be integers between −(2^53 − 1) and 2^53 − 1 and are written in their shortest decimal form. librotenado has no floating-point numbers: money and decimal numbers are strings, each in its one form (part 0001). An implementation MUST reject a number outside this range when reading an event.
null,trueandfalseare written as such. A member whose value is absent is left out; a member whose value isnullis written (for exampleprevof the first event of a device).
Objects and types
Every object of the protocol has a type, defined in its part:
| Type | Part | Type | Part | |
|---|---|---|---|---|
money | 0001 | event | 0002 | |
currency | 0001 | timestamp | 0002 | |
posting | 0001 | account | 0004 | |
booking | 0001 | chart | 0004 | |
document | 0005 | chart_translation | 0004 | |
original | 0005 | organisation | 0008 | |
person | 0008 | registration | 0008 | |
tax_id | 0008 | role | 0008 | |
filing | 0007 | shares | 0008 | |
currency_table | 0001 |
The type is written as the member type exactly where the context does not determine it, and nowhere else:
- on a root object – one that stands on its own: stored, transmitted, exported or published as a file. An event
is always a root object; an account exported on its own is the root object
{"type": "account", …}; - on each object in an array that admits objects of more than one type.
Inside a root object the type of every nested object follows from where it stands – the amount of a posting is
money – so nested objects MUST NOT carry type, and root objects MUST. A reader MUST reject an object that
breaks this, as it would otherwise have two forms.
Where a type has subtypes, the member kind names them: the kind of an event (booking_recorded, …), the kind of an
account (asset, …), the kind of a document (invoice, …), of a role, of a filing. type says what an object is,
kind which variety of it.
One form of “none”
Where a member is optional, absence is its one form of “none”: it is left out, never written as null or as an
empty array, unless its part says otherwise (prev is written as null). A reader MUST reject the other forms, so
that the same content never arrives with two hashes.
Use
- The identity of an event (part 0002) is the SHA-256 hash of the canonical encoding of the event as received – all of its members, including those the reader does not know, which a later version may have added.
- An implementation MUST keep a received event in a form from which it reproduces exactly that encoding, and MUST pass that on – never an encoding of its own reading of the event, which would drop what it does not know and change the hash. It MAY store and transmit events in any formatting.
- The reference library keeps every event’s canonical bytes next to its reading (
EventSet::receive,EventSet::canonical).
Example
The member order is irrelevant on input; the canonical form sorts it:
{"seq":1,"time":{"wall_ms":1790812800000,"device":"laptop","counter":0},"prev":null,"payload":{…}}
becomes
{"payload":{…},"prev":null,"seq":1,"time":{"counter":0,"device":"laptop","wall_ms":1790812800000}}
0004 Chart of accounts
Version 0.1, draft. Implemented in the reference library (Chart, ChartTranslation, Balances).
librotenado has one harmonised chart of accounts. National charts – prescribed ones such as France’s Plan comptable général and common ones such as Germany’s standard charts – are translations of it. Where a national chart distinguishes more finely than the harmonised one, the harmonised chart grows; a translation never bends.
Accounts
An account is an object of type account (part 0003):
| Member | Content |
|---|---|
id | its identity, a UUID (part 0001), stable across versions of the chart |
kind | asset, liability, equity, income or expense |
parent | the account it refines; absent for a top-level account |
attributes | further distinctions as attribute → value, e.g. a tax treatment or a non-profit sphere; absent when empty |
Assets and expenses normally have a debit balance, the other kinds a credit balance (part 0001: debit positive).
Names are not part of an account, as they differ by language: translation data names an account by its id. The
identity carries no meaning – no path, no name – so moving an account in the hierarchy or renaming it changes
nothing that refers to it. Accounts that a set of books opens itself – each bank account, each customer – refine
accounts of the chart (part 0002).
Charts
A chart is an object of type chart with name, version and accounts. A chart MUST satisfy:
- every
idoccurs once; - every
parentis an account of the chart, of the samekind; - no account refines itself, directly or through others.
An account inherits the attributes of the accounts it refines; its own values take precedence.
Translations of national charts
A translation is an object of type chart_translation:
| Member | Content |
|---|---|
chart | the national chart, e.g. de-skr04 |
version | its version |
into | the harmonised chart it translates into, as name/version |
accounts | national account number → harmonised account |
- A translation is unambiguous by construction: each national account maps onto exactly one harmonised account.
- Every harmonised account it names MUST exist in the chart it translates into.
- It is reversible for a harmonised account onto which exactly one national account maps. Where several national accounts map onto the same harmonised account, the harmonised chart is coarser than the national one: the model has to grow before that national chart is complete. Implementations report such accounts.
Translations are versioned together with the national chart and tested with published cases per country (OPEN: which charts may be published, see the whitepaper).
Balances
The balance of an account, for one owner in one currency on a date, is the sum of the base_units of the postings
of that owner on that account in that currency, in all bookings – recorded and reversing – dated on or before that date
(part 0001). A positive balance is a debit balance, a negative one a credit balance. Over all accounts of one owner in
one currency, the balances sum to zero.
In the hierarchy, the balance of an account includes the accounts that refine it – of the chart and opened in the books (part 0002): the balance of office expenses includes that of software. Balances rolled up this way over all accounts are the trial balance, from which reports are derived.
Open points
- The content of the harmonised chart 0.1 – the accounts themselves – is the next step; this part defines its form.
- Attributes: a registry of attribute names and values (tax treatment, sphere, …).
0005 Documents
Version 0.1, draft. Implemented in the reference library (Document, Original, event kind document_recorded).
A document is what a booking is based on: an invoice, a credit note, a receipt, a bank statement. It is a record of its own: its data – kind, number, date – stand in the document, never only in its file. The file it came as, the original, is an artefact: kept byte for byte as the receipt in the legal sense, but nothing in the books depends on reading it. Everything derived from the original – the document’s data, a booking proposal, a translation into the model – refers to it and never replaces it.
There is no document without its original. If the file is missing – a lost till receipt – there is no
document; the booking has no documents, and its postings say why in their note (part 0001).
The original is the document itself – the PDF, the XML of an e-invoice, the image of a receipt – not the way it travelled: an e-mail that carried an invoice is not part of the document.
Describing a document
A document is an object of type document (part 0003):
| Member | Content |
|---|---|
id | its identity, a UUID (part 0001), like an account’s |
kind | invoice, credit_note, receipt, bank_statement, or – from an authority (part 0007) – acknowledgement or notice |
number | its number as printed on it, e.g. the invoice number; absent if it has none, never null or empty |
date | the date it bears, e.g. the invoice date (part 0001) |
received | the day it was received; it may be earlier than the day it was recorded – a letter scanned days later |
original | the file it came as (below) |
Later versions add kinds and members – the parties, the due date, the taxes and totals of an invoice (OPEN). An implementation keeps a document of a kind it does not know, but does not interpret it.
The original
The original is an object of type original:
| Member | Content |
|---|---|
hash | the SHA-256 hash of its bytes, written sha256: followed by 64 lower-case hexadecimal digits, like every hash in the protocol |
size | its size in bytes |
media_type | its media type, e.g. application/pdf, application/xml |
format | the structured format it carries, if any (below); absent otherwise, never null or empty |
name | the file name it arrived with; absent if none, never null or empty |
- Version 0.1 knows only SHA-256; the prefix lets a later version add another algorithm without confusion.
- The bytes are not part of an event. They are stored in any store addressed by their hash (a directory, an S3-compatible bucket, …) and synchronised alongside the events. An implementation MUST check the size and the hash of bytes it receives before it accepts them as the original.
Formats in version 0.1 (names are lower case; more follow with their translations):
format | Meaning |
|---|---|
en16931-ubl | an invoice according to EN 16931 in the UBL syntax (e.g. XRechnung, Peppol BIS Billing 3.0) |
en16931-cii | an invoice according to EN 16931 in the UN/CEFACT CII syntax – also a PDF with an embedded CII invoice (ZUGFeRD/Factur-X) |
iso20022-camt.053 | a bank statement (ISO 20022 BankToCustomerStatement) |
Events
document_recordedwithdocument– a document was recorded with its data and original (part 0002).- A booking MAY list the documents it is based on in
documents, as an array of document identities; the member is absent when there are none (part 0001). A document may back several bookings (an invoice and its payment), a booking may rest on several documents. - An implementation MUST report, and MUST NOT silently resolve, a booking based on a document that has not been recorded (it may not have arrived yet), and a document recorded twice under the same identity.
Open points
- The further data of a document: parties, due date, period of service, taxes and totals – for invoices taken from EN 16931 by translation.
- Correcting the data of a recorded document by a later event.
- Translations of documents into bookings: their form and their versioning, per format.
- Whether the names of formats should be the identifiers the standards define (e.g. the specification identifier of EN 16931 and its CIUS such as XRechnung) instead of names of our own.
- Which rules require keeping the structured part of an e-invoice as received (see the whitepaper).
0006 Encryption
Version 0.1, draft – not yet implemented. Everything here is a proposal for discussion.
Books are end-to-end encrypted between the people and devices that share them. Whoever relays events – a relay run by anyone – stores and forwards them without being able to read them. A backend that processes books in plain text (recognition, scheduled bank retrieval, filings) is a member like any device, and implementations make that visible to the people whose books it reads.
The design takes its architecture from any-sync, the protocol of Anytype: a strictly ordered list of members and keys beside unordered data, one symmetric key for reading, wrapped for each member, and a chain of keys that keeps the whole history readable. librotenado specifies and implements it on its own; no code is taken from any-sync.
Two strands
A set of books has two strands:
- The events (part 0002): a set without an order, merged by union, written offline. Encryption does not change this.
- The members: a strictly ordered list of records, each pointing to its predecessor by hash. A record adds or removes a member, changes a member’s rights, or changes the key. Only this list needs an order.
The first record is written by the founder of the books. While two devices or people share the books, they order the list between themselves. With more, an orderer decides the order of records it is given and rejects a record that does not follow the current head – it sees records, not books. A relay MAY be the orderer. Which records an orderer accepts – only those signed by a member with the right to make them – is checked by every member again.
Keys
- Every member – a person’s device or a backend – has a key pair for signing and one for receiving keys. Keys are made on the device and never leave it unencrypted.
- The books key is a symmetric key. Events and originals (part 0005) are encrypted with it. It is wrapped for every member with the member’s public key, and the wrapped keys stand in the member list.
- When a member is removed, a new books key is made and wrapped for the remaining members only. Everything written afterwards is encrypted with the new key.
- The new key encrypts the previous one, forming a chain: whoever holds the current key can open every earlier one and read the whole history. A new member – an accountant, an auditor – can therefore read the books from the beginning, as accounting needs for ten years and longer.
What a relay sees
An event travels in an envelope: the books it belongs to, an identifier to route and deduplicate it by, the key it is encrypted with (by its position in the key chain), and the ciphertext of its canonical encoding (part 0003). The event’s hash is computed over the plain text and is visible only to members. Originals travel the same way.
A relay therefore sees which books exist, who writes when, and how much – not what. Whether sizes are padded to hide more is OPEN.
Limits
These limits are part of the design and implementations SHOULD state them:
- Removing a member protects the future, not the past. What a member has read, it may have kept.
- A compromised device can read until the next change of key. Unlike Messaging Layer Security (RFC 9420), the design does not heal itself after a compromise; it trades this for an order only on members and for a history that stays readable.
- Losing every key of the books loses the books. There is no master key. Recovery – a member kept in reserve, a printed recovery key – is OPEN.
Why not MLS
MLS was the first candidate. It fits chats with many members, keeps no history for new members, and its changes of membership have to be applied in one agreed order (OPEN: to be verified in RFC 9420 and the MLS architecture document). Books have few members, need their whole history for a decade, and synchronise without an order. Whether MLS could serve as the transport of the member list later is OPEN.
Open points
- The algorithms: signing, wrapping keys, encrypting events and originals.
- The member list byte for byte: records, rights (read, write, manage members), invitations and requests to join.
- The envelope byte for byte, and the identifier a relay routes by.
- Access for a limited time – an auditor for one period – and whether it can be narrower than the whole history.
- Recovery when keys are lost; archives for retention periods of ten years and more.
0007 Filings
Version 0.1, draft – not yet implemented. Everything here is a proposal for discussion.
Books end in filings: VAT returns, recapitulative statements, annual financial statements, audit files. Every country has its own systems for them. librotenado defines one open interface; adapters connect it to the systems of each country.
Filings
A filing is an object of type filing (part 0003):
| Member | Content |
|---|---|
id | its identity, a UUID (part 0001) |
kind | what it is, e.g. vat_return, recapitulative_statement, annual_accounts |
jurisdiction | the country it is filed in, ISO 3166-1 alpha-2 (DE) |
filer | the organisation or person filing (part 0008), an owner of the books |
period | the period it covers: from and to, both dates (part 0001), inclusive |
basis | the state of the books it was derived from (part 0002) |
corrects | the filing it corrects, if any; absent otherwise |
content | what it reports, in the terms of the model – per kind (OPEN) |
- A filing is derived from the books and records from what.
basisis the state of the books when it was derived: whoever has the same events can derive it again and get the same content. When an event that concerns its period arrives later, the state no longer matches – the filing may need a correction. - A filing is never changed. A correction is a new filing whose
correctsnames the first, as a reversal does for a booking.
Events
kind | Members | What happened |
|---|---|---|
filing_prepared | filing | a filing was derived and is ready to be submitted |
filing_submitted | filing, documents | it was submitted; documents are what the authority returned on submission |
filing_accepted | filing, documents | the authority accepted it |
filing_rejected | filing, documents | the authority rejected it |
In the last three, filing is the identity of the prepared filing. What an authority returns – a confirmation of
receipt, a protocol, a notice – is a document (part 0005) of kind acknowledgement or notice, with its original.
A rejected filing, and a filing whose basis no longer matches the books for its period, go to the review list
(part 0002).
Adapters
An adapter translates a filing into the format of one country and submits it. Adapters are separate services: an implementation talks to an adapter over HTTPS, with a documented REST or GraphQL interface (OPEN: which, or both). So an adapter can run on the same machine or as a service of a provider, and can use libraries that cannot be part of the implementation itself – for example Germany’s ERiC for ELSTER, which is not open source (OPEN: the conditions of its use).
Credentials are not part of the protocol. Certificates, passwords and tokens for an authority’s system belong to the adapter or to the device that submits; an event records that a filing was submitted and by whom, never how access was gained.
Adapters are tested with the official test cases of each authority where they are published.
Open points
- The kinds of filings and their content per country, starting with Germany.
- The adapter interface, request by request: REST or GraphQL, authentication of the implementation towards the adapter.
- Which events concern a filing’s period – and so make its
basisoutdated – per kind.
0008 Parties
Version 0.1, draft. Implemented in the reference library (Organisation, Person, event kinds
organisation_described, person_described, owner_added, owner_removed).
A set of books always belongs to at least one organisation or natural person – its owners – and every posting names the owner whose books it is in (part 0001). Who they are decides which law applies, which national chart and filings follow, and who files (part 0007).
Organisations and persons are described by events (part 0002). A later description with the same identity replaces an earlier one in the agreed order of events; the earlier one stays in the books, as everything does.
Organisations
An organisation – a company, an association, a foundation, a partnership – is an object of type organisation:
| Member | Content |
|---|---|
id | its identity, a UUID (part 0001) |
name | its name as registered |
legal_form | its legal form as a code, e.g. de-gmbh, de-ev, fr-sas |
country | the country whose law it is formed under, ISO 3166-1 alpha-2 (DE) |
registrations | its entries in public registers (below); absent when none, never empty |
tax_ids | its tax numbers (below); absent when none, never empty |
shares | how its capital is divided into shares (below); absent when it has none |
A registration (type registration) is register – the register as a code, e.g. de-hrb –, number, and,
where a register is kept by several authorities, authority (e.g. the local court).
Shares (type shares) are count – a whole number, at least 1 – and nominal, the nominal amount of each, money
above zero (part 0001). An organisation lists one entry per nominal amount; its share capital is the sum of count ×
nominal. A company with a share capital of 1,024 euros in 512 shares of 2 euros has
"shares": [{"count": 512, "nominal": {"currency": "EUR", "base_units": "200"}}]. Shares are always counted in
whole numbers; what fraction of the capital someone holds follows from the counts – 257 of 512 shares are 50.2 %.
A tax number (type tax_id) is scheme and number. The scheme vat is an EU VAT identification number, written
with its country prefix; national schemes have codes such as de-steuernummer.
Persons
A natural person is an object of type person:
| Member | Content |
|---|---|
id | their identity, a UUID |
name | their name as they use it |
tax_ids | their tax numbers, e.g. a personal tax identification number; absent when none |
roles | their roles in organisations (below); absent when none |
A role (type role) is organisation – the identity of an organisation –, kind, and, where the role holds
shares, shares: how many of one nominal amount the organisation has ({"count": 257, "nominal": …}). A person
holding shares of several nominal amounts has one role for each.
kind | The person … |
|---|---|
owner | owns the organisation alone, without shares – a sole trader’s business |
shareholder | holds shares of a company |
partner | is a partner of a partnership |
managing_director | manages and represents it |
board_member | is a member of its board |
member | is a member of an association or a cooperative |
authorised_signatory | may sign for it, e.g. under a commercial power of attorney |
Later versions add kinds; an implementation keeps a role of a kind it does not know but does not interpret it.
Owners
owner_added with owner – the identity of an organisation or a person – makes them an owner of the books;
owner_removed ends it. An implementation MUST report, and MUST NOT silently resolve:
- No owner – the books belong to no organisation and no person.
- Unknown owner – an owner that is not described (the description may not have arrived yet).
- Unknown organisation – a person’s role in an organisation that is not described.
- Shares – shares held of a nominal amount the organisation does not have, or more of them than it has.
- Not an owner – a posting (part 0001) in the books of an organisation or person that is not an owner.
Personal data
Persons are personal data. The protocol carries what accounting and filings need and no more; who may read the books is a matter of encryption (part 0006).
Open points
- The registries of codes: legal forms, registers, tax schemes – per country, with sources.
- Further data an organisation’s books depend on: the financial year, the period of VAT returns, the applicable VAT regime (e.g. small businesses), the accounting standard – and whether they belong to the organisation or to the books.
- Addresses, and the parties of documents (sellers, buyers, part 0005).
- Relations between organisations: groups, holdings, branches.
- Capital that is not divided into shares: the contributions of partners in a partnership, the assets of a foundation.
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.
Changelog
Specification and library share one version. Until 1.0, any version may change what an earlier one specified.
Unreleased
- 0001 Base types: the currency table of version 0.1,
spec/data/currencies.json– every ISO 4217 currency with a minor unit (list of 2026-09-17), andx-btc,x-eth; typecurrency_table. - 0004 Chart of accounts: balances per owner, account and currency on a date, and the trial balance rolled up the hierarchy.
- Library:
CurrencyTable::v0_1(),EventSet::balances,EventSet::trial_balance,Balances.
0.1.0 – 2026-10-28
The first draft of the protocol.
Specification
- 0001 Base types: money as an exact count of a currency’s base unit
(
{"currency": "EUR", "base_units": "1240"}); currency codes from ISO 4217 or beginning withx-; definitions of how a currency divides –subdivisions, e.g.[100]for EUR – as reference data, names by language as keys<code>/<stage>; decimal numbers for prices and rates; amounts and numbers in one written form only; dates, account identifiers, postings that name their owner, bookings that balance for every owner in every currency. - 0002 Events and synchronisation: immutable events as root objects with a
kind, hybrid logical clocks, a hash chain per device, merging by union, forks, gaps and broken links reported rather than resolved; the chart a set of books uses (chart_selected) and accounts of its own (account_opened), with postings on unknown accounts for review. - 0003 Canonical encoding: byte-exact JSON after RFC 8785 as the basis of event
hashes, taken over the event as received so that members of later versions survive; objects and their types
(
typeon root objects,kindfor varieties); one form of “none”; no duplicate members; strings not normalised (NFC recommended). - 0004 Chart of accounts: the form of the harmonised chart – accounts identified by UUIDs, without names, in a hierarchy with attributes – and translations of national charts. The accounts themselves are not yet specified.
- 0005 Documents: invoices, credit notes, receipts and bank statements as records of their
own – identity, kind, number, date, day received – with the file they came as, the original, as an artefact
identified by its hash; no document without its original, a note on the posting instead. Every hash is written
sha256:…. - 0006 Encryption: proposal, inspired by any-sync – an ordered list of members and keys beside the unordered events, a books key wrapped per member, a chain of keys that keeps the history readable.
- 0007 Filings: proposal – filings derived from a recorded state of the books, never changed but corrected, answers of authorities as documents, adapters over HTTPS, credentials outside the protocol.
- 0008 Parties: every set of books belongs to at least one organisation or person – organisations with legal form, registers, tax numbers and their capital in whole shares of a nominal amount, persons with tax numbers, roles and the shares they hold.
Library
librotenado0.1.0 implements 0001–0005 and 0008 (Money,Currency,CurrencyDefinition,Decimal,Booking,Event,EventSet,Chart,Document,Organisation,Person, …), with the examplequickstart.
Documentation
- The documentation site (
docs/, built with mdBook) for librotenado.dev: introduction, specification, a guide to the library and the API documentation.