Stages #
Classification: Internal-Only
What each stage does, what it may assume, what it costs, and how it behaves when something goes wrong. The flow and the cost split are in Overview.
Types, signatures and schemas live in the implementation and in the Linear issues that track it. This page describes behaviour, which changes far less often than either.
How a stage works #
A stage is one async step over inputs it is handed. Three rules hold for all of them:
- Everything a stage reads arrives as an argument. No global config, no ambient lookup, no reading the extraction back out of the database.
- Every outbound call goes through an injected client, so tests and the accuracy harness can substitute a recorded one.
- No stage writes anything. Not to bonsapi, not to the queue, not to the vector store. The single write happens after validation.
This is not style. The accuracy harness calls stages one at a time with recorded inputs, and a stage with hidden IO cannot be called that way — so the constraint is what makes per-stage measurement possible at all, and per-stage accuracy, cost and latency are how v2 is judged.
The entity’s accounting context — contacts, accounts, tax rates, tags, items, locations, priority fields, calculation settings — is fetched once before the first stage and is immutable for the run. v1 fetches pieces of it from several places mid-pipeline; gathering it up front is what lets a stage be called in isolation.
Ordering #
prep → Extend → contact matching → ┬─ header fields ─┐
└─ line items ────┴→ accounting → aggregation → validation
Contact matching precedes every field stage, because all three take contact knowledge. Header and lines are siblings. Accounting waits for both. There is exactly one join point in the graph, and it is accounting’s input.
Header fields, line items and accounting each run an extra focused call per configured priority field, inside the stage — see the priority-field pass.
Talking to bonsapi #
v2 reaches bonsapi over gRPC, not the internal REST scope that
bonsai-invoice and hinoki use, following the precedent tofu-external-mcp
set. bonsapi already runs an internal gRPC server whose handlers delegate to
the same service layer the REST controllers call.
Three things do not exist yet and every stage that touches bonsapi depends on them: a service definition covering the extraction surface, service-to-service auth (a worker has no signed-in user to impersonate), and a Rust client.
The rule that makes this safe. Write-path parity is the biggest risk in the whole design, and a second transport is exactly how a second write path gets introduced by accident. So the gRPC handlers delegate to the same bonsapi service layer the internal REST controllers call. They do not reimplement a write, do not skip a validation the REST path applies, and do not add one it does not. gRPC is a transport swap. If a handler needs behaviour the REST path lacks, that behaviour belongs in the service layer where both paths get it.
Why gRPC rather than the existing REST client: one typed contract that both sides generate from, so a field added on one side cannot silently go missing on the other; the same pattern the other consumer already uses; and trace context propagates across the boundary without new work, so a v2 extraction’s bonsapi-side audit logs join its trace.
Cost of it, stated: none of it exists yet, all of it is on the critical path for every stage, and it touches bonsapi — an area the extraction work otherwise leaves alone. The alternative is the existing REST scope, which works today and needs nothing built.
Which model, and why #
One model, on Bedrock, for every text stage. A stage does not get to pick a different one; if one turns out to need a stronger model, that is a deliberate addition with a written reason, not a local choice.
Prep is the exception and it is a real dependency: prep needs image input, and the LLM client is text-only. Multimodal support has to land there before prep can be built at all.
Two cost properties worth stating once, because every stage budget rests on them:
- A budget is mostly an input-token budget. Output costs roughly 3× input, but the volumes are lopsided the other way. Input is rarely the binding constraint; the number of calls is.
- Reasoning is billed as output. A reasoning trace lands in the output count and is priced accordingly, so raising reasoning effort is the fastest way to leave a stage’s budget. The model’s own default is off, and a stage that raises it carries the cost in its own budget.
prep #
Decides what reaches Extend at all: which pages belong to which document, which are primary versus supporting, their orientation, and how complex the document is.
| Input | The extraction’s page images |
| Output | Page groups, per-page type and rotation, a complexity signal |
| Model | The vision model — needs image input, which the LLM client does not have yet |
| Budget | 0.35¢ per page — the only v2 stage that sends images |
| Replaces | Multi-invoice detection, snippet detection, page classification and rotation |
Page references are true page identities, not positions in a list. Treating them as indices is wrong the moment a non-primary page has been filtered out and left a gap — a bug v1 carries a comment about, and one v2 should make unrepresentable instead.
Split is not this stage’s decision to execute. When prep finds more than one document, the extraction is split by the same bonsapi path v1 uses; the children are fresh extractions and inherit the arm. Prep reports; it does not act.
| Failure | Behaviour |
|---|---|
| Model call fails, retryable | Retry with delay, up to the job’s retry limit |
| Model call fails, exhausted | Fall back to one group over all pages with unknown complexity, and record it. Do not fail the extraction |
| A page image is unreadable | Drop that page from the group, record it, continue |
| Every page unreadable | Dead-letter the job; the extraction goes to error in the extraction phase |
Degrading to “one document, unknown complexity” rather than failing is v1’s behaviour and is preserved: a user would rather have an extraction with a bad split note than no extraction.
Extend #
The text layer. One parse call per document.
| Input | The primary pages of one group, in order |
| Output | Markdown text, tables, per-block geometry, word-level boxes |
| Model | Not an LLM — Extend’s cheap parse tier |
| Budget | 1.50¢ per page, half the total |
| Replaces | The OCR bbox service and the PaddleOCR / VLM bbox sources |
Parse configuration, and why each choice is made that way:
| Choice | Reason |
|---|---|
| The cheap engine tier | The expensive tier is reserved for validation escalation |
| Spatial output, not plain markdown | Geometry is the point; markdown alone loses the table structure lines depend on |
| Chunked per page | A chunk maps to a page, so a field’s page is known without inference |
| Tables as structured output | Lines are built from table cells, not from prose |
| Word-level boxes returned | What header bbox lookup needs |
| Only the group’s primary pages | Supporting pages are not parsed, and not paid for |
Cost comes back from Extend in credits, not currency. The credits-to-cents constant lives in config, and the conversion is checked against an invoice from Extend rather than trusted.
| Failure | Behaviour |
|---|---|
| 5xx or timeout flagged retryable | Retry by file reference, never by re-uploading — a re-upload costs two requests instead of one |
| Extend reports the parse failed | Dead-letter it. A document Extend cannot parse will not parse on the third attempt either |
| Empty output — no text, no words | Treat as failure, not as an empty document. Silently extracting nothing from a parse that returned nothing is the worst available outcome |
| An unrecognised status | Retry once, then dead-letter |
Replay. The harness caches parse output by file hash, so component runs never re-pay Extend. That cache is keyed on the file and the parse configuration — a configuration change must miss the cache, or every later measurement is taken against a stale text layer.
contact matching #
Majority vote over five signals, each resolved by an equality lookup against signals that past verifications recorded. No vector search, no weighted scoring. A model reads the signal values off the text; the matching itself is a database lookup.
| Input | The text layer, the entity’s contact list, recorded signals |
| Output | A matched contact with its confidence, plus the per-signal votes |
| Model | One call to extract signal values. A second only on fallback |
| Budget | 0.25¢ |
| Replaces | Vendor matching by name similarity |
| Baseline | 88.9%, target +5% |
The five signals #
| Signal | What it is | Strength |
|---|---|---|
| Tax identification number | The TIN on the document | Identifies a legal entity by construction |
| Bank number | Bank account or IBAN on the invoice | Nearly as strong; a company rarely shares one |
| Domain | Registrable domain from an email or website — invoices@gotofu.com → gotofu.com |
Usually one company, but subsidiaries share a parent domain |
| Name alias | The contact name as written on the document, which routinely differs from the name in the accounting software | Weak on its own — it is the thing being disambiguated |
| Address | Billing address on the document | Weakest: serviced offices and registered agents are shared |
How a match happens #
- One model call over the text returns the signal values present on this document. Absent signals come back empty; a document with no TIN is normal, not a failure.
- Each signal present runs one equality lookup, scoped to the entity and the extraction type. Scoping by extraction type stops a supplier’s signals voting in a customer match.
- Each signal casts at most one vote per contact, and only when it resolves to exactly one contact. A signal that matches several contacts is ambiguous and votes for nobody — a shared office address must not pull a match toward whichever tenant happens to be listed first.
- The contact with the most votes wins.
- No votes at all → fall back to the v1 approach: give the model the name alias and the entity’s candidate contact list and let it pick one.
A document can offer several name candidates — a header name, a footer name, a stamp — so the name alias is matched against all of them. Several candidates matching still counts as one vote, not one per candidate.
The lookups run as a single round trip that reports which signal voted, which is what makes per-signal hit rates measurable without instrumenting the database.
Hard invariant: never return a contact belonging to another entity. The entity is part of every lookup, and nothing else is trusted to enforce it.
Why matches improve over time #
We do not know whether a match was right until the customer verifies. So signals are recorded in two steps:
extraction → signals stored on the extraction record
(nothing recorded against a contact — we do not
yet know which contact is correct)
customer verifies
→ the signals are attached to the verified contact
next invoice → the vote has something to match against
The write happens in bonsapi, in the same transaction that records the verification, so a failed verify never leaves signals behind.
Three consequences, all worth stating plainly:
- The first invoice from a supplier always falls back to the model. The vote has nothing to vote with. Accuracy for a genuinely new contact is the fallback’s accuracy.
- Accuracy climbs over time, which makes the A/B readout time-dependent: measuring v2’s contact accuracy in its first week understates where it lands. See Measurement.
- The loop learns hardest from our mistakes. When the customer corrects the contact, the signals from the document attach to the corrected contact — which is exactly the behaviour we want, and why the write happens on verify rather than on extraction.
What is recorded per contact #
One record per contact per extraction type, holding one value per signal. Writes merge per signal: a value present on the new document replaces the stored one, and a signal absent from it leaves the stored one alone. Latest wins per signal, never absence wins — otherwise a supplier’s second invoice, which happens not to show a TIN, would erase the TIN the first one taught us.
One value per signal is right for four of the five. The name alias is deliberately lossy: a supplier genuinely has several spellings, and one slot keeps whichever was verified last. That cost is paid on the read side instead, by matching every candidate the document offers. A separate store for one field is not worth the join and the second thing to keep consistent.
Treat the name alias as the weakest signal, and never let it win alone. It is the only one that changes without anything being wrong, it is what the fallback matcher already compares on, and it is what the stage exists to disambiguate.
Seeding from the accounting system #
Most of the cold start is avoidable. The contacts already synced from the accounting system carry a tax identification number, a name and an address:
| Signal | Seedable |
|---|---|
| Tax identification number | Yes |
| Name alias | Yes, but transient — the first verify overwrites it with the document’s spelling. Useful until then, no worse than empty after |
| Address | In principle, but see below |
| Domain | No |
| Bank number | No |
Seed only where nothing is recorded yet. Learned signals come from documents a customer actually verified; a re-sync must never overwrite them with the provider’s copy.
The seeded address will rarely match an invoice address. The provider holds a structured record — line, city, postcode, country — while the invoice address is one string read off the page. Even with a canonical rendering, a provider record and an invoice header are two different renderings of the same place. Seeded TIN and name carry the seeding benefit; seeded address mostly will not.
Normalisation #
Equality matching is exactly as good as its normalisation, and the same rules apply on the write path and the read path, over the stored value.
| Signal | Normalisation |
|---|---|
| Tax identification number | Strip spaces, hyphens and dots; uppercase (EU VAT numbers carry letters) |
| Bank number | Strip spaces and hyphens; uppercase for IBAN |
| Domain | Lowercase, strip www., take the host from an email address, then reduce to the registrable domain using a public-suffix list |
| Name alias | Case, whitespace and width folding, plus a decision on legal-suffix handling — 株式会社 / (株) / K.K. / Co., Ltd. — applied to every candidate |
| Address | Whitespace and width folding; keep it light |
The public-suffix trap. Naive “last two labels” on foo.co.jp yields
co.jp, which matches every Japanese company at once. Given the customer base,
this is not hypothetical.
Address needs less normalisation than it looks. The match is
invoice-to-invoice, not invoice-to-accounting-record: the same supplier’s
template renders the same address the same way every month, and it is read the
same way, so the strings agree without heroics. Heavy normalisation of Japanese
addresses — 丁目/番地 versus hyphens, building names, postcode placement — is
not required and risks folding two genuinely different addresses together. The
exception is a seeded address, which comes from a different source.
The raw values are kept alongside the normalised ones. Debugging a bad match without the raw text means re-running the extraction.
Unsettled rules #
Rules the design fixes in shape but not in value. Each is set from measurement rather than chosen up front:
- Is one vote enough? A lone TIN match is convincing; a lone address match is not. A flat majority treats them the same. Strong signals — TIN, bank number — should be able to win alone; weak ones need corroboration.
- Tie-break. On an equal count, the suggested precedence is TIN, bank number, domain, name alias, address, which is the strength ordering.
- Stale signals after a correction. A customer who verifies contact A and later re-verifies the same document as contact B leaves the old signals on A, where they keep voting. Either clear that value from other contacts in the entity, or let the ambiguity rule neutralise it.
- Contact deletion and merges leave signals pointing at an id the provider will reject, and need a cleanup path on contact sync.
Recorded signals hold customer PII — tax identification numbers, bank account numbers and addresses. Same handling as the rest of the extraction data: no values in reports, none in logs, and a retention answer before it ships.
Failure modes #
| Failure | Behaviour |
|---|---|
| Signal extraction fails | Retry once, then go to the fallback with whatever name text is available |
| The signal store is unreachable | Skip the vote, use the fallback, record the degradation. Do not fail the extraction |
| No signals on the document | Skip the vote entirely and use the fallback |
| Every signal ambiguous | No votes; fall back |
| Fallback call fails | No contact matched |
| Nothing matched | No contact matched. Not an error — a genuinely new supplier produces exactly this |
| Entity has no contacts | Skip both the vote and the model call — zero cost |
Every field stage must work without contact knowledge. A first invoice from a new supplier is the common case, not an edge case.
header fields #
| Input | The text layer and contact knowledge |
| Output | The header field values, with a per-field confidence and the block each came from |
| Model | Reasoning effort follows prep’s complexity signal |
| Budget | 0.35¢ |
| Baselines | invoice date 93.8%, total amount 92.6%, invoice number 91.2%, due date 91.0%, tax id 84.0% |
Fields: title, description, language, invoice number, invoice reference, contact name, invoice date, due date, currency, line amount type, total amount, tax identification number, location, and the addresses.
Two requirements that are easy to miss and expensive to retrofit:
- Per-field confidence is not optional. The write path carries it and the webapp renders it. A stage that returns values without confidences breaks write-path parity, which is the biggest risk in the whole design.
- Record which block produced each value, here — not later. Extracted values are normalised, so dates and amounts will not string-match the text they came from. Aggregation cannot recover the link by searching; the stage that read the block has to keep the reference. This is the whole reason header bbox is not free while line bbox is.
| Failure | Behaviour |
|---|---|
| Model call fails, retryable | Retry, then let validation escalate rather than failing outright |
| The response does not fit the expected shape | One retry; a second failure dead-letters. A model that cannot fill the shape twice will not on the third try |
| A field is absent from the document | Empty, with no confidence — correct, not a failure. A hallucinated invoice number is worse than a missing one |
| Total amount missing | Still empty. Validation decides what that means; header does not guess |
line items #
| Input | The text layer’s tables and contact knowledge |
| Output | The line items, the column mapping, and the rows deliberately skipped |
| Model | One call per document, mapping columns |
| Budget | 0.30¢ average. Scales with table count, not line count |
| Baseline | line count 89.9% |
The model maps columns; the rows are built deterministically. It is not asked to emit line items. This is the single most important decision on this page: it makes output size independent of line count, and it makes “0 lines extracted from a document that has lines” — a known v1 failure — structurally impossible rather than merely unlikely.
Each built line keeps a reference to the table cells it came from, which is what makes line bbox free in aggregation and is only obtainable here.
Amounts stay in decimal all the way through. They feed the calculator, which is decimal throughout, and going via floating point reintroduces exactly the cent-level drift the calculator exists to avoid.
Shapes that break the one-table assumption, all of which must be handled: multiple tables on one document, one table split across pages, and rows that are subtotals, section headers or continuations rather than line items. The last of these is recorded with a reason, so “why is this line missing” is answerable.
| Failure | Behaviour |
|---|---|
| No table in the text layer | Zero lines, recorded with a reason. Legitimate — some AP bills have none |
| Column mapping call fails | Retry; on exhaustion, zero lines plus a recorded failure, and let validation escalate |
| The mapping names a column that does not exist | Reject the mapping and retry once. Never index past the table |
| An amount cell does not parse as a number | Leave that field empty on that line, keep the row |
accounting #
The only join point in the graph.
| Input | The extracted lines, the extracted header fields, the text layer, and the entity’s accounting context |
| Output | Per-line account, tax rate, tags and item assignment, plus header-level assignments |
| Budget | 0.15¢ |
| Replaces | Accounting metadata, tags, item assignment and account refinement |
Each assignment references a line the line items stage produced. An assignment naming a line that does not exist is a programming fault, not a model fault, and should fail loudly.
Provider capability gating must be preserved exactly. v1 gates: tags only when the entity is set to auto-assign; header-level versus line-level tags per integration; debit/credit tags for double-entry providers; item assignment only for AP bill and only where the provider supports it. This is the part of the rewrite most likely to be quietly lost, and it fails as a rejected publish — visible to the customer, days later, far from this code — rather than as a wrong extraction. The aggregated-description behaviour v1 stores in metadata must survive too.
| Failure | Behaviour |
|---|---|
| Model call fails | Retry; on exhaustion, unassigned lines plus a recorded failure. An extraction with no account codes is still useful; a failed extraction is not |
| An assignment names an account, tax rate or tag the entity does not have | Drop that assignment, record it. Never write an id the provider will reject |
| An assignment names a line that does not exist | Hard error |
| Entity has an empty chart of accounts | Skip the call, zero cost |
aggregation & bbox #
| Input | Every field stage’s output, plus the text layer’s geometry |
| Output | The extraction in the shape the rest of the product already consumes, with confidences, bbox and per-stage cost |
| Model | None. Pure compute |
| Budget | 0 |
The output shape is v1’s, deliberately: the same extracted data, confidences and bbox that the write path, the webapp, the exports and the accuracy views already consume. Aggregation is where write-path parity is either achieved or lost.
Bbox is two mechanisms, not one:
- Line fields inherit the cell box the line was built from — a lookup, effectively free.
- Header fields need a normalisation-aware resolution from the recorded source block, because the normalised value no longer matches the source text. Do not budget this as free; it is the part that takes work.
| Failure | Behaviour |
|---|---|
| A field has no resolvable box | No box for that field. Never fail an extraction over a missing highlight |
| A recorded source block is absent from the text layer | Record and drop. Indicates a stage bug, and shows up in bbox coverage |
| A line has no cell reference | No boxes for that line, keep the line |
validation #
| Input | The aggregated extraction and the entity’s calculation settings |
| Output | Pass, escalate, or terminal needs-review |
| Model | None — the shared calculation engine |
| Budget | 0 for the check. Escalation is charged to the escalation budget |
| New in v2 | v1 has no validation step at all |
The check recomputes the total from the extracted lines using the entity’s own tax calculation mode, rounding methods and decimal-place settings, then compares it against the extracted total within a footing tolerance.
Using the entity’s settings rather than a default is the whole point. The same lines legitimately foot to different totals under line-by-line versus accrued tax, or under a different rounding method. Validating against a house default would flag correct extractions for entities configured differently, and the escalation loop would then spend real money re-parsing documents that were right the first time.
Escalation policy:
- On a footing failure, re-parse at Extend’s expensive tier and re-run the stages from Extend onward.
- Cap the attempts. On the last failure the extraction is written with its numbers intact and flagged for review, never discarded.
- Record every escalation outcome: reason, attempt, whether the re-run fixed the footing, and what the re-parse cost. Without this, “does higher-effort re-parsing actually help?” is unanswerable — and that question decides whether the loop survives past the A/B.
| Failure | Behaviour |
|---|---|
| No total on the document | Cannot foot. Needs review without escalating — a re-parse cannot invent a total the document does not state |
| Zero lines | Same: nothing to foot against |
| Footing fails within tolerance | Pass. Tolerance is config, and its value is set from measurement |
| Footing fails, attempts remain | Escalate |
| Footing fails, attempts exhausted | Needs review |
| The calculation engine panics | Hard error. A panic here is a bug in shared code that also runs in the webapp, and must not be swallowed |
The priority-field pass #
Not a stage. A priority field is one the entity asked us to be extra careful about, and it is handled inside whichever stage already owns that field. The stage extracts normally, then makes one additional focused call per priority field it owns. If line amount is a priority field, the line items stage builds the lines and then re-asks the model to look harder at just that column.
| Owner | Fields |
|---|---|
| header fields | invoice number, invoice date, due date, total amount, tax id, currency, reference, title, description |
| line items | description, quantity, unit price, tax rate, tax amount, line amount, discount fields |
| accounting | account, tax rate, tags, item assignment |
Rules:
- The pass may not widen its stage’s output, and may not read another stage’s output. If it needs something a sibling produced, the field belongs to the sibling.
- The refined value overrides the general extraction’s value and carries its own confidence, as v1 does.
- It works for re-extractions as well as first passes.
- Cost: 0.10¢ across all three stages, one call per configured field, zero when the entity configures none — which is the common case, so it must not cost a call to discover.
- A pass failure never fails the stage. The general extraction’s value stands, and the failure is recorded.
Failure policy, in one place #
| Class | Behaviour | Where it lands |
|---|---|---|
| Transient — 5xx, timeout, throttle | Retry with delay, up to the job’s retry limit | The delay queue |
| Permanent — unparsable document, malformed response twice, a dangling reference | No retry | The dead-letter queue |
| Degradable — one signal down, one field absent, one box unresolvable | Continue with less, record it | The result, and the per-stage metrics |
| Footing failure | Escalate to the expensive parse tier, capped | Validation; terminal state is needs-review |
Two rules that hold everywhere:
- Fail the extraction only when there is nothing to write. Every stage above degrades where it can. A partial extraction a user can correct beats an error they cannot act on — and it is what v1 does, so doing otherwise would also skew the A/B.
- On any hard failure the extraction goes to error, in the extraction phase, through the same bonsapi service the v1 path uses. The heartbeat runs on the same schedule too, so a stalled v2 extraction is detected by the machinery that already detects a stalled v1 one.