Skip to main content

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.
These are not the Close checksums. The Close list is a reconciliation surface that runs after the fact and reports. The canonical phrasing when both appear together is “18 GL invariants + 104 Close checksums”, and the Close list is never described as invariants.

The table

Validated inside LedgerService.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 every Decimal. 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.
Invariant 14 writes a second chain. 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.