Skip to main content

The Bookkeeper

🟡 Deployed source; agent flow unexercised end to end for this documentation cut. The only agent that can reach the ledger, and the reason the rest of this documentation spends so much time on gates.

The shape

A launch runs ingest, match and propose, then pauses with a proposal. The proposal surfaces as a review card in the same inbox the Clerk’s queue feeds. On human approval the gated API mints a posting mandate, sets the verdict on the checkpoint and resumes the same thread. LangGraph restores the checkpoint and runs post and record. The proposal is never re-run on resume. The human approved a specific entry; re-deriving it at resume time would mean approving one thing and posting another.

Two sources, one loop

An adjustment carries its own pre-built lines, so ingest does no database read and propose gate-checks them - balanced, one-sided, no suspense account - instead of re-deriving.

The guardrails

Every one is enforced before any GL write.
posting_rules raises UnmappedAccountError for a record it cannot code.The legacy poster this replaced did BILL_KIND_ACCOUNT.get(kind, "6900"), quietly booking anything it could not code into the suspense account. This one does not.The suspense accounts are reserved for an explicit, human-chosen coding decision. They are never an agent default, because a default suspense account is how a book accumulates a pile of “we will look at that later” that nobody ever does.Resolution order: the bill’s kind first, then the document type as a fallback. Neither has a catch-all. An unrecognised kind and an unrecognised document type together raise.
ledger_bridge.post_and_mark, gated behind the interrupt, never an SDK tool.The Bookkeeper’s tool surface mirrors the Clerk’s boxed one. The model can propose; it cannot call anything with an effect.Everything else the Bookkeeper touches is read-only.
interrupt_before carries no interrupt value, so “resumed” and “a human approved this” are different facts, and only the post node can tell them apart.It requires an explicit verdict and a named human checker. A blank checker raises MissingCheckerError.A refusal raises before the transaction opens, so the gate is not consumed and a real checker can still post.
Every security-relevant field - the organisation, the entry kind, the record id, the checker, the verdict - comes out of the server-signed mandate, not out of state.trusted_record re-reads the record those ids name and rebuilds the entry from that. The checkpoint’s lines are only ever compared against it, so a substitution is refused visibly rather than silently corrected.A checkpoint that contradicts the mandate raises rather than being quietly overruled, so a tampered thread is visible, not merely harmless.And a field the graph writes as an outcome is never a field it reads as an input.
For an intake entry, three distinct authenticated people stand behind the document: whoever filed it, whoever approved it into the queue, whoever approves the posting.The checker may additionally be none of the other identities that touched the record - the drafting agent, or a reviewer who resubmitted and therefore may have edited the amounts.An adjustment has no intake identities. Its checker must be a named non-agent human and may not be the proposing agent.post raises MakerIsCheckerError before the transaction opens, and created_by on the entry is that named human, never an agent.

The post-back is one transaction

Through the single writer, so it inherits the 18 GL invariants, the advisory lock, the hash chain, the event stream and the idempotency key. In that same transaction it also:
  • opens the AP or AR obligation the entry’s control leg stands for, through settlement.record_obligation, the single obligation writer;
  • records the VAT register line the entry’s input-VAT leg stands for, through the backend’s own register writer;
  • flips the intake row to posted and stamps the result entry id;
  • writes the audit row.
Input VAT. A supplier VAT invoice’s recoverable VAT belongs on account 1300 (VAS 133), not inside the expense or inventory leg, so a payable posts three legs: Dr <coded> net / Dr 1300 VAT / Cr 2000 gross. The gross is the anchor for the AP credit and its obligation, the net is derived as gross - VAT, the VAT is read from a stated amount or from gross - pretax and never inferred from a rate, a triple that does not add up raises a typed InconsistentVatError and posts nothing, and the register line travels with the leg in the same transaction so GL 1300 stays tied. This is the merged posting path: recoverable input VAT is no longer buried inside the expense or inventory leg. Full rule on Payables and input VAT; the register on VN VAT and statutory tax.
Every posted line states its currency, and none states a rate: the rate is the engine’s to resolve inside the posting transaction, as of the entry date. A document whose currency field is not a currency raises at propose time rather than being read as the base currency. See FX as of the transaction date.

The account map

Resolved from the bill’s kind first, and from the document type only when the source did not tag one. Document-type fallbacks cover every creates_ap type in the registry, so the common path never needs a kind. Still no wildcard default.
The knowledge store may be consulted for a record the static table does not cover, but its answer is only ever used to pick among real account codes. It can never invent one and can never suppress the hard error. The static table is the floor; the knowledge store raises the ceiling. When it is unreachable, which is its normal degraded mode, the static table alone decides and an uncovered record still raises.

Worked cases

Three from the Bookkeeper’s eval dataset. The Proof tab carries all ten.
An approved intake row, through proposal, through the human posting gate, to exactly one balanced immutable entry - with the checker recorded as created_by.This is the case that proves the whole money path end to end, against a real migrated GL rather than a mock.
A record the rules cannot map must raise.The evaluator grades that the error is actually raised, not merely that nothing posted. That distinction matters: a silent no-op would satisfy the weaker check while leaving a document that quietly never posts.
The human who approved the intake row may not also approve its posting.Refused before the transaction opens, so the gate is not consumed and a different, eligible checker can still post it.
Two more worth naming: bk-idempotent-relaunch proves a double-approve is a no-op that short-circuits at ingest, and bk-rejected-posting-writes-nothing proves a rejection leaves the ledger untouched and the row still ACTIVE for re-work.

A posting thread belongs to the Bookkeeper alone

Running another graph on a Bookkeeper thread overwrites its channels, and after that a resume stops meaning “resume”: the run re-enters the graph and pauses at the gate again, forever, so an approved document becomes unpostable. Two independent blocks now exist. The UI’s passthrough guard refuses thread-creation-on-resume, refuses direct channel commands, and treats a missing thread as a denial - an unknown id is a claim, not a no-op. And the launch path refuses to adopt a thread whose recorded graph is not the Bookkeeper, so the failure lands at the launch with a cause instead of at the reviewer’s approval.

Where it is enforced

The in-process helpers are not the product. The deployed API runs in a different container from the graph, so everything it does goes over the wire through the LangGraph SDK. When you need to know whether the money path works, run the shape test_resume_posting_live.py encodes - a real server against a real migrated Postgres - not the in-process helper.

The tests that would fail if it broke

test_trust_boundary.py, test_line_substitution.py, test_post_authorization.py, test_resume_posting_live.py, test_posting_rules.py, test_hidden_field.py, test_ap_control_tie.py, and the live acceptance suite against a real migrated GL.