
Our Ledger Said It Had Been Tampered With. It Hadn't.
Our tamper-evident general ledger started reporting broken entries. Nobody had touched them: the same exchange rate was hashed one way on the way in and another on the way out. A control that raises a false alarm is not partially working — it is harmful.
- Anton MaciusField CTO
In this article
We write a lot about tamper-evident records. Then we built one for ourselves, and it accused us of something we had not done.
The system is a double-entry general ledger, part of a platform we are building that puts CRM, ERP and commerce on one data model. Every posted journal entry is reduced to a canonical form, hashed with SHA-256, signed with an Ed25519 key, and linked to the entry before it. An integrity sweep walks the chain and re-verifies every link, every hash and every signature. If an entry was altered, removed or reordered, the chain breaks at that point and reports where.
It started reporting broken entries. Nobody had touched them.
The bug
Every entry includes an exchange rate. At the moment of posting, that rate exists in memory as the string "1". When the verification sweep reads the same entry back from Postgres, the database returns it as a decimal, which renders as "1.000000000000".
Same value. Different bytes. Different hash.
Everything about the cryptography was correct. The keys were right, the chain logic was right, the signatures were valid against what was signed. The defect was that two parts of our own system disagreed about how to write the number one.
The fix is a small function whose only job is to force every rate into a canonical twelve-decimal string before it goes anywhere near the hash, and a test asserting that the formatted form of "1" equals the formatted form of "1.000000000000". That test exists because we shipped the bug.
Why a false alarm is worse than no alarm
It would be easy to file this as an ordinary defect. It is not.
An integrity system that raises a false alarm does not fail neutrally. It trains everyone who sees it to discount the alarm. The second time it fires, someone assumes it is the formatting thing again. The third time, nobody checks carefully. And the whole value of the mechanism rests on the alarm being believed the one time it is real.
So the acceptance criterion is stricter than "detects tampering." It is: never reports tampering that did not occur. A control that cries wolf is not partially working. It is harmful, because it manufactures the complacency it exists to prevent.
Where these bugs live
The failure was not inside the hashing, the signing, or the database. It was at the seam between write time and read time — which is where this class of bug always lives. Any time a value is written by one component and read by another, the two are separate systems that happen to share a name for the thing. Serialisation, type coercion, precision, string encoding: none interesting individually, all of them places where a value survives a round trip while its representation does not.
If you are building anything that hashes, signs or compares stored data, the early question is not whether the crypto is right. It is: does every field produce identical bytes on the way in and on the way out? We asked it late.
The part about AI
Almost all of this system was written with AI assistance, and it is fair to ask where that stood on this one.
Nowhere, and I do not think it reflects badly on it. The bug is not visible in any single file. Both halves are individually correct, well tested, and do exactly what they say. Finding it meant holding the whole path in one head and asking what changes between writing and reading — a question about a system, not about code.
That is roughly where the line sits on everything we build this way. Breadth moves fast. Invariants need someone who understands why the invariant exists, because getting one wrong does not produce a bug report. It produces every number downstream being quietly untrustworthy.
What accountability actually looked like here
Not the hash chain. Not the signatures. Those are table stakes and they were working.
It was what happened when our own evidence system told us something was wrong: we assumed it was right, went looking, and found we were the problem. That instinct is the control. The cryptography just makes it possible to act on.
Ask the round-trip question early. We build tamper-evident records for systems where the alarm has to be believed — canonical forms, signed chains, and the tests that prove the control never fires on a value nobody touched. Book a design review.