Skip to main content

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

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; 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: 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. 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. The numbered table, with the typed error each raises, is on the 18 GL invariants.
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.

Layer 4: the database is the last line

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 and have that guarantee mean something the agent cannot talk its way around. See 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: 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 rather than once at close, and a break routes to the lane that owns it.
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 is what makes the middle number countable.
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.

Why a break is reported rather than refused

The two sets answer different questions at different times. 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 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:
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.

Where it is enforced

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.