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

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 existsthe whitepaper
know what an implementation must dothe specification, starting with 0001 and 0002
build on the Rust libraryGetting started and the API documentation
contributeCONTRIBUTING.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.

PartContentState
0001 Base typesamounts, currencies, dates, accounts, bookingsdraft, implemented
0002 Events and synchronisationevents, hybrid logical clocks, chains per device, merging; the accounts of a set of booksdraft, implemented
0003 Canonical encodinga byte-exact encoding every implementation can hash (RFC 8785); objects and their typesdraft, implemented
0004 Chart of accountsthe harmonised chart: hierarchy with attributes; national charts as translationsdraft, implemented (form; the accounts themselves follow)
0005 Documentsinvoices, receipts, statements as records of their own; originals as artefactsdraft, implemented
0006 Encryptionend-to-end encryption: an ordered list of members and keys, a books key per member, a chain of keysdraft, proposal
0007 Filingssubmissions to authorities: derived from a state of the books, never changed; adapters over HTTPSdraft, proposal
0008 Partiesthe organisations and persons books belong to: legal form, registers, tax numbers, roles and sharesdraft, 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_units is an integer written as a string in decimal: an optional -, then 0 or a digit 1–9 followed 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 of a-z, 0-9 and -: 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.

CurrencysubdivisionsStagesBase units per unit
EUR[100]euro, cent100
JPY[]yen1
x-btc[100000000]bitcoin, satoshi10^8
x-eth[1000000000, 1000000000]ether, gwei, wei10^18
x-galleon[17, 29]Galleon, Sickle, Knut493
  • 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:

MemberContent
typeevent
kindwhat happened (below); the members that come with it stand next to the others
timea hybrid logical clock timestamp (type timestamp): wall_ms, counter, device
seqthe event’s position in its device’s chain, starting at 1
prevthe hash of the device’s previous event; null for position 1, never left out

Kinds in version 0.1:

kindMembersWhat happened
organisation_describedorganisationan organisation was described, or its description changed (part 0008)
person_describedpersona person was described, or their description changed (part 0008)
owner_addedownerthe books belong to this organisation or person, too (part 0008)
owner_removedownerthe books no longer belong to them (part 0008)
chart_selectedchart, as name/versionthe books use this harmonised chart of accounts (part 0004)
account_openedaccountan account of the books’ own was opened, refining a known one (below)
booking_recordedbookinga booking (part 0001) was recorded
booking_reversedreverses, bookingthe booking recorded by the event reverses was cancelled by booking, with the opposite amounts
document_recordeddocumenta 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 prev is 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 \u00xx in lower case; every other character – including / and all non-ASCII characters – as is.
  • Strings are not normalised: é written as one character (NFC) and as e with 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, true and false are written as such. A member whose value is absent is left out; a member whose value is null is written (for example prev of the first event of a device).

Objects and types

Every object of the protocol has a type, defined in its part:

TypePartTypePart
money0001event0002
currency0001timestamp0002
posting0001account0004
booking0001chart0004
document0005chart_translation0004
original0005organisation0008
person0008registration0008
tax_id0008role0008
filing0007shares0008
currency_table0001

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):

MemberContent
idits identity, a UUID (part 0001), stable across versions of the chart
kindasset, liability, equity, income or expense
parentthe account it refines; absent for a top-level account
attributesfurther 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 id occurs once;
  • every parent is an account of the chart, of the same kind;
  • 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:

MemberContent
chartthe national chart, e.g. de-skr04
versionits version
intothe harmonised chart it translates into, as name/version
accountsnational 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):

MemberContent
idits identity, a UUID (part 0001), like an account’s
kindinvoice, credit_note, receipt, bank_statement, or – from an authority (part 0007) – acknowledgement or notice
numberits number as printed on it, e.g. the invoice number; absent if it has none, never null or empty
datethe date it bears, e.g. the invoice date (part 0001)
receivedthe day it was received; it may be earlier than the day it was recorded – a letter scanned days later
originalthe 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:

MemberContent
hashthe SHA-256 hash of its bytes, written sha256: followed by 64 lower-case hexadecimal digits, like every hash in the protocol
sizeits size in bytes
media_typeits media type, e.g. application/pdf, application/xml
formatthe structured format it carries, if any (below); absent otherwise, never null or empty
namethe 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):

formatMeaning
en16931-ublan invoice according to EN 16931 in the UBL syntax (e.g. XRechnung, Peppol BIS Billing 3.0)
en16931-ciian invoice according to EN 16931 in the UN/CEFACT CII syntax – also a PDF with an embedded CII invoice (ZUGFeRD/Factur-X)
iso20022-camt.053a bank statement (ISO 20022 BankToCustomerStatement)

Events

  • document_recorded with document – 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):

MemberContent
idits identity, a UUID (part 0001)
kindwhat it is, e.g. vat_return, recapitulative_statement, annual_accounts
jurisdictionthe country it is filed in, ISO 3166-1 alpha-2 (DE)
filerthe organisation or person filing (part 0008), an owner of the books
periodthe period it covers: from and to, both dates (part 0001), inclusive
basisthe state of the books it was derived from (part 0002)
correctsthe filing it corrects, if any; absent otherwise
contentwhat it reports, in the terms of the model – per kind (OPEN)
  • A filing is derived from the books and records from what. basis is 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 corrects names the first, as a reversal does for a booking.

Events

kindMembersWhat happened
filing_preparedfilinga filing was derived and is ready to be submitted
filing_submittedfiling, documentsit was submitted; documents are what the authority returned on submission
filing_acceptedfiling, documentsthe authority accepted it
filing_rejectedfiling, documentsthe 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 basis outdated – 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:

MemberContent
idits identity, a UUID (part 0001)
nameits name as registered
legal_formits legal form as a code, e.g. de-gmbh, de-ev, fr-sas
countrythe country whose law it is formed under, ISO 3166-1 alpha-2 (DE)
registrationsits entries in public registers (below); absent when none, never empty
tax_idsits tax numbers (below); absent when none, never empty
shareshow 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:

MemberContent
idtheir identity, a UUID
nametheir name as they use it
tax_idstheir tax numbers, e.g. a personal tax identification number; absent when none
rolestheir 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.

kindThe person …
ownerowns the organisation alone, without shares – a sole trader’s business
shareholderholds shares of a company
partneris a partner of a partnership
managing_directormanages and represents it
board_memberis a member of its board
memberis a member of an association or a cooperative
authorised_signatorymay 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. 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.

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), and x-btc, x-eth; type currency_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 with x-; 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 (type on root objects, kind for 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

  • librotenado 0.1.0 implements 0001–0005 and 0008 (Money, Currency, CurrencyDefinition, Decimal, Booking, Event, EventSet, Chart, Document, Organisation, Person, …), with the example quickstart.

Documentation

  • The documentation site (docs/, built with mdBook) for librotenado.dev: introduction, specification, a guide to the library and the API documentation.