# Xorino Log — the chain format, so it can be checked without us This describes exactly how the logbook's hash chain is built, so that anybody who holds an export can recompute it on their own machine. It is the document the checksum page of the exported logbook points at. `verify_chain.py` beside this file is a working implementation, ~200 lines of standard-library Python. Read it as the second half of this description. **What the chain proves:** no single record was altered, inserted or removed after the fact. Each row's hash covers its own content *and* its predecessor's hash, so one changed byte invalidates that row and every row after it. **What it does not prove:** that the database owner did not recompute the whole chain from scratch. A chain without a key cannot show that. The device signature beside it addresses that, within the limits stated at the end. The honest word is **tamper-evident**, not tamper-proof. --- ## 0. Which form is this? The export declares two version numbers, and they answer different questions: | field | says | |---|---| | `format_version` | how the JSON document is structured — which blocks exist, what they are called | | `chain_format_version` | how a hashed **line** is built — field order, encodings, which fields exist | A reader who wants to *parse* the file needs the first; a reader who wants to *recompute the hashes* needs the second. **This description is chain format 4.** **Both verifiers read every line form that has ever existed**, so one current copy of a tool checks a file of any age. That is deliberate: a tool available only as "the latest version" would otherwise refuse a file it could read perfectly well, and send its reader looking for an older tool that nobody keeps. The order in which they decide is what separates checking from guessing: 1. The file declares a form the tool knows → **that form is authoritative**. If the chain fails against it, the chain is broken. The tool does not then try other forms until one passes — that would make the declaration decoration. 2. The file declares nothing → the known forms are tried, and the output **names the one that fitted**. 3. The file declares a form newer than the tool → the tool says so and checks anyway. A change to the record forms leaves the *line* untouched, so such a file is usually verifiable; the output then states that the payloads may be built differently than the tool reads them. Why the number exists at all: without it, a tool older than the file recomputes every hash wrongly and blames the data for its own age. That is not hypothetical. On 2026-09-04 two fields entered the line form while the export kept declaring the same version, and the published description of this format was wrong four hours after it was written, with nothing failing anywhere. **Corrected 2026-09-27:** this paragraph used to call form 4 "the first that changes an existing row rather than adding beside it". It is not. **Form 2 was** — it inserted `category_source` into `trip` and `full_tank` into `odoref`, at fixed positions, which is the same kind of change. Forms 4 and 5 did it again; form 3 is the only one that merely added row kinds beside the existing ones. xorino-product found it while carrying form 5 onto the help page, by reading our own history table two lines further down (bridge 445). What is true of forms 2, 4 and 5 alike: **no hash written under the previous form reproduces under them.** Each was therefore made while it was still cheap — nothing has shipped, and the only chains in an older form sit on the development phone, where a rebuild is a button. The same field after the first shipped build would cost a migration across every hash a user holds. **For a verifier, chain formats 1 and 2 are the same line.** The record forms inside `payload_before` / `payload_after` changed between them, but the chain hashes those payloads as *text* — their internal shape never enters the computation. Only a change to the `chg` line itself makes a new line form, and that has happened once: `reason` was added on 2026-08-30. Both tools still read the shape from before it. **History** | form | since | change | |---|---|---| | 1 | — | the original. Never shipped. | | 2 | 2026-09-04 | `trip` gained `category_source` after `category`; `odoref` gained `full_tank` after `fuel_millilitres` | | 3 | 2026-09-05 | two new row kinds: `origin` and `restore`. No existing row changed | | 4 | 2026-09-10 | `trip` gained `distance_source` after `measured_distance_mm` | | 5 | 2026-09-26 | `odoref` gained `reading_source` after `entered_reading_mm` | An export written before the field existed declares no `chain_format_version`; that is form 1. --- ## 1. The canonical form Every record has one, and only one, textual form that gets hashed. A tag, then the fields in a fixed order, joined by `|`: ``` chg|1|1755691500000|6:CREATE|4:TRIP|17|∅|10:trip|17|42|∅ ``` Field order is part of the format. Adding, removing or reordering a field changes every hash ever written. ### Encodings | kind | encoding | example | |---|---|---| | integer | bare decimal | `17` | | timestamp | epoch **milliseconds**, bare decimal | `1755691500000` | | boolean | `1` / `0` | `0` | | enum | by **name**, length-prefixed | `8:BUSINESS` | | text | **length-prefixed by UTF-8 byte count** | `9:Meier AG` | | blob | lower-case hex SHA-256 of its bytes, then length-prefixed | `64:0390…fb81` | | NULL | `∅` (U+2205 EMPTY SET, UTF-8 `E2 88 85`) | `∅` | Three of these are choices worth stating, because a reader will otherwise wonder: - **Length prefixes instead of escaping.** `9:Meier AG` says "take nine bytes", so a `|` inside free text cannot imitate a field boundary. Escaping would have to get every case right — a separator in a name, a trailing backslash, the NULL marker appearing literally — and a missed case shows up only when a hash fails to reproduce, long after the fact. This matters most for the payload fields, which are themselves `|`-joined strings. - **An empty string is `0:`, not `∅`.** They are different values and must hash differently. - **Epoch milliseconds, not ISO-8601.** `…T14:05:00Z` and `…T14:05:00.000+00:00` are the same instant and would hash differently. An integer has one rendering. - **Enums by name, never by ordinal.** An ordinal is a position in a source file; a name survives someone inserting a value. **No floating-point value appears anywhere in a chained row.** IEEE-754 has no canonical byte representation that survives a library change, so every quantity is an integer of a small unit: distances in **millimetres**, coordinates in units of **1e-7 degrees**, fuel in **millilitres**, money in **minor units** (cents) plus an ISO-4217 code. --- ## 2. The chain link The chain is over the change-log rows. Every record written to the book produces one, and *only these rows are hashed* — a verifier needs no other table. ``` chg | seq | occurred_at | event_type | entity_type | entity_id | payload_before | payload_after | reason ``` - `payload_before` / `payload_after` carry the record's own canonical form (section 4), length-prefixed like any other text. On a create, `before` is NULL; on a `VOID`, `after` is NULL. - `reason` is the user's justification. It is NULL for most events and **mandatory for `VOID`**. - **`prev_hash` is not a field here.** It is bound by the hash step below. Carrying it in both places would hash the predecessor twice and let the two copies drift apart. ### The hash step ``` hash(row) = SHA-256( prev_hash + "|" + canonical(row) ) lower-case hex ``` `prev_hash` is the previous row's `hash`, and the **empty string** for the first row. ### Verifying Read the rows ordered by `seq` and check three things per row: 1. `seq` is `previous + 1`, starting at 1 — **gapless**. A jump means a row was removed or inserted. 2. `prev_hash` equals the previous row's `hash`. 3. Recomputing `hash` from `prev_hash` and the canonical form reproduces the stored value. Report the **first** failure. After one altered row every later link fails too, so a count would say "everything after row 12 is wrong" when the honest statement is "row 12 is wrong". ### The limit of this check, stated plainly **Rows removed from the *end* of the chain are not detectable by these three checks.** What remains still starts at 1, is still gapless, and every hash still comes out — a truncated chain is a valid shorter chain. That matters, because the end is where the newest corrections live. Two things narrow it: - **The export declares its own `chain.length` and `chain.head`.** A verifier compares them against what it recomputed, so a truncation now needs a *second* edit to stay hidden. This is weak evidence — the declaration sits in the same file — but it turns a silent removal into a deliberate one and catches the accidental case outright. - **A signature is the real answer.** A signature stored at position 40 names the head at position 40; a chain truncated to 38 cannot produce it. That is why the verifier reports "covers position N, not the current head" rather than treating a stale signature as a pass. Without a signature, truncation cannot be ruled out. We would rather write that down than let somebody discover it while relying on the opposite. ### Event types `CREATE` · `CLASSIFY` · `CORRECT` · `MERGE_REFERENCE` · `RECONCILE` · `SPLIT` · `EXPORT` · `RESTORE` · `RECOMPUTED` · `HANDOVER` · `VOID` `RECONCILE` (since 2026-09-13) is the one event that carries the same `odoref` record in `before` and `after`: the reading was held against the book's sum, and the two fields `computed_reading_mm` and `delta_mm` went from `∅` to a value. Nothing else in the row changes, and it happens at most once per reference. Exports written before that date carry the pair on the `CREATE` row and no `RECONCILE`; both readings are correct. Entity types: `TRIP` · `STAY` · `VEHICLE` · `ODOMETER_REFERENCE` · `GAP` --- ## 3. The device signature Optional, and its absence is a valid state, not a defect. The head — the last row's `hash` — is signed with `SHA256withECDSA` over a P-256 key held in the phone's hardware-backed keystore. The signature, the public key (Base64 X.509/SPKI) and the algorithm name travel with the export, so the check needs nothing from us. Signatures are **checkpoints**: a signature at position 40 already covers everything before it, because the head hash binds every predecessor. **Each signature carries its own public key.** The key cannot leave the device and does not survive a device change — so after a restore the chain arrives intact and unsigned from that point on, and the older signatures must stay checkable against the key they were made under. **The limit, stated plainly:** without key attestation the signature proves possession of a key, not that the key sits in tamper-resistant hardware. Attestation would close that, but it needs a certificate chain fetched over the network *at the verifier*, which would tell an offline reader "unverifiable" for reasons that have nothing to do with the data. --- ## 4. Record forms Needed only to interpret the payloads; chain integrity does not depend on them. Fields in order, encodings from section 1. ``` trip id · started_at · ended_at · start_lat_e7 · start_lon_e7 · end_lat_e7 · end_lon_e7 · start_place_label · end_place_label · destination_source · odometer_start_mm · odometer_end_mm · measured_distance_mm · distance_source · category · category_source · transport_mode · business_partner · purpose · detour_reason · vehicle_id · not_my_vehicle · polyline · receipt_hash · superseded_by · derived_from · chain_seq stay id · arrived_at · departed_at · lat_e7 · lon_e7 · place_label · customer · chain_seq > **`arrived_at` may be `∅`** (since 14.09.2026). A stay whose beginning lies before the > recording — the first stay in a new book, or the one after a gap — has no measured > arrival, and the app writes no value rather than the moment the data happens to start. > "Nobody said" must not hash or read like a statement. A stay with no arrival still has a > departure; a duration computed from it would be invented, so an export leaves the time on > site blank for such a row. > > **`departed_at` is `∅` only while the stay is still open**, and an open stay is not in the > chain at all (it has no `chain_seq`), so it never appears as a `stay` line. It enters the > chain when it is closed, with both ends. vehicle id · display_name · opened_at · opening_reading_mm · opening_reading_source · closed_at · closing_reading_mm · is_premium_parallel vehid id · vehicle_id · type · value · display_name · confirmed_at · first_seen_at · chain_seq odoref id · vehicle_id · recorded_at · kind · entered_reading_mm · reading_source · computed_reading_mm · delta_mm · fuel_millilitres · full_tank · amount_minor · amount_currency · superseded_by · note · chain_seq gap id · started_at · ended_at · reason · distance_mm · resolved_as · note · chain_seq export id · created_at · period_start · period_end · app_version · algo_version · lines · chain_seq origin previous_head · previous_length · previous_format restore previous_head · previous_length · previous_format · signature_state · key_fingerprint ``` The last two are not records; they are the **opening row of a chain that replaced another one**, and they sit in `payload_before` because that is what was replaced. A chain cannot be migrated across a change to the canonical form — old hashes stop reproducing — so it is rebuilt from the exported records, and one of these rows says so. - **`origin`** — the records are this device's own, replayed after a format change. The device wrote them and had verified the chain they came from. - **`restore`** — the records came out of an export **this device did not write**: a device change, or a recovery. Whoever ran it could have edited the file first, and the resulting chain would be flawless either way. **A chain proves internal consistency, never provenance** — which is why this row records evidence instead of asserting an origin. `signature_state` is that evidence, and it has four values because the cases carry different weight: | value | means | |---|---| | `VALID` | the export carried a device signature over the head it declares, and it verified | | `INVALID` | a signature was present and did **not** verify — the one value that should stop a reader | | `NONE` | no signature. Expected after a device change: the signing key never leaves its device. A weaker statement, not a suspicious one | | `UNCHECKED` | present but not checked here. Kept apart from `NONE` because "we did not look" and "there was nothing to look at" are different findings | An export written after a restore hoists all of this into a **`provenance`** block at the top of the document, next to `chain`, including the opening row verbatim so the summary can be checked against what was hashed. A book kept on one device from the first row carries no such block. Three of these repay a second look, because they are where the book's honesty lives: - **`odoref` says where its number came from** — `reading_source` (since 2026-09-26) is `ENTERED` when a human read the instrument and typed it, `DOCUMENT` when it was taken off a receipt or another paper, `DERIVED` when it was computed backwards, and **`∅` when the row was written before the field existed**. The distinction is not bookkeeping: a number off a document can be checked against that document and a number typed at the pump cannot, and the case that produced the field is one where the *same* handwritten figure was read once as an odometer reading and once as a customer number, four weeks apart. `∅` therefore means "not recorded", never "read off the dashboard" — a rebuild of an older export leaves it empty rather than inventing a provenance. - **`odoref` keeps two readings** — `entered_reading_mm` is what the driver read off the dashboard, `computed_reading_mm` is what our running sum said at that moment, and `delta_mm` is the difference. Keeping both is what makes the difference checkable: with only one of them it would vanish the instant the continuation re-bases. **Both may be `∅`** (since 2026-09-13): the reading was typed but not yet held against the sum, because the trips before it are not booked yet. The pair arrives with a `RECONCILE` row; until then the reading makes no claim about a difference and no gap row belongs to it. - **A trip's two odometer readings are derived, never typed.** Only reference points are entered by hand. So a per-trip reading cannot have been edited after the fact — there is no input path that could. - **`category_source` says who decided the category** — `UNSET`, `INHERITED`, `RULE` or `USER_CONFIRMED`. The app may propose a category, and after the seven-day entry window an unanswered proposal stands. Recording where it came from is what keeps a guess from reading like a confirmation; only `USER_CONFIRMED` is a statement by a person. - **`distance_source` says where the kilometres came from** — `MEASURED` along the recorded points, `ODOMETER_GAP` from the difference between an entered reading and the running sum, `ENTERED` typed by the driver, `∅` for a row that never said. The middle one is the **most** accurate of the three: it comes from the vehicle's own counter, while our measurement cuts corners and loses reception. The field exists because a logbook may record and must not reconstruct — once a distance can be typed for a stretch that was never recorded, `measured_distance_mm` alone would claim a measurement that did not happen, and a reader could not tell the two apart. - **`full_tank` is three-valued, and the third value carries weight.** Litres between two fuel stops are a consumption figure only if both were full tanks. `∅` means nobody said — which must not hash, or read, the same as an explicit "not full", or an evaluation would compute a number from a pair it should skip. --- ## 5. Running the check Two implementations, for two different readers. **In a browser — `verify-chain.html`.** Open the file, drop the export on it. No installation, no upload, nothing loaded from anywhere: a single self-contained file that works from a USB stick on a machine with no internet. It brings its own SHA-256 rather than using the browser's `crypto.subtle`, whose availability depends on the security context — and a locally opened file is exactly the case where that is uncertain. A promise that holds in some browsers and not others is not a promise. It checks the chain and **not** the signature. Doing that in a browser needs crypto that is not reliably there, and a tool that quietly checks less than it claims is worse than one that names its limit. ```bash python verify_chain.py --db xorino-log.db # a database pulled off the phone python verify_chain.py --json export.json # an exported logbook python verify_chain.py --self-test # check the tool itself ``` `--self-test` runs the tool against test vectors that were built by hand from the rules above and hashed with `sha256sum` — not taken from any implementation. The same vectors are pinned in the app's own test suite and behind the browser page's *Selbsttest* button, so agreement means **four independent constructions agree**: the app's Java, this Python, the page's JavaScript, and a command-line hasher. Exit status: **0** for an intact chain, **1** for a broken one, **2** when the file declares a chain format the tool does not know *and* none of the forms it does know reproduces the hashes. The third is deliberately not the second — "I cannot check this" and "this is wrong" are different answers, and a verifier that conflates them is worse than none. Both tools print plain ASCII. A console that turns a dash into a replacement character invites exactly the doubt these tools exist to remove, and we do not know what machine they will be run on. (This sentence used to contain such a character as an illustration, which was the same mistake one paragraph further in: a reader cannot tell an illustration from a defect.)