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

# What guards the engine

> Five layers around the single writer: source-specific release controls above it, the 18 GL invariants inside it, the database beneath it, the two hash chains behind it, and the 104 Close checksums after it.

# What guards the engine

A posting rule decides *what* an entry should be.
This page is everything that decides whether an entry is **allowed to exist**.

The first two layers depend on the source.
Document-originated postings use the mandate and three-person intake sequence; approved workpapers require a named non-agent human who did not propose them; native subledger, reversal and system-origin events use their applicable canonical domain controls.
Every path then converges on the same single ledger writer and the shared defenses below it.

```mermaid theme={null}
flowchart TD
  DOC["document proposal"] --> M["1· posting mandate<br/>signed · server-minted"]
  M --> S["2· document control<br/>three people"]
  ADJ["approved workpaper"] --> A["named human<br/>proposer ≠ checker"]
  OTHER["native · reversal<br/>system event"] --> K["canonical<br/>domain control"]
  S --> W["3· the 18 GL invariants<br/>inside LedgerService.post_entry"]
  A --> W
  K --> W
  W --> D["4· the database<br/>CHECK constraints · immutability triggers · RLS"]
  D --> C["the entry, and both hash chains"]
  C --> CL["5· the 104 Close checksums<br/>at close, reporting"]
```

<Warning>
  **The terminology is binding.**
  The **18 GL invariants** are enforced in the ledger writer, before the write, and they *refuse*.
  The **104 Close checksums** run over the whole book, and they *report*.
  The canonical phrasing when both appear together is **"18 GL invariants + 104 Close checksums"**, and the Close list is never described as invariants.
</Warning>

## Layer 1: an agent-staged proposal needs a mandate

A **mandate** is a short signed statement, minted only by the gated server-side approval path for a document or adjustment proposal, that says: *org O has authorised this checker to decide on this record, which is of this kind.*

The posting node reads its security-relevant fields **out of the mandate** and out of the database row the mandate names - never off the graph checkpoint, which a caller can write to.
Verification is literally the first statement the node executes, so there is no code above the trust boundary to read anything at all.

The design took five rounds of adversarial review to become true, and each round's defect was the previous fix one field to the left.
The full history is on [the posting mandate](/greatbook/capabilities/posting-mandate); the rule it distilled into is worth stating here:

> A field the graph writes as an **outcome** must never be a field it reads as an **input**.

`WS_POST_SIGNING_KEY` unset does not disable the check.
It falls back to a per-process random key, so any cross-process resume - including the real approval endpoint - fails loudly with an explicit error.
A missing secret breaks posting at deploy time instead of silently degrading to no control at all.

## Layer 2: source-specific human control

For a document-originated posting, three distinct people stand behind the two intake gates:

| Role                | Who                                                       |
| ------------------- | --------------------------------------------------------- |
| **submitter**       | the person who filed the document into the queue          |
| **intake approver** | the person who let it out of review and into ACTIVE       |
| **posting checker** | the person who approved the journal entry into the ledger |

Not two, and not "two plus a constant".
Each must be a real authenticated identity, and no two may be the same person.

The rule is decided in one pure module and enforced in **three** places, because each covers what the others cannot: at the intake gate, so a document cannot enter a state where it could never be posted; at the API boundary, so a reviewer gets a clean conflict rather than a failed run; and in the graph's posting node, against the record it re-read itself, so the rule holds even if the graph is driven another way.

Identities are compared on a canonical key - casefolded, trimmed, reduced to an email's local part - and that choice deliberately leans towards *merging* two spellings.
A refused posting is a phone call; an admitted self-approval is a fraudulent entry in an immutable ledger.

A **refusal** is subject only to the half of the rule that matters for it: whoever refuses must be a named human.
Applying the full rule to rejections once stranded documents that could be neither posted nor rejected nor resubmitted.
Detail on [maker-checker](/greatbook/capabilities/maker-checker).

An Accountant adjustment has no submitter or intake approver.
Its stable adjustment-keyed approve/reject path requires a named non-agent human, refuses the proposing agent as checker, and binds the decision to the exact workpaper source.
Native subledger, reversal and system-origin events remain under their canonical domain controls rather than inheriting intake identities.

## Layer 3: the 18 GL invariants

Enforced inside `post_entry`, before any write, on every posting.
On any failure the writer raises a typed error and writes nothing.

| Group                                 | Invariants     | What they refuse                                                                                                                    |
| ------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| the entry is a double-entry           | 1, 2, 3, 4     | fewer than two lines, a two-sided line, a negative amount or a `float`, an entry that does not balance in the **base** currency     |
| the accounts and amounts are real     | 5, 6, 7        | a typo'd, retired or other-org account; a foreign line with no way to value it; a book value that would have to be recomputed later |
| the entry is attributable and in time | 8, 9           | a line with no activity and flow; a posting into a closed month, or into no month at all                                            |
| the entry is unique and ordered       | 10, 11, 16     | the second posting of the same source; a gap or collision in the sequence; two postings extending the chain from one head           |
| the entry is evidence                 | 12, 13, 14, 15 | an entry that cannot be tied to its predecessor; a half-written draft; a posting with no second trail; any edit                     |
| failure is total                      | 17, 18         | a partial write, and an untyped failure a caller cannot handle                                                                      |

The numbered table, with the typed error each raises, is on [the 18 GL invariants](/greatbook/capabilities/gl-invariants).

<Note>
  Invariants 2, 3 and 15 exist **twice** - once in the writer and once as a database CHECK constraint or trigger.
  The writer's version gives a human a readable refusal; the database's version holds against a direct SQL session that never went through the writer.
</Note>

## Layer 4: the database is the last line

| Guard                                                           | What it stops                                                                               |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| CHECK constraints on the line columns                           | a two-sided or negative line inserted by anything, including hand-written SQL               |
| immutability triggers on `journal_entries`                      | an UPDATE to a posted entry, from any session                                               |
| a unique index on `(org_id, source_type, source_id, entry_set)` | a concurrent double-post - exactly one durable row survives, and the loser is a typed no-op |
| row-level security, org-scoped                                  | a cross-org read or write; a cross-org caller sees zero rows                                |
| the per-org advisory lock                                       | a forked hash chain under concurrency                                                       |

RLS is not a filter added in application code.
It is a property of the database, which is what lets the Auditor be given a [session the database itself holds read-only](/greatbook/agents/auditor#read-only-enforced-by-the-database) and have that guarantee mean something the agent cannot talk its way around.
See [tenancy and RLS](/greatbook/capabilities/tenancy-and-rls).

## Layer 5: the 104 Close checksums

Everything above refuses a bad **entry**.
None of it can detect a bad **book** - a correct entry posted twice from two different sources, a control account whose sub-ledger cannot explain it, a rail whose bank statement disagrees.

That is what the Close list is for.
It reproduces the v12 workbook's reconciliation dashboard as live queries over the GL and all seven sub-ledgers, in two kinds of row:

| Kind                    | Rule                                          |
| ----------------------- | --------------------------------------------- |
| **must-be-zero**        | `ROUND(value) == 0`; anything else is a break |
| **explainable balance** | reported and named, never forced to zero      |

`close_checks.py` declares **104 checksums**: 82 must-be-zero and 22 explainable.
The number that actually **executes** is data-dependent, because per-rail, per-item and per-facility checksums expand with the book.

Each of the 104 carries a generated **phase** tag and **layer** tag, so 78 of them are asked [every night](/greatbook/agents/assurance#the-continuous-close-sweep) rather than once at close, and a break routes to the lane that owns it.

<Info>
  **Two related numbers, and it is worth saying which you mean.**
  The v12 workbook carries roughly 91 rows; the code declares 104 checksums; a live run executes however many those 104 expand to.
  The [generated inventory](/greatbook/capabilities/close-list#the-size-of-the-surface) is what makes the middle number countable.
</Info>

A Close break is **a finding for a human, not a rejected transaction**.
Calling these invariants would imply the system refuses a posting that would break a tie-out - which it does not, and should not.
Full treatment on [the Close checksums](/greatbook/capabilities/close-list).

## Why a break is reported rather than refused

The two sets answer different questions at different times.

|            | GL invariant            | Close checksum                  |
| ---------- | ----------------------- | ------------------------------- |
| when       | before the write        | at close, over the whole book   |
| scope      | one entry               | the GL and seven sub-ledgers    |
| on failure | refuse; write nothing   | report a named break            |
| who acts   | the caller, immediately | a human, with the whole picture |

A control-account tie needs *every* posting and *every* sub-ledger row to be present to mean anything.
Enforcing it per-posting would refuse the first of two entries that are only correct together.

## The independent re-check

The [Auditor](/greatbook/agents/auditor) re-verifies a subset of all of this over **already-posted** data, from a read-only session, using the writer's own hashing code rather than a reimplementation:

| Family                     | What it recomputes                                                                                                                                                                              |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| hash chain                 | every entry's checksum and the event stream's chain, from scratch                                                                                                                               |
| the **GL invariant sweep** | the trial balance nets to zero for the org and each period, every posted entry still balances in base currency, nothing sits in a closed period, every posted document maps 1:1 to a real entry |
| orphan scan                | documents stuck mid-pipeline past a threshold                                                                                                                                                   |

<Note>
  Where the code uses `invariant` as a literal identifier - the Auditor's family key, and the eval datasets' `expect_checks_ran` - the identifier is kept verbatim in code font and the prose calls it the **GL invariant sweep**.
  Renaming a code identifier inside a code block to match prose would be a different kind of lie.
</Note>

## Where it is enforced

| Layer                          | Code                                                                                                                        |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| the proposal mandate           | `langgraph_chat/agents/bookkeeper/post_auth.py`                                                                             |
| source-specific human controls | `langgraph_chat/agents/common/sod.py`, `devcenter/close_service.py`, enforced in the document and adjustment approval paths |
| the 18 invariants              | `backend/app/services/ledger_service.py`, `post_entry()`                                                                    |
| the database half              | migration `0002_gl_guards_fx_rls`, plus `0029`-`0031` for the approval and maker-checker constraints                        |
| the Close list                 | `backend/app/logics/finance/close_checks.py`, `run_close_list`                                                              |
| the independent sweep          | `langgraph_chat/agents/auditor/`                                                                                            |

## The test that would fail if it broke

`backend/tests/test_ledger_engine.py` and `test_gl_security.py` for layers 3 and 4.
`agents/bookkeeper/tests/test_trust_boundary.py` for layer 1 - it drives the executed attacks from each review round.
`backend/tests/test_close_checks.py` for layer 5, against seeded breaks.
`backend/tools/live_acceptance.py` re-runs the Close list, the RLS cross-org check and the FX as-of-date assertion against the **live** database, because a suite on a throwaway database says nothing about production.

## Related

* [How a journal entry is formed](/greatbook/gl/index) - the path these layers wrap
* [The 18 GL invariants](/greatbook/capabilities/gl-invariants) - the numbered table
* [The Close checksums](/greatbook/capabilities/close-list) - the reconciliation surface
* [Immutability and the hash chain](/greatbook/capabilities/immutability-and-hash-chain) - invariants 12, 14 and 15 in full
* [The security boundary](/greatbook/proof/security) - what has been attacked, and what held
