Tamper-evident submetering for industrial buildings: measure each tenant or production line, split the shared consumption, and let anyone recompute the evidence behind a bill.
The billing side and the evidence side are both built. Certificates of origin are the remaining phase.
A meter reading only means something if you know who measured it. So the signature is produced on the device, over a canonical form of the payload, before anything touches the network. The API verifies that signature, links the reading into a per-meter hash chain, and every settlement interval is sealed with a Merkle root. A tenant who disputes an invoice line pastes its hash into the verifier page and the recomputation happens in their browser — a proof checked by the server that issued the invoice would prove nothing.
An inclusion proof alone would not be enough — it says a reading is in some tree, not that the tree is the one you were shown last month. The log is an RFC 6962 append-only tree, so a consistency proof can show that nothing was rewritten between two heads, and each head is co-signed by an independent witness and optionally anchored on chain. BLOCKCHAIN.md explains why the most useful part of that needs no blockchain at all.
The honest limit of all of it: cryptography proves the reading was not altered after signing, not that the meter measured the truth. See THREAT-MODEL.md.
Meters signing on the device, readings chaining into the log, an interval sealed with its Merkle root, and an invoice line recomputed from its evidence.
Prorata.mp4
To reproduce it: make install && make demo && make dev.
Nothing in this chain trusts the step before it. Each stage produces something the next one re-derives rather than accepts.
flowchart TB
M["SDM630MCT · Modbus registers"] --> A
A["Edge agent — canonical payload + Ed25519<br/><i>the private key never leaves the device</i>"]
A -->|"MQTT 5 over mTLS"| SUB["Subscriber · separate process from the API"]
SUB --> ING["IngestReading — verify · replay · chain"]
ING --> CH[("readings — per-meter hash chain<br/><i>MySQL append-only triggers</i>")]
CH --> SEAL["SealInterval — RFC 6962 Merkle root"]
CH --> SET["ComputeSettlement — largest remainder"]
SEAL --> HEAD["PublishHead"]
HEAD --> W["Witness · outside operator control"]
HEAD -.->|optional| ANCH["ProrataAnchor.sol"]
SET --> INV["Invoice<br/><i>every line cites its two boundary hashes</i>"]
INV --> V["Verifier page<br/><b>recomputes in the tenant's browser</b>"]
SEAL --> V
W --> V
classDef ev fill:#12261f,stroke:#3f9d70,color:#e9f6ef
class V,W ev
The verifier is the point of the whole design: a proof checked by the server that issued the invoice would prove nothing.
Cheapest and most decisive first, so a flood of forged frames costs the API almost nothing — it is rejected before any signature maths or database write.
flowchart TD
F["Signed frame arrives"] --> M{"meter known and active?"}
M -->|no| RM["reading.unknown_meter<br/>reject"]
M -->|yes| S{"Ed25519 signature verifies<br/>over the canonical payload?"}
S -->|no| RS["reading.bad_signature<br/>reject"]
S -->|yes| R{"seq > current head seq?"}
R -->|no| RR["reading.replay<br/>reject"]
R -->|yes| T{"ts within 5 min of now?"}
T -->|"in the future"| WT["audit reading.future_timestamp<br/><i>recorded, not rejected</i>"]
T -->|ok| C
WT --> C{"is it a correction?"}
C -->|yes| CV{"supersedes this meter's own<br/>not-yet-superseded reading?"}
CV -->|no| RC["reject"]
CV -->|yes| L
C -->|no| L["chain_link(prev_hash, payload_hash)<br/>append"]
Note the asymmetry: a future timestamp is audited, not refused. Refusing it would let a meter with a drifting clock silently lose energy, which is a worse failure than a logged anomaly.
backend/ FastAPI + SQLAlchemy + MySQL, hexagonal
witness/ independent co-signer — runs outside the operator's control
contracts/ ProrataAnchor.sol, the on-chain head registry
domain/ entities, RFC 6962 log tree, tariff calendar, allocation
application/ ingest_reading, seal_interval, compute_settlement,
publish_head, verify_chain
infrastructure/ MySQL repositories, Ed25519, MQTT subscriber
interfaces/http/ routers, JWT/RBAC, composition root
scripts/ enrolment, sealing worker, demo seed
edge/ SDM630MCT Modbus simulator + signing agent
frontend/ Angular 22 console — 16 screens, runs with npm install
ops/ Mosquitto mTLS config, dev PKI script
Dependencies point inwards only. Nothing in domain/ or application/ imports
FastAPI, SQLAlchemy, aiomqtt or pymysql — the adapters depend on the ports, never
the reverse:
flowchart TB
EDGES["<b>Adapters</b> — replaceable without touching a rule<br/>interfaces/http · 18 routers, JWT/RBAC, rate limit<br/>infrastructure/db · MySQL repositories<br/>infrastructure/mqtt · subscriber<br/>infrastructure/crypto · Ed25519, secrets<br/>infrastructure/anchor · witness, EVM"]
APP["<b>application</b> — 10 use cases<br/>ingest_reading · seal_interval · compute_settlement<br/>issue_invoice · publish_head · evaluate_alerts<br/>verify_chain · verify_audit_chain · shred_tenant · user_admin"]
DOM["<b>domain</b> — pure Python, no framework<br/>log_tree RFC 6962 · hashing canonical form<br/>allocation largest remainder · tariff 3.0TD P1–P6<br/>invoice · corrections · alerts · shredding · audit<br/><i>ports — the contract the adapters implement</i>"]
EDGES -->|"depend on"| APP -->|"depend on"| DOM
classDef pure fill:#12261f,stroke:#3f9d70,color:#e9f6ef
class DOM pure
That purity is what lets 134 of the 261 cases — log tree, canonical hashing, allocation and the protocol vectors — run with no database and no I/O at all. The log tree alone contributes 119 of them: every leaf of every tree size up to 64 is proved, and consistency is checked against every earlier size.
make install # backend venv, edge venv, npm install
make demo # PKI, containers, schema, demo data, device identities
make dev # API, ingest, sealing, meter simulator, both agents
make web # the console, in a second terminalmake demo is idempotent — run it twice, it will tell you what was already
done. It provisions and enrols the meters too, so nothing has to be pasted
between terminals. make dev starts every process in one place, writes each to
logs/<name>.log, and Ctrl-C stops all of them.
Ten defects that only appeared on a real first run are catalogued in FIXES.md, with what each one taught.
Only MySQL and Mosquitto run in Docker. The API, the workers and the edge agent run from their virtualenvs, so a code change does not mean an image rebuild.
The individual targets still exist — make run, make ingest, make seal,
make sim, make agent — plus two that make dev deliberately leaves out
because they belong to a second party or a schedule:
make witness # the independent co-signer, on :8100
make publish # publishes the log head and gets it witnessedmake check-chains re-walks every meter's chain and the audit log and exits
non-zero if any link is broken — wire it to cron, because a hash chain nobody verifies is a comment
rather than a control.
Then the demo fixtures and the meters:
make seed # 3 tenants, 3 users on SP-001
cd edge
python provision_keys.py --device-id SDM630-MAIN # prints a public key
python provision_keys.py --device-id SDM630-A1
cd ../backend
python scripts/enroll.py --device-id SDM630-MAIN --supply-point SP-001 \
--role main --public-key <base64 from above>
python scripts/enroll.py --device-id SDM630-A1 --supply-point SP-001 \
--role tenant --tenant-id T-01 --public-key <base64>Enrolment is a separate, deliberate operator action: there is no endpoint a device can use to introduce itself. That is the whole reason "signed by this device" means anything. The private key never leaves the device — if you copy it from a laptop, the evidence chain is theatre.
In three more terminals:
make sim # SDM630MCT simulator on :5020
make agent # reads Modbus, signs, publishes over mTLS
make seal # closes each interval and computes its Merkle rootThe sealing worker deliberately runs one interval behind the clock, plus a two minute grace window. Sealing an interval that can still receive a late frame would make that frame permanently unprovable.
make web-check # Angular template type-check (strictTemplates)
make web-test # 4 Karma specs, headless, once — finds Chromium under any name
make test # 261 tests: log tree, protocol vectors, allocation, ingest, seal,
# settlement, corrections, chain integrity, audit chain,
# head publication, and the HTTP endpoints themselves
make lint # ruff + mypy on domain and application
make audit # bandit + pip-audittest_end_to_end.py exercises the real SQLAlchemy adapters on SQLite, so it
runs in CI without a container. Worth naming the trade-off: that means the
MySQL append-only triggers from migration 0001 are not covered there. They
are dialect-specific DDL and need the compose stack.
backend/tests/test_protocol_vectors.py and
frontend/src/app/core/verifier.spec.ts assert the same constants. The
canonical form and Merkle construction are a cross-language protocol; those
vectors are what keeps the Python and TypeScript sides from drifting apart.
- Energy from register differences, never from summing frames. A lost frame must not become lost energy. Cumulative registers are read; a backwards register raises instead of clamping.
- The common-area split uses largest remainder. Rounding must not create or destroy kWh, or the difference lands silently on whoever is billed last.
- MySQL enforces append-only. Triggers reject
UPDATE/DELETEonreadingsandsealed_intervals, so application-level bypass is not enough. seqis persisted on the device. A power cycle cannot rewind it, so replays stay detectable even on a compromised transport.- Erasure without destroying evidence. Readings are encrypted per tenant; destroying the key erases the data and leaves every root, inclusion proof and consistency proof verifying. A random per-reading nonce is what stops the hashes being brute-forced afterwards.
- Organisations, scoped at a single chokepoint — with the honest caveat that this is application-level isolation, not database-enforced.
- Prices are versioned, not constant. A March invoice uses March's prices; a version that has been billed is evidence and is superseded, never edited.
- Four passive controls became alerts. Silent meters, reconciliation gaps, broken chains and unwitnessed heads now raise, deduplicate while open, and resolve themselves when the condition clears. A control nobody looks at is not a control.
- Sessions are revocable. A JWT alone stays valid until it expires, so deactivating an account left its holder working for another half hour.
- Four roles, and an admin panel that cannot lock the customer out. Account creation, deactivation, deletion, password reset and MFA reset — with the API refusing any of them that would remove the last administrator.
- TOTP with the parts that usually get skipped: encrypted seeds, replay protection per time step, hashed single-use recovery codes, and a challenge token that is useless anywhere except the second step.
- Every invoice line cites the two readings it is the difference of. A verifier a tenant can only reach by writing SQL against the operator's own database is not a verifier. The invoice carries both boundary hashes per line, and the log tree size it was billed from, so the evidence stays checkable months later.
- Nothing is ever edited — bad readings are superseded. A correction is a new signed frame pointing at the reading it replaces, carrying the original's timestamp so the energy stays in its own tariff period. The usual cause is a CT ratio configured wrong at commissioning.
- The audit log is chained too. Anything worth trusting later gets a hash chain; there is nothing special about readings.
- Ingestion is a separate process from the API. They fail differently and a restarted subscriber must not take the web tier with it.
- pymodbus is pinned to 3.6. 3.14 deprecated the datastore API the simulator needs.
- The interface is English throughout, including code comments and demo fixtures. The tariff calendar still models the Spanish 3.0TD access periods (P1–P6) because that is the regulation the figures have to match, not a language choice.
MIT — see LICENSE.