> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vinmake.com/llms.txt
> Use this file to discover all available pages before exploring further.

# The 18 GL invariants

> The preconditions the single writer enforces before any write, what each one rejects, and the typed error it raises.

# 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.

<Warning>
  These are not the [Close checksums](/greatbook/capabilities/close-list).
  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.
</Warning>

## The table

Validated inside `LedgerService.post_entry()`.
On any failure the writer raises a typed error and writes nothing.

| #  | Invariant                                                                                                                                                        | What it rejects                                                                      | Typed error                             |
| -- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------- |
| 1  | The entry has at least two lines.                                                                                                                                | a one-line "entry", which cannot be double-entry                                     | `MinimumLinesError`                     |
| 2  | Each line is one-sided: exactly one of debit and credit is non-zero.                                                                                             | a line carrying both, which makes the balance ambiguous                              | `OneSidedLineError`                     |
| 3  | All amounts are non-negative `Decimal`s.                                                                                                                         | a negative amount used to fake the other side, and any `float`                       | `NegativeAmountError`, `TypeError`      |
| 4  | `sum(debit_base) == sum(credit_base)` within the shared balance tolerance.                                                                                       | the entry that does not balance in the book's own currency                           | `UnbalancedError`                       |
| 5  | Every account exists, is active, and is owned by the posting org.                                                                                                | a typo'd code, a retired account, another org's account                              | `AccountInvalidError`                   |
| 6  | The line currency resolves, and a rate is present for any non-base currency.                                                                                     | a foreign-currency line with no way to value it                                      | `CurrencyError`, `RateUnavailableError` |
| 7  | Base-currency amounts are computed and stored on every line.                                                                                                     | a line whose book value has to be recomputed later from a rate that may have changed | -                                       |
| 8  | The required Tier-1 dimensions are present.                                                                                                                      | a line that cannot be attributed to an activity and a flow                           | `DimensionRequiredError`                |
| 9  | The entry date falls inside an **open** fiscal period.                                                                                                           | a posting into a closed month, or into no month at all                               | `PeriodClosedError`                     |
| 10 | Exact replay: an existing `(org, source_type, source_id, entry_set)` returns that entry only when the immutable request facts match.                             | the second posting of the same source, or reuse of its key with different facts      | typed replay conflict for changed facts |
| 11 | The entry number is allocated monotonically per org.                                                                                                             | a gap or a collision in the entry sequence                                           | -                                       |
| 12 | `checksum = sha256(prev_checksum + head + canonical(lines))`, chained per org. `head` is the entry's own identity: org, date, source type, source id, entry set. | an entry that cannot be tied to its predecessor                                      | -                                       |
| 13 | Status is set to `posted` atomically.                                                                                                                            | a half-written entry visible as a draft                                              | -                                       |
| 14 | An `event_stream` record is appended, itself hash-chained.                                                                                                       | a posting with no independent second trail                                           | -                                       |
| 15 | No previously posted entry is mutated. Database triggers enforce this.                                                                                           | an edit, including one made from a direct SQL session                                | trigger error                           |
| 16 | A per-org advisory lock serialises chain extension.                                                                                                              | two concurrent postings extending the chain from the same head                       | -                                       |
| 17 | The caller owns commit and rollback. The writer does neither.                                                                                                    | a sub-ledger row that commits without its entry, or the reverse                      | -                                       |
| 18 | On any invariant failure, raise a typed error and write nothing.                                                                                                 | a partial write, and an untyped failure a caller cannot handle                       | `LedgerError` subclasses                |

## 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.

<Note>
  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.
</Note>

**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

| Concern                                                                | Code                                                     |
| ---------------------------------------------------------------------- | -------------------------------------------------------- |
| all 18, numbered in the docstring and in the code                      | `backend/app/services/ledger_service.py`, `post_entry()` |
| the typed error hierarchy                                              | `backend/app/core/errors.py`                             |
| the database-level half: CHECK constraints, immutability triggers, RLS | migration `0002_gl_guards_fx_rls`                        |
| the Decimal and rounding rules invariants 3, 4 and 7 rest on           | [`money.py`](/greatbook/capabilities/money)              |
| the rate resolution invariant 6 rests on                               | [`fx.py`](/greatbook/capabilities/fx)                    |

<Note>
  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.
</Note>

## 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](/greatbook/agents/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.

<Note>
  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.
</Note>

## 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](/greatbook/gl/guards) - these eighteen in the context of the other four layers
* [The general ledger](/greatbook/capabilities/general-ledger) - the writer these rules live inside
* [Immutability and the hash chain](/greatbook/capabilities/immutability-and-hash-chain) - invariants 12, 14 and 15, in full
* [The Close checksums](/greatbook/capabilities/close-list) - the other set of checks, and why the distinction matters
* [The Auditor](/greatbook/agents/auditor) - the GL invariant sweep over posted entries
