The 18 GL invariants
A GL invariant is a precondition. It runs before the write and it refuses. An unbalanced entry, a two-sided line, an entry in a closed period or a line missing a required dimension never reaches the database at all. There are eighteen of them, they live in one function, and they are numbered in the source so a refusal can be traced to a rule.The table
Validated insideLedgerService.post_entry().
On any failure the writer raises a typed error and writes nothing.
The three that make the rest trustworthy
Invariants 12, 14 and 16 are what make the chain evidence rather than decoration. Invariant 12 hashes a canonical serialisation. Current checksum version 2 sorts over the complete canonical line facts and stringifies everyDecimal.
So neither line order nor 10 versus 10.0000 can change a checksum, and two representations of the same entry hash identically.
Without that, a checksum would be a hash of an arbitrary rendering, and a verifier could not reproduce it.
The stored checksum version preserves the interpretation of historical entries; a legacy version-1 non-base entry that lacks the old raw rendering is reported as linkage-only rather than reinterpreted under version 2.
The
head term is easy to drop when restating the formula, and dropping it is not cosmetic.
It binds the checksum to the entry’s own identity, so two entries with byte-identical lines posted from different sources hash differently.
The Auditor’s hash_chain family recomputes with the writer’s own helper rather than a restatement of it, which is why the recompute cannot drift from the formula even when prose about the formula does.event_stream is an org-scoped, sequenced, hash-chained append log with its own prev_hash.
An attacker who somehow rewrote an entry and its checksum would still have to rewrite the event chain from that point forward.
Invariant 16 is a transaction advisory lock, not a table lock.
It is taken before the idempotency read and released when the caller’s transaction ends, so read-tail and append-tail are one critical section.
Without it, two postings racing for the same chain head produce two entries claiming the same predecessor, which is a chain fork that only shows up at audit.
Where it is enforced
Invariants 2, 3 and 15 exist twice, once in the writer and once as a database CHECK constraint or trigger.
That redundancy is deliberate: the writer’s version gives a human a readable refusal, and the database’s version holds even against a direct SQL session that never went through the writer.
The test that would fail if it broke
backend/tests/test_ledger_engine.py drives each refusal path against a real migrated Postgres.
backend/tests/test_gl_security.py covers the database-enforced half, including that a posted row rejects an UPDATE.
The Auditor also re-checks a subset of these over already-posted entries, which the code calls the invariant check family and this documentation calls the GL invariant sweep: trial balance nets to zero, every posted entry still balances in base currency, and no posted entry sits in a closed or undefined period.
Where the code uses
invariant as a literal identifier - the Auditor’s family key, and expect_checks_ran: ["hash_chain", "invariant", "orphan"] in the eval datasets - the identifier is kept verbatim in code font.
Renaming a code identifier inside a code block to match prose would be a different kind of lie.What goes wrong without it
Each invariant is a specific book that went wrong somewhere.- Without 4, you get a ledger that is “balanced except for rounding”, which is the sentence a book of record may never contain.
- Without 9, a closed month reopens itself silently, and last quarter’s numbers change after they were filed.
- Without 10, a retried agent run or a replayed webhook can book the same invoice twice, while key-only replay can return an unrelated old entry for changed facts.
- Without 15, the audit trail is a suggestion.
Related
- What guards the engine - these eighteen in the context of the other four layers
- The general ledger - the writer these rules live inside
- Immutability and the hash chain - invariants 12, 14 and 15, in full
- The Close checksums - the other set of checks, and why the distinction matters
- The Auditor - the GL invariant sweep over posted entries