Skip to content
Portrait of Henrique KasprzakHenrique Kasprzak
All work

2024 — present

Xanadu Fleet

Inspections that survive with no signal

Role
Sole author, close to 300 commits
Built with
Go, PostgreSQL, htmx, PWA, S3 Object Lock (WORM), Ed25519, Goose, GitHub Actions, MagaLU Cloud

The problem

A haulage company inspects every vehicle twice: once when it leaves the yard, once when it comes back. That process was paper. A driver filled in a form on a clipboard, the sheet went into a folder, and by the time anyone needed it — a breakdown, an insurance claim, an argument about who put the dent in the door — it was missing, illegible, or filed under the wrong month.

Nobody could answer the only question that mattered: what condition was this truck in on the 14th, and who signed for it?

What the system does

  • Versioned digital checklists — 58 items for a truck, 19 for a machine, trailers attached to the inspection — executed at hand-out and hand-back.
  • An offline-first PWA that field crews use to capture inspections, photos and fuelings with no connectivity, syncing later.
  • A web admin built with html/template and htmx: assets, users, reports, a manager’s cockpit, CSV exports.
  • Permission groups — driver, operator, manager, administrator — edited in the app, so extending what a group may do is a grant, not a deploy.
  • Corrections that supersede a record instead of editing it, and nonconformities that carry forward to the next trip.
  • A per-tenant hash chain, signed and anchored to write-once storage, so the records are evidence rather than opinion.

Ten Architecture Decision Records document the choices below; three user manuals — driver, manager, administrator — document the result.

Versioning the checklist came first

The first real decision had nothing to do with offline support. It was this: if you edit a checklist, every inspection ever filled in against it silently changes meaning. An inspection that passed 58 checks now claims to have passed 59, and one of them was never actually looked at.

So checklist versions are immutable, and every inspection pins the exact version it was executed against. Adding an item creates a new version; historical records keep pointing at the old one and keep telling the truth. It costs a join and a little discipline in the admin UI, and it is the difference between a record and a guess.

A record is never edited, only superseded

An inspection is legal evidence, so it is immutable on submit — the database tables are append-only, with triggers that block UPDATE and DELETE. But drivers still make genuine mistakes: a wrong odometer reading, a mis-marked item. The correction path had to fix the data without touching the record.

A correction is a new inspection that supersedes an earlier one. It carries a reference to the record it replaces; the original is never modified and stays in history, marked as superseded. The detail pages link both ways. Only the newest record in a chain can be corrected, so there is always exactly one current answer.

The subtle part is that the supersession link itself is committed to the hash chain — but only when present. Records taken before corrections existed hash byte for byte as they always did and keep verifying. The link therefore cannot be re-pointed or hidden after the fact.

Offline-first, and what that actually meant here

The mobile client is a PWA rather than a native app, and the reasoning was measured, not assumed: the driver fleet is standardised on Android, so the one real PWA weakness — iOS evicting unsynced storage — does not apply. A native app would have added a store account, a review cycle, and a second toolchain for a solo developer to maintain, to serve a fleet the PWA already served.

What offline-first cost in practice:

  • An IndexedDB durable outbox on the device, with persistent storage requested so the browser will not evict it.
  • A framework-neutral REST sync API — pull reference data, push inspections — so the client choice stays reversible without touching the server.
  • Ingest that tolerates the real world: records arriving out of order must not roll an asset’s odometer backwards, and an inspection whose clock is badly skewed is quarantined rather than trusted or dropped.
  • Photos ride a separate channel. The device presigns an upload, PUTs the bytes to object storage, then registers the photo against its record — and the server checks that the hash it declared matches what actually landed. A record is accepted before its photos arrive, so a checklist is never held hostage by a slow image.

Carrying a defect forward without lying about the photo

When a driver starts an inspection, every item that failed on that truck’s previous inspection comes back pre-marked. For a defect that had a photo, the driver chooses: keep it, update the photo, or mark it fixed.

“Keep” is the interesting one. The evidence rules forbid a stale photo standing in as if it were captured today, but forcing a re-shoot of an unchanged crack is a misleading kind of honesty. The resolution was to reuse the photo by hash, with dated provenance: the new result declares the same photo hash as the prior inspection plus the date it was originally captured, and that provenance is sealed into the hash chain. The driver sees the actual prior photo before choosing — fetched and cached during the last sync, so it works with no signal — and the server refuses any “carried from” claim that the chain did not already commit to for that asset. Nothing masquerades as today that was not re-affirmed today.

Evidence you can hand to a third party

The hosting provider’s managed Postgres offered no continuous point-in-time recovery, and the inspection record is the legal tier. So the design treats object storage as the authoritative event log and Postgres as a rebuildable projection:

LayerMechanism
Tamper evidencePer-tenant hash chain written at insert: record_hash = sha256(canonical payload ‖ prev_hash ‖ seq), length-prefixed so field boundaries cannot collide, results sorted so the hash is independent of insertion order
ImmutabilityAppend-only tables enforced by triggers, not by convention
Off-box copyA background worker signs each record hash with an Ed25519 key and writes it to a bucket with Object Lock in compliance mode — a retention not even the account owner can shorten
Health signalAnchor lag, max(seq) − last anchored seq; zero means every inspection has reached the immutable copy, and a growing lag is the backup alarm
CheckpointsA signed digest of the chain head once a day
Independent auditAn evidence-verify tool that reads only the bucket and the published public key — no database needed — so an auditor or a court can check the log without trusting the server

The restore drill is a runbook, and it is run. An untested backup does not count.

Fueling, deliberately without a chain

Forestry drivers fuel at the client’s mobile fuel truck and fill in the client’s paper requisition; the company kept no digital copy of its own. Fueling capture reuses the whole field machinery — offline, client-minted IDs, idempotent push, a mandatory photo of the receipt stub sealed into the record at capture — and records diesel and ARLA litres against the asset’s reading.

It is append-only but not part of the hash chain, and that was a decision rather than an omission. The client holds the authoritative copy of every requisition; this record is reconciliation evidence, not the primary legal record, and chaining it would have been cost without a claim behind it. The ADR names the trigger for revisiting: if the fueling record ever becomes billing evidence, it gets its own chain.

Suspicious fuelings are surfaced, never rejected. Litres that disagree with the pump, clock skew, an unassigned asset — all accepted and badged in the admin list, because a record that is refused in the field is a record that is lost.

Running it in production

ConcernHow it is handled
Regressionsgo vet, go build and go test on every push, against a fresh Postgres
Schema changesGoose migrations, versioned and checked in — past fifty by now
DeploysAn on-demand workflow builds the image and installs it over SSH; nothing ships by accident
Losing the databaseSnapshots plus the signed off-box log above
Personal dataCSV exports that carry driver names are rate-limited and audited
Browser attack surfaceA baseline Content-Security-Policy
Forgetting whyTen ADRs and three runbooks: deploy, disaster recovery, evidence anchoring

Why htmx, and not React

The admin is forms and tables. Every piece of state it cares about lives in Postgres, and the server already knows how to render it. Reaching for React would have meant a build step, a second copy of the domain model in TypeScript, and a synchronisation problem I would have created for myself.

html/template with htmx gives the admin partial updates without any of that. The client-side complexity goes where it is genuinely unavoidable: the offline PWA, which really does need to hold state, queue work and resolve it later.

Where it is now

Close to 300 commits. Since June the whole interface was rebuilt from a design prototype onto a single design-system stylesheet; the home page became a manager’s cockpit with fuel, division and drill-through reports; and the system began reconciling its own figures against the company’s ERP — result per branch, by joining freight documents to the ERP’s records — which is where a fleet system stops being a form and starts being an argument you can win.