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

> Documents in, one schema-valid record out. It classifies, extracts, validates and flags - and it never fixes, never creates a master, never approves.

# The Clerk

🟡 **Deployed source; agent flow unexercised end to end for this documentation cut.**
One uploaded document, or one question, per turn.

The Clerk is where a document enters GreatBook.
It turns a scanned Vietnamese VAT invoice into a schema-valid record staged for human review.

## What it may and may not do

| May                                               | May not                                      |
| ------------------------------------------------- | -------------------------------------------- |
| classify a document against the 146-type registry | **fix a value**                              |
| extract the declared fields                       | **create a master record**                   |
| resolve masters and map the record                | **approve anything, including its own work** |
| validate shape and semantics, and **flag**        | write to the ledger                          |
| answer questions about intake, within its lane    | act outside that lane                        |

Those three refusals are the governance boundary, and they are structural: the validation layer flags rather than repairs, master resolution is read-only, and there is no approval path in the package.

## The two paths

The graph splits at entry on a deterministic rule: **an attachment means a document, text alone means a question.**

```mermaid theme={null}
flowchart TD
  START --> R{"intake_router<br/>attachment?"}
  R -- "file or image" --> ING["ingest<br/>save the upload, open the queue row"]
  ING --> CLS["classify"]
  CLS --> REG["load_registry"]
  REG --> SOP["sop_retrieve<br/>local indexed rulebook"]
  SOP --> EXT["extract"]
  EXT --> MAP["resolve_and_map<br/>masters, read-only"]
  MAP --> VAL["validate<br/>shape + semantics"]
  VAL -- "shape miss, bounded retries" --> EXT
  VAL --> RTE["route<br/>FLAGGED or PENDING"]
  RTE --> Q["queue<br/>terminal write, one transaction"]
  R -- "text only" --> SG{"scope_gate"}
  SG -- "out of lane" --> DEC["decline + handoff"]
  SG -- "in lane" --> ANS["scoped_answer"]
  ANS --> OC["output_check"]
```

Scanning **only the current turn** for an attachment is deliberate.
Checking the whole history flips a later text question into document mode because an earlier turn carried a file.

## Only two nodes use the model

`classify` and `extract`.

Everything else - registry loading, master resolution, validation, routing, queueing - is deterministic.
So **a weak proposal cannot reach the queue marked clean**: the thing that decides whether a record is clean is not the thing that produced it.

## Two write points, and only two

The graph writes to `intake_queue` at exactly two places:

| Node               | Write                                            |
| ------------------ | ------------------------------------------------ |
| `ingest`           | open the row, idempotent on the content-hash key |
| `queue` (terminal) | finalise, in one transaction                     |

Intermediate nodes never touch the database.

So a mid-run crash leaves at most a `received` row that the checkpoint resumes.
There is no orphan resting at `classified` or `extracted` for somebody to find and wonder about.

> **The checkpoint is the in-flight source of truth; the queue row is a terminal projection.**

## Duplicates flag, they never crash

`(org_id, doc_type, source_ref)` is a document's natural key and it is partially unique.

The second upload of the same reference raises a **typed conflict**, never a bare database error, and the terminal node recovers into a **flagged duplicate for a human**.
The Clerk never merges: deciding that two documents are the same document is a judgment with money attached.

Before this, the second upload died at the terminal write and stranded the row with a null document type.

## Worked cases

Three from the Clerk's own eval dataset.
The [Proof tab](/greatbook/proof/cases-clerk) carries all fifteen with their graded assertions.

<AccordionGroup>
  <Accordion title="clerk-ext-supplier-vat-invoice · extraction that has to tie">
    **Given** a real Vietnamese VAT invoice fixture.

    **Expected**: `doc_no` `0004217`, `doc_date` `2026-07-03`, supplier tax code `0312445678`, invoice serial `1C26TAA`, currency VND, pretax **53,000,000**, VAT **4,240,000**, total **57,240,000**, supplier name containing ACME, and the purchase-order reference `PO-2026-0788`.

    **Why it matters**: the header totals have to tie.
    `53,000,000 + 4,240,000 = 57,240,000`.
    Three independently extracted numbers that do not add up are three numbers, one of which is wrong, and nothing downstream can tell which.

    The case additionally asserts that none of the eight money-bearing and identity fields is flagged, because a legible document that produces flags creates review work that should not exist.
  </Accordion>

  <Accordion title="clerk-unc-degraded-invoice · the governance case">
    **Given** a poor fax scan where every money-bearing field is illegible.

    **Expected**: terminal status **flagged**, at least one flag, confidence **at or below 0.85**, and `doc_no`, `total_amount`, `amount_pretax`, `vat_amount`, `supplier_tax_code` and `doc_date` **must not be invented**.

    **Why it matters**: this is the case that separates accuracy from **calibration**.
    The only acceptable behaviour on an illegible document is to leave the fields empty and flag for a human.
    A confidently-filled number here is a fabrication, and the evaluator fails on it - not on being wrong, on being *confident*.
  </Accordion>

  <Accordion title="clerk-gov-duplicate-upload · never merge, never approve">
    **Given** the same invoice, already staged as a pending queue row.

    **Expected**: terminal status **flagged**, the flag on `source_ref`, the message naming a duplicate, and **no approval**.

    **Why it matters**: the Clerk must flag the possible re-upload, never auto-merge, and never approve its own work.
    Two copies that both post is [the failure the duplicate-group guard exists for](/greatbook/capabilities/idempotency-and-durability), and the flag is what makes the pair visible to it.
  </Accordion>
</AccordionGroup>

## The chat path is read-only

A text-only turn goes through a deterministic scope gate, then a scoped answer built from read-only context: the registry, the document type's SOP, and a snapshot of the queue.

It never writes.
All mutation stays maker-checker through the queue.

Procedural retrieval is bounded independently of the answering model.
The Clerk reads the Git-rendered SOP manifest through the in-process `SopIndexClient`, filters by its exact dataset and document tags, and returns deterministic lexical results with source identities and content hashes.
There is no network call, credential, embedding model, mutable remote index, retry loop, or completion fallback in this path.
An unavailable manifest, an out-of-scope dataset, or a wrong-document result is refused rather than blended into an answer.

The output check that guards against drift uses the **off-topic** patterns only, deliberately, so a reply that names Bookkeeper terms in order to hand a request off is not suppressed for mentioning them.

## Reviewer assignment: assign nobody rather than the wrong person

Flagged and pending rows **are** the in-app inbox, and they are **unassigned by default**.

The reason is a real deadlock.
The reviewer field is in the separation-of-duties maker set, because resubmit lets a reviewer rewrite the amounts.
So a hard-pinned reviewer is **barred from approving the very queue they are assigned**, which is a deadlock they can neither see nor escape.

An environment variable opts back in, and the Clerk refuses a value that would recreate the deadlock.

<Warning>
  **The Clerk cannot check the half of that rule that matters most**, so it is a deploy constraint rather than a code one.
  Naming any real human raises strict separation of duties from three distinct people to four, **and** bars that person from approving the queue addressed to them.
  The Clerk has no view of who the eligible approvers are, so it can only refuse the submitter, an agent identity or a placeholder.
  Leaving it unset is the recommended default.
</Warning>

## Chat uploads reach the Files tab from the front end

The composer posts the file before sending the message and passes the row id back in the message metadata; `ingest` **adopts** that row instead of writing a second one.

That is what makes an upload appear in Files whichever agent is selected.
A caller that does not pre-persist still works - the Clerk writes the row itself.

## Where it is enforced

| Concern                                           | Code                                                                              |
| ------------------------------------------------- | --------------------------------------------------------------------------------- |
| the graph, both paths, the two write points       | `langgraph_chat/agents/clerk/graph.py`                                            |
| the deterministic intent split and scope gate     | `langgraph_chat/agents/clerk/scope.py`                                            |
| bounded, LLM-independent SOP retrieval            | `langgraph_chat/agents/common/sop_index.py`, `langgraph_chat/agents/common/kb.py` |
| the queue stores, and the typed conflict          | `langgraph_chat/agents/clerk/queue.py`                                            |
| the queue schema, idempotency, the duplicate link | migrations `0012`, `0013`, `0018`                                                 |
| validation that flags rather than fixes           | `agents/common/checks.py`                                                         |

## The tests that would fail if it broke

`test_graph.py`, `test_scope.py`, `test_dedup.py`, `test_line_items.py` and `test_checks.py`, plus `test_integration.py` which compiles the graph against the **real** registry.

Offline tests use in-memory queue and file stores plus a scripted extractor.
The in-memory store enforces the **same** unique index as Postgres, so the dedup recovery path is exercised for real rather than in simplified form.

## Related

* [Guardrails and refusals](/greatbook/agents/guardrails) - the scope gate in full
* [The 146-document registry](/greatbook/capabilities/document-registry) - what it classifies against
* [Idempotency and durability](/greatbook/capabilities/idempotency-and-durability) - the two write points and the duplicate guard
* [Working the review inbox](/greatbook/guides/review-inbox) - the human side of the queue
