Skip to main content

The Auditor

🟡 Deployed source; agent flow unexercised end to end for this documentation cut. Strictly read-only, and the reason it was safe to build before any further write agent.

The shape

No posting gate. No interrupt. No write channel at all. The contrast with its siblings is the design: the Bookkeeper compiles with an interrupt and a single boxed write channel; the Auditor compiles with neither. Every node here reads or recomputes.

Read-only, enforced by the database

Postgres refuses the write. That is the claim, and it took four mechanisms to make it true through the deployed transport, because the obvious two were both measured to fail.
What does not work, and both were tried.psycopg.connect(dsn, read_only=True) on an autocommit connection is inert. psycopg applies that attribute when it begins a transaction, and an autocommit connection never begins one. Measured on psycopg 3.3.4: the session reports transaction_read_only = off and executes an INSERT happily.Forcing default_transaction_read_only=on as a startup parameter is correct, and on the deployed DSN it is honoured by nothing. Supabase’s pooler consumes the options startup field for its own tenant routing and does not forward it to Postgres. There is no error: the session simply comes up writable, and a live probe through this channel once committed a CREATE TABLE.
So the channel does four things, in this order, and refuses rather than degrades at every one of them.
1

Refuse the transaction pooler, from the DSN

Port 6543 is Supabase’s transaction-mode pooler. This channel proves itself read-only with a session-scoped SET, which on a transaction pooler would persist onto a shared backend the pooler later hands to a writer, and whose read-back could be answered by a different backend than the one it was set on. Reading the book through that pooler would be fine; proving the channel read-only through it is impossible, so the DSN is refused before anything connects.
2

Force the startup parameter anyway

-c default_transaction_read_only=on, appended to any options the DSN already carries rather than replacing it. It is the only form with no writable window at all, and it is the real enforcement on a direct DSN and in CI. It is kept precisely because it is stronger where it survives.
3

Ask libpq which endpoint it actually reached

The DSN can leave the port unstated and let PGPORT or a service= entry fill it in, which passes the string check and then lands the SET on a shared backend. conn.info.port is libpq’s own answer after all that resolution. A port that reads as 6543, or that cannot be read at all, is refused and the connection is discarded unused.
4

SET it a second way, then make the database confirm it

SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY is measured to survive the pooler that discards the startup parameter. Then show transaction_read_only is read back, and a session that does not answer on is refused. That last clause is the whole design: an assertion defends the property against any transport, including the next pooler that swallows something else.
A caller may not suppress the refusal. An Auditor that cannot open a provably read-only channel has nothing to report from, so a check reports inconclusive rather than reading the book through a session that could also write to it. The four probes below were run inside the deployed container, against the real database, through the real pooler:
The three DML probes are deliberately zero-row, so a writable channel would have changed nothing. The DDL probe is the exact vector that committed before the fix.
This is why the Auditor has its own read layer rather than using the shared pool. The shared pool commits on a clean exit, which is correct for the writers it serves and wrong here. And the Auditor never imports a writer: it reuses the ledger writer’s pure hash helpers so its recompute cannot drift, but never its post path.The Accountant reads through this same channel, deliberately. The read-only guarantee is enforced in exactly one place, so there is one thing to prove rather than two things to keep in step.
The read-only claim is also graded, not asserted. Every ledger eval case fingerprints the organisation’s entry, line and intake counts and the chain tail before and after the run, and requires them identical. The read-only guarantee is proven, not trusted.

Browser questions and job mode are separate

The browser may submit only conversational messages to the Auditor. It cannot select mode="job", a privileged check family, job-only scope or an arbitrary job thread; those fields are absent from the browser allowlist and an attempted override is rejected. Scheduled and event-triggered jobs are created server-side. The job runner sets job mode, selects one recognized family and its bounded scope, owns retries, and persists the durable run and findings. This keeps a browser question read-only without letting it impersonate a scheduled sweep. If the job’s required scope cannot be established, the result is inconclusive rather than clean. The monthly_close_gated job is the important period-scoped example. It runs only the close_gated family with a bounded last_closed scope, revisits closed periods that have never been verified or still carry unresolved findings, and advances coverage only after a verified run. Finding identity includes the period, so a later clean month cannot resolve an earlier month’s exception.

The seven check families

The Auditor started with four families and now declares seven. Two lists matter here and conflating them would make every “audit everything” answer permanently incomplete: close_gated is declared, runnable and schedulable. A job that names it resolves an explicit finished period; an invalid, open or unevaluable period is answered inconclusive by name rather than dropped or crashed. It is kept out of the routine full sweep on purpose: a permanent “could not verify” line under every sweep is not honesty, it is noise that teaches a reviewer to skim past the lines that matter.
Build-time tests pin all seven declared families to runnable implementations and pin the routine full sweep separately, so the explicit-period boundary cannot drift silently.
Relinks and freshly recomputes the per-organisation entry checksum chain, and the event-stream hash chain, flagging any linkage break, gap, or a stored hash that does not match a fresh recompute.It reuses the writer’s own _entry_checksum and _canonical_lines, so if the writer’s hashing changes, the Auditor recomputes with the new rule because it is literally the same code. A verifier with its own copy drifts, and then reports either false tampering or false safety.One historical limit. New checksum-version-2 entries, including foreign-currency ones, preserve complete canonical facts for recomputation. A legacy version-1 non-base entry may lack the old raw rendering, so it falls back to legacy linkage-only verification - still chain-guarded, and explicitly counted rather than reported as fully recomputed.
Re-checks over already-posted entries:
  • the trial balance nets to zero, organisation-wide and per period;
  • every posted entry balances in base currency;
  • no posted entry sits in a closed or undefined period;
  • every posted intake row reconciles one to one to a real GL entry.
This is the same set of properties the 18 GL invariants enforce at write time, re-asked of the book as it stands. The code keys this family invariant; in prose it is the GL invariant sweep.It is not the Close checksums - those are the Accountant’s, and they report tie-outs across the seven sub-ledgers.
Intake rows resting in a non-terminal pipeline status past an age threshold.received, classified and extracted are statuses a row passes through within one Clerk run. Resting there means the run died mid-pipeline.flagged and pending (awaiting review), active (awaiting posting), and rejected and posted (terminal) are not orphans. The eval case for this seeds a stale orphan alongside a legitimately in-flight row, so it grades false positives too.
Cross-system reconciliation over the reconciliation knowledge graph: a document that moves a balance and claims to be posted with no link to any entry, a version group with more than one record that can still enter the book, an obligation whose key names two rows, and the AP/AR control account against its own sub-ledger.Freshness is a gate, not a footnote. The graph is projected by the DevCenter every three hours, and a reconciliation over a graph older than six hours is reported inconclusive with no other rule having run. Reporting four clean rules over a three-day-old graph is worse than reporting nothing, because it reads as an all-clear.The family proposes and reports; it never merges, and like everything else here it writes nothing: the graph is written by a projector the DevCenter owns, and a build-time test fails if any Auditor module so much as imports it.
The subset of the Close checksums whose expected value is true now, not only after a period-end procedure, run nightly rather than once a month.A tie between two representations of one fact written in the same transaction, a referential-integrity or catalog check, an arithmetic identity inside a row, or a bound. It reuses the Accountant’s Close oracle rather than carrying a second copy of it, and it deliberately skips the two checks another family already owns, naming the owner as it does: AP-CONTROL and AR-CONTROL belong to recon. Two families reporting one fact would be two register rows for one exception.Its fail-safe is the period question. The Close oracle skips the period-scoped payroll, depreciation-coverage and Wave E kết chuyển controls when it is given no period, which is 26 of the 104 checksums, so a nightly run with no period would cover most of the list and report a clean pass. Instead the open fiscal periods are resolved at run time, the payroll subset runs once per open period, and with no period open that subset is inconclusive with the reason stated rather than silently absent.The family is labelled “Close checksums (continuous)” and never “invariants”.
Runnable only with an explicit finished-period subject: scheduled monthly or requested by a question naming one of its controls.A drain-to-zero pool whose non-zero state is a normal in-flight condition, a tie against evidence that arrives with a lag, or a completeness assertion about a period. Twenty-six of the 104, and asking them mid-period would file findings nobody can clear.It is a separate family rather than a parameter on close_continuous for a structural reason: the register resolves findings by family, never by job or parameter, so one family running under two phases would clear every close-gated finding the monthly job filed, every night.
The row-level, early face of the Close list, and the newest family.GRNI asks “is the goods-received-not-invoiced pool zero?” once, organisation-wide, at close. This family asks “is this goods receipt, 47 days old, still uninvoiced?” tonight, and names the document. Same money, two grains, with the Close code as the join, so the Accountant’s close and the Auditor’s nightly sweep agree by construction.Six rules, four of them aged on a configurable severity ladder. The exception system is the page for all of it.Like recon it inherits the graph freshness gate: a stale or unprojected graph makes the whole family inconclusive and no rule runs. A completeness sweep over a graph that stopped projecting last week would report “no missing documents” about documents it never saw.
The recon and completeness families’ routing triggers are narrower than the others, deliberately. A full sweep and a generic assurance question still run them, but a narrow question reaches recon only on a genuinely cross-system term (duplicates, versions, the document of record, the graph itself) and completeness only on a genuinely completeness term (missing, unallocated, unsettled, overdue, aging, unsigned, three-way). Bare “evidence” and bare “document” are excluded from the second list on purpose: both are ordinary single-family audit vocabulary. The reason is the inconclusive verdict: a family routed in unnecessarily puts a “did not run” line in front of a reviewer who asked whether the trial balance matched.

Answering the whole question

A reviewer’s question routes to the families it names. “Any chain breaks?” runs hash_chain only. A generic assurance question, or an empty message, runs the full sweep.
A multi-part request answers every family it names. Found by a live adversarial review of the usage guide, and fixed: “Run a full check of the books: hash chain, accounting invariants and orphan scan” previously ran two of three families and named none of them in the reply.Three independent defects sat behind that one symptom, and all three are fixed:
  1. Routing. A family trigger matched a singular stem, which a plural cannot match, so the enumerated family silently vanished. Family stems now match plurals, the families are a union that never short-circuits, and an explicit “full / complete / whole check” wins outright. That last choice is deliberate: for a read-only agent, running one family too many costs a database read, while running one too few answers half the question. It keys on a generic assurance noun, never a family noun, so a narrow question still routes narrowly.
  2. Reporting. A per-family readout now narrates one line per family the reviewer requested - PASS, an exception count, INCONCLUSIVE, or NOT RUN - on both the clean and the exception branch. Driving it from what was requested rather than from what came back is what makes a family that never ran read as NOT RUN instead of vanishing from a confident answer.
  3. The thread. The message channel had no append reducer, so the terminal node’s write replaced the channel and wiped the reviewer’s own question, leaving an untitled thread with only an answer and no history for follow-ups.
The reviewer-facing family labels are centralised in one place, so the report headline, the chat answer and the generated SOP cannot drift apart. That is also where the binding terminology lands: the family is labelled “GL invariants”, never “accounting invariants”.

Inconclusive is never a pass

A read failure produces inconclusive findings, never a false all-clear. That posture runs through the whole agent. An assurance process that reports success when it could not check is worse than one that does not run, because it manufactures confidence. Findings carry two severities at once: The dual shape means the same finding can be scored in the audit report and surfaced in the existing review inbox without the Auditor writing anything. It returns the flags for a caller to render; it never inserts them. That is what keeps “no new write path” true.

Worked cases

Six seeded faults, each injected into a real migrated GL, each asserted to be flagged. Plus three that grade behaviour rather than detection:
  • aud-known-good-passes - a clean book passes, which also proves the fresh recompute reproduces every stored checksum;
  • aud-declines-write-request - a request to fix the books short-circuits at the gate;
  • aud-unreadable-ledger-is-inconclusive - the fail-safe. An unreachable database must never render as a clean book.
The Proof tab carries all ten with their graded assertions.

Where it is enforced

The tests that would fail if it broke

The offline scope and report suites, plus live acceptance against a real migrated GL where each of the six faults is injected and asserted flagged.