Skip to main content

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

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. 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: 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 carries all fifteen with their graded assertions.
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.
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.
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, and the flag is what makes the pair visible to it.

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

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

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.