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

> One approved document to one balanced journal entry, behind a human posting gate, idempotently. The only agent on the write path.

# 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

```
ingest -> match -> propose -> [ HUMAN GATE: interrupt_before=post ] -> post -> record
```

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](/greatbook/capabilities/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

| `entry_kind`       | Source                                                 | Trusted record                        |
| ------------------ | ------------------------------------------------------ | ------------------------------------- |
| `intake` (default) | an ACTIVE `intake_queue` row                           | the row, **re-read at post time**     |
| `adjustment`       | an [Accountant](/greatbook/agents/accountant) proposal | the payload, **signed at stage time** |

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.

<AccordionGroup>
  <Accordion title="Account mapping is a hard error. There is no suspense fallback">
    `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.
  </Accordion>

  <Accordion title="The only GL write is one function, and it is graph code">
    `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.
  </Accordion>

  <Accordion title="Releasing the gate is not an approval">
    `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.
  </Accordion>

  <Accordion title="The checkpoint decides nothing">
    Every security-relevant field - the organisation, the entry kind, the record id, the checker, the verdict - comes out of the [server-signed mandate](/greatbook/capabilities/posting-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.
  </Accordion>

  <Accordion title="Source-specific separation of duties">
    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.
  </Accordion>
</AccordionGroup>

## The post-back is one transaction

Through the single writer, so it inherits the [18 GL invariants](/greatbook/capabilities/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](/greatbook/capabilities/subledgers/settlement);
* **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.

<Info>
  **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](/greatbook/gl/rules-payables); the register on [VN VAT and statutory tax](/greatbook/capabilities/subledgers/vat-statutory).
</Info>

<Info>
  **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](/greatbook/capabilities/fx).
</Info>

## The account map

Resolved from the bill's kind first, and from the document type only when the source did not tag one.

| Kind                                       | Account | VAS                        |
| ------------------------------------------ | ------- | -------------------------- |
| `material`, `material-VAT`, `raw-material` | `1200`  | 152 raw materials          |
| `CMT-154`, `subcontract`                   | `1210`  | 154 work in progress       |
| `consumable`, `consumable-627`             | `5120`  | 627 manufacturing overhead |
| `service`, `professional`                  | `6410`  | 6425 professional fees     |
| `rent`                                     | `6200`  | 6427                       |
| `utilities`                                | `6420`  | 6237                       |
| `freight`, `freight-641`, `logistics`      | `6110`  | 6417 / 6412                |
| `asset`, `fixed-asset`                     | `1500`  | 211 fixed assets           |
| `canteen`                                  | `6400`  | 6428 office and admin      |
| `telecom`, `software`                      | `6430`  | 6426                       |

Document-type fallbacks cover every `creates_ap` type in the registry, so the common path never needs a kind.
Still no wildcard default.

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

## Worked cases

Three from the Bookkeeper's eval dataset.
The [Proof tab](/greatbook/proof/cases-bookkeeper) carries all ten.

<AccordionGroup>
  <Accordion title="bk-post-one-balanced-entry · the full loop">
    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.
  </Accordion>

  <Accordion title="bk-unmapped-account-hard-error · the anti-suspense guardrail">
    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.
  </Accordion>

  <Accordion title="bk-maker-cannot-be-checker · refused before the transaction opens">
    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.
  </Accordion>
</AccordionGroup>

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

| Concern                          | Code                                        |
| -------------------------------- | ------------------------------------------- |
| the graph and its refusals       | `langgraph_chat/agents/bookkeeper/graph.py` |
| the account map and the proposal | `posting_rules.py`                          |
| the mandate                      | `post_auth.py`                              |
| the only GL write                | `ledger_bridge.py`                          |
| launch and resume over the wire  | `runner.py`                                 |

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

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

## Related

* [The posting mandate](/greatbook/capabilities/posting-mandate) - the trust boundary, in full
* [Two gates, three people](/greatbook/capabilities/maker-checker) - the separation-of-duties rule
* [The general ledger](/greatbook/capabilities/general-ledger) - the writer it posts through
* [The Bookkeeper drafts, then Gate 2](/greatbook/guides/posting-gate) - the same flow, from a reviewer's chair
