How Document Substrate works

You give it documents. It reads them, hides everyone’s personal details, and turns the pile into answers and finished workpapers — checking its own work at every step. Here is the whole trip.

The whole trip in one picture

Four stations left to right: your file, reading with names hidden, a searchable library, then answers and reports.

1

Your file goes into a locker

The moment you upload a file, it lands in a private locker. There is no public link to it — the only key is made fresh when needed and stops working after 60 seconds.

A file moves into a locked private locker; a note explains the only key expires after 60 seconds.
Under the hood

Uploads go to a private Supabase Storage bucket (source-documents). A documents row is created with status: uploaded, and an event kicks off the processing line. The original file is only ever reachable through short-lived signed URLs minted server-side.

2

A reading machine takes over

Six stations, in order. Station 2 — hiding personal details — comes before anything else gets to look. If a station jams, the belt stops and tells you which one, and a person can restart from that exact spot.

A six-station conveyor: read, hide names, guess the type, slice and file, sum up, ready. A note says a jammed station can be restarted without starting over.
Under the hood

One durable Inngest function runs parse → redact → classify → chunk + embed → summarise, moving documents.status through each stage to ready. Parsing is LlamaParse (large documents are sharded); chunks are page-aware so citations can point at real pages; failures set failed_<step> and the retry endpoint re-enters at any step. Classification only suggests a type — a person confirms it.

3

The privacy trick: code names and a vault

This is the most important picture on the page. Real names, addresses and account numbers are swapped for code names and locked in a vault. The AI only ever works with the code names — it cannot leak what it never had. The real values are swapped back only on your screen, only for you, and every swap-back is written in a logbook.

On the way in, John Smith becomes the code name PERSON_1 and the real name goes into an encrypted vault. The AI sees only code names. On the way out, the code name is swapped back for the signed-in user, and a logbook records it.
Under the hood

Redaction is a hybrid: checksummed regex rules (IRD, bank accounts, cards…) plus an LLM pass on every page that fails closed. Placeholders are per-document ([A1B2C3D4_PERSON_1]); real values are AES-256-GCM encrypted in document_entities. Hydration requires a document scope, decrypts only those documents’ entities, and always writes an audit_log row.

4

A form-filler copies out the facts

Once a document is ready and its type is confirmed, an AI fills in a tidy form — supplier, total, dates. This runs on its own conveyor belt: if form-filling fails, the document itself is untouched and still searchable.

An invoice is copied into a tidy form with supplier, total, due date and currency fields, on its own conveyor belt with its own status chips: queued, extracting, extracted.
Under the hood

Extraction is deliberately not a pipeline step — it has its own Inngest function, its own extract_status lane, and never touches documents.status. Schemas live in lib/substrate/extracts/registry.ts (Zod + instruction + engine); extraction runs over the redacted text, and the few schemas allowed to see the raw PDF have their output re-redacted against the vault before it is saved.

5

Workflows: slots, rules, and a finished file

A workflow is a shelf of named slots to fill — bank statement, invoices, rent summary… Plain rules then check the lot: do the numbers add up, is a month missing, do two documents disagree? No AI decides — same facts in, same verdict out, every time. You get a report on screen and an Excel or Word workpaper, stamped DRAFT until every slot is happy.

A shelf of document slots with one marked missing, a rules engine that checks sums, coverage and disagreements with no AI, and two outputs: an on-screen report and an Excel or Word file stamped draft until complete.
Under the hood

A workflow job pins a code-level WorkflowDef to one collection and period; each run is an immutable snapshot. Documents fill slots by confirmed type; the engine in lib/workflows/engine.ts runs presence, period, coverage and consistency rules (reconcile, continuity, unique…) over the extracted fields — there is not a single model call in lib/workflows. Outputs are deterministic .xlsx / .docx renderers.

6

The practice manual: the business’s own know-how

Rules and forms can’t hold everything a business knows. The practice manual is that knowledge written down as a book: when to chase a missing document, when a small difference is fine to accept, what a senior checks before signing. Ask “how do we…?” in chat and the assistant quotes this book — not generic textbook advice — and every check in a workflow report shows what the manual says right beside it. The book even grows from experience: when reviewers keep accepting the same finding, a new page is drafted — and a person decides whether it joins the book. But the manual only ever explains; the plain rules from the last picture stay in charge.

A book in the centre. Filled two ways: people write the know-how down, and repeatedly accepted findings become drafted pages a person approves. Used two ways: chat quotes the book for how-do-we questions, and every check in a workflow report shows what the manual says. A note says the book explains but never decides.
Under the hood

The manual is versioned markdown in manual/ — one chapter set per engagement type (rental property, sole trader, portfolio), with front-matter binding each chapter to a workflow and the slots and rules it explains. A build-time contract test (lib/workflows/practice-manual.test.ts) keeps the factual half honest: every referenced rule must exist, and every workflow’s rule set must be fully covered. It is applied read-only in exactly three places: the practice_manual chat tool, the chat system prompt (which prefers it over generic advice for “how do we” questions), and the “What the manual says” disclosure under each finding in the workflow report — fetched live via manualForRule, never snapshotted. The growth loop is pnpm propose: clusters of accepted findings become PII-scrubbed draft paragraphs in .proposals/, and a human merges. Division of labour: the manifest is the machine truth (slots, formulas, tolerances), the manual is the practice truth (why, and what to do about it), and it never feeds the rules engine.

7

The safety gates

Three sets of gates, at three different moments: every question you ask, every value copied out of a document, and every change to Document Substrate itself. Nothing reaches you — and nothing ships — without passing its gates.

Three bands of gates. On every question: sensible limits, a last privacy sweep, and answer inspection. On every extracted value: it must point to its page, and the numbers must add up or a needs-review flag is raised. On every change to Document Substrate: practice exams on golden examples, automatic build and test inspectors, and one narrow doorway that keeps text out of outside logs unless proven name-free.
Under the hood

Every question: input limits, rate / budget checks and scope rules up front; scrubPiiInText sweeps the assembled prompt; the output guard (lib/chat/output-guard.ts) blocks PII / secret / prompt leaks, repairs stray placeholders, and the figure guard flags numbers that appear in no retrieved excerpt.

Every extracted value: grounding re-locates each field in the page text and stores page + span (the Verify tab); deterministic checks (extracts/checks.ts) test arithmetic and continuity and raise needs_review.

Every change: eval gates over golden sets (pnpm eval:gates — schema validity, field accuracy, no-fabrication, groundedness); CI runs typecheck, lint, tests and build, plus an init.sql drift check; and every model call goes through lib/ai/telemetry.ts, which keeps prompt / completion payloads out of third-party telemetry unless the call is redacted by construction (recordsRedactedPayload) — enforced by lint.