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