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.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 insidepost_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.
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.
Related
- How a journal entry is formed - the path these layers wrap
- The 18 GL invariants - the numbered table
- The Close checksums - the reconciliation surface
- Immutability and the hash chain - invariants 12, 14 and 15 in full
- The security boundary - what has been attacked, and what held