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.
Running a stage in the harness #
The harness runs as stage --stage <name> --case <dir>. Entity, knowledge, contacts and the
accounts, tax rates, tags and items each come from the first of: a flag (--entity,
--knowledge (repeatable), --contacts, --accounts, --tax-rates, --tags, --items,
--provider), the path given in the case’s inputs.json (relative to the case directory;
knowledge is a list whose cards are joined), the file beside the document, or nothing, which
is listed as missing. A named file that does not exist is an error, and so is a flag the chosen stage never
reads. A named accounting part replaces that part of accounting.json alone; a missing
accounting.json is listed unless all four lists are named.
Accounting and aggregation calculate amounts with the entity’s calculation settings, resolved as
the v1 pipeline resolves them: the integration’s accounting_config in accounting.json decides
every setting it has, and otherwise calculation_settings in entity.json (the entity’s decimals,
total rounding and AP bill override, as stored). A case with neither gets the defaults: two
decimals, line-by-line tax, nearest rounding. prepare writes both.
A case holds its document as one document.pdf, .png or .webp, or as a document/ directory
of page images with a pages.json: one entry per page with file, page_number, type
(PRIMARY | SUPPORTING), and optionally rotation, skew, original_document_page_id and
coordinates. The values are the stored ones: document_page.rotation (clockwise degrees),
public_metadata.orientation.skew, and for a snippet its coordinates (fractions of the page it
was cut from) with file naming that page. A snippet is marked by original_document_page_id;
coordinates without it is an error. Having both forms, an unlisted page file, or a
duplicate page_number is an error. The directory also takes JPEG pages (a page that is changed goes to Extend as PNG); the single document.<ext> stays PDF, PNG or WebP. Only primary pages are sent to Extend; the supporting ones
are named in the output of the run.
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.
When nothing has been learned about the matched contact yet, the workflow reads
the contact’s three most recently updated paid invoices from the accounting
software, and the field stages get those with lines as past examples, with no
rules. They are not stored, and a failed read is logged and leaves the run
without contact knowledge. prepare records what the eval data collector
returned for each case in past-invoices.json, and the harness makes the same
examples from it. The case’s own bill was not in the accounting software when
it was extracted, nor was an invoice dated after the day the case ran, so the
next invoice takes the place of each.
Line items and accounting ask again for the priority fields they own, 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.
Prompts #
A stage is handed its prompts the way it is handed its model: they arrive on the same value, resolved for the extraction type being run. A stage never looks one up from a global registry, which is what lets the accuracy harness swap the prompts of one stage and compare, exactly as it swaps a model.
A prompt is plain text with {{name}} placeholders. Only that exact form is a
placeholder; every other brace is literal, so a JSON example inside a prompt
needs no escaping. A stage fills them from a struct it owns, one field per placeholder. Rendering
fails if a placeholder has no field or a field has no placeholder, so an edit
that drops an input cannot go unnoticed. Substituted
values are never re-scanned: a document that happens to contain {{x}} cannot
inject into the prompt it is rendered into. What fills a placeholder, and any
branching on provider or entity settings, is the stage’s code, not the template.
Templates are GPG-encrypted in the repository, under
prompts_encrypted/<extraction type>/<stage>/<name>.txt.gpg, and decrypted by
the build script into the build directory with the hinoki key
(GPG_SECRET_KEY_BASE64). Plaintext never lands in the source tree. This
protects the repository, not the artifact: the text is in the binary.
mise decryptwrites plaintext to the gitignoredapps/tofu-extraction/prompts/for editing, andmise encryptre-encrypts what changed.- A build without the key falls back to dummy. Every template becomes the
text
dummyand the build prints a warning, as with hinoki’s dummy prompts. Dummy templates render leniently: the typed values a stage passes need not match placeholders that are not there, while the real templates stay strict. The catalog logs an error and reportsis_dummy(); never read accuracy off a dummy build. CI and release builds do not fail without the key, because it is not wired into them; making them fail belongs with the deployment plumbing (ENG-8083), which also passes the key as a file path (GPG_SECRET_KEY_FILE) so Cargo does not record the value under a cachedtarget/.
Output schemas are derived from the type the answer is deserialized into, then
tightened to what strict: true accepts (every property required, optional ones
nullable, no bounds, no $ref). A type the rules cannot express, such as a
recursive one, is refused when the schema is built rather than rejected by the
provider at call time.
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, plus a second for any pages the first left out.
| Input | The primary pages of one group, in order |
| Output | Markdown text, tables, per-block geometry, OCR line and word boxes |
| Model | Not an LLM — Extend’s light parse tier, pinned to engine version 1.0.0 |
| Budget | 1.50¢ per page (0.5 credits), 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 |
|---|---|
parse_light, version pinned to 1.0.0 |
The expensive tier is reserved for validation escalation. Pinned so the text and boxes every stage reads do not change when Extend ships a new version |
| Markdown output, tables as HTML | Geometry is the point: every block has a box |
| 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 |
| OCR lines and words returned | Boxes are picked from the OCR lines, then narrowed to the words that say the value, so a value is not boxed with the rest of its line |
| Only the group’s primary pages | Supporting pages are not parsed, and not paid for |
| Pages sent as the model will see them | A page that is a snippet is cut from its parent by its polygon, with what lies outside the polygon blacked out, a residual tilt is straightened, and the stored rotation is applied last, the same chain the v1 arm reads a page through. A page with none of the three goes as the file it is |
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.
Every case is decided here, in the caller. tofu-extend classifies and owns no retry loop. The
options for each are retry, fall back to the v1 arm or fail to review; the stage never falls
back to v1, because that choice is made above it, where the arm is picked. It only ends in a failure
the caller can act on.
| Failure | Behaviour |
|---|---|
| 5xx, 429 or a dropped connection | Retry in the stage: up to 4 calls with exponential backoff from 2s, capped at 30s, then jittered to between half and one and a half times that. A Retry-After is a floor on the wait, never a replacement. No new attempt starts once 10 minutes have passed. When the attempts are spent the job is retried through the delay queue |
| Upload | Done once. Every attempt after it goes by file reference, so a retry is one request and not two |
| Timeout — our own request deadline | Retry once, not up to 4 calls: a run that timed out spent its whole budget and the same document is slow again, so each retry is a full-price repeat. After the retry it fails to review, with no job retry. A timeout the attempt limit or the budget cut short before its retry is an ordinary transient failure and goes back to the queue. The deadline sits above Extend’s own 5-minute cutoff on purpose, so it fires on a stalled connection rather than a slow document. How Extend reports its own cutoff is not yet verified against the live API; until it is, that response follows whichever row its status and body match, a 504 being a 5xx |
| Extend reports the parse failed | Fail to review. A document Extend cannot parse will not parse on the third attempt either |
| An unrecognised status | Fail to review at once. The response is not the shape we expect, and a repeat does not change that |
| Empty or near-empty text — no chunks, or under 20 characters of text per page | Fail to review. Silently extracting nothing from a parse that returned nothing is the worst available outcome. Counted from word-level OCR when the run has it, since a table’s markup inflates the characters of a chunk. Checked before anything is parsed again, so a blank run is not paid for twice. When pages are read one by one a blank page is allowed and listed in the output as empty_pages; only a document with no text on any page fails, and the per-page character floor is not applied |
| Partial output — on a whole-file parse, pages the request asked for (its page ranges, else the whole file) with no chunk. Pages read one by one are a run each, so they have none to repair | Parse just those pages again by file reference and merge them in. Pages still absent after that fail to review, naming them. A second parse whose chunks are not on the pages asked for is refused rather than merged. Chunks on the first parse that carry no page cannot be checked and are read whole, as the lines stage already does. If the second parse exhausts its retries the job is retried from the top, and pays for the first parse again |
| Repaired pages come back with no OCR boxes | Degrade, not fail: their text is merged in and only the boxes are missing. Recorded on the stage output and carried into the aggregate report |
Each live parse’s credits are recorded when it returns, so a second parse for missing pages is a second entry in the ledger.
On a whole-file parse a genuinely blank page that Extend returns no chunk for looks the same as a dropped one, so a document with one fails to review after its second parse. If that proves common in practice, the rule to relax is the last step of the partial-output row.
Replay. The harness caches parse output by file hash, so component runs never re-pay Extend, except for a run with no text or with pages to repair: that is not stored, so it is parsed live again on every replay. 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. A page read on its own is keyed on the page as sent, after its crop, straightening and rotation, so a changed rotation misses and the pages around it hit.
Pages. Read one by one, each comes back as page 1 of its own run. They are merged in
page_number order: pages renumbered by position among the pages sent (so a skipped supporting
page leaves no gap, and the document’s own page number is not in the output, except in
empty_pages), block and chunk ids prefixed with the page so they stay
unique (and the OCR words keep their blocks), credits added. Boxes are mapped back to the frame
they are stored in — a whole page’s to the stored page, a snippet’s to its straightened crop,
which is where the webapp draws it. Undoing a straightening tilts a box, so the box kept is the
one enclosing it.
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, if it is eligible. A strong signal (TIN, bank number) may win alone. A weak one (domain, name alias, address) needs a second signal to agree, and the name alias never wins on its own vote. On equal votes the contact holding the strongest signal wins.
- No eligible winner → fall back to the v1 approach: give the model the document text, the name aliases and the entity’s candidate contacts, and let it pick one or decline. Declining is a valid answer: a wrong contact is worse than none, because contact knowledge feeds every field stage. When it declines it also gives the supplier’s name as the document writes it, which is the only source of a name for a contact that does not exist yet. The call is made for an entity with no contacts too, for that reason. A large contact book is shortlisted by name similarity to a fixed cap before the model sees it, which is v1’s weakness and wants measuring.
The vote runs in the stage. The lookup only returns, per signal, the contacts that hold the document’s value, and a document with no signals skips it. The candidate list is fetched only when the fallback runs, so a voted match never loads the contact book. The output records which mechanism decided, the confidence, every signal’s vote and both the raw and the normalised signals, so voted and fallback matches can be scored apart.
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 |
| 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.
Each field is told the matched contact’s rule for it and the values its latest bills had. When the latest five of those bills all have the same line amount type, it is not asked.
One requirement that is 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.
The stage records no source block. Extracted values are normalised, so a date or an amount does not string-match the text it came from, and aggregation finds each header box by looking the value up in the text layer as the document would write it (see aggregation & bbox). That lookup is why header bbox is not free.
| 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 document’s markdown and the line knowledge |
| Output | The line items, each field with the model’s confidence |
| Model | One call per document; one per page from 30 pages |
| Budget | 0.30¢ average |
| Baseline | line count 89.9% |
The whole markdown goes to the model together with the knowledge cards that target a line field or the line group, and the matched contact’s rules for its lines with the lines of its latest bills, and the model answers with the lines. Evaluation found this the most accurate arrangement, so the stage does not detect tables or build rows itself: a bill whose lines are not in a table is read the same way as one whose lines are.
The markdown is read whole — not through the first-ten-and-last-ten-pages window the header stage uses — because a line on page 15 of 40 must not disappear. From 30 pages a document is read a page at a time, as v1 does, so that no single call is sent or asked to write more than a model will take. The calls run together and the lines are joined in page order, each line keeping the page it was read from, so its boxes are looked for on that page alone. The pages are the ones the chunks say they are on, not Extend’s page count: a document whose chunks carry no page is read whole, however many pages Extend counts.
Amounts are held as decimals from the text the model wrote and never go through
arithmetic on floats. They feed the calculator, which is decimal throughout. A
tax rate is a percent, as in v1 and the calculator: 10% is 10.
| Failure | Behaviour |
|---|---|
| No line items on the document | Zero lines, no failure. Legitimate — some AP bills have none |
| The text layer has no text | Zero lines, plus a recorded failure and no model call. A parse that returned nothing is not an empty bill |
| Model call fails, transient | The stage fails and the job retries, as in the header stage |
| Model call fails, permanent, or the answer does not fit the schema | Zero lines from that call, plus a recorded failure naming the page; validation reports it |
| A field the model cannot read | Empty, with its confidence as given. The line is kept |
accounting #
The only join point in the graph.
| Input | The extracted lines, the extracted header fields, the matched contact, and the entity’s accounting context |
| Output | Per line: account, tax rate, tags and item. For the bill: header tags and the aggregated description |
| Budget | 0.15¢ |
| Replaces | Accounting metadata, tags, item assignment and the aggregated description |
Each assignment names a line the line items stage produced. The model answers with an account’s code and everything else by id; the stage stores the account’s id. There is no header-level account or tax rate: v1 assigns those per line only, and so does v2.
Provider capability gating must be preserved exactly. It fails as a rejected publish — visible to the customer, days later — rather than as a wrong extraction. These are v1’s gates:
| What | Gate |
|---|---|
| Line tags | The provider auto-assigns tags. With no provider, as for internal accounting, they are assigned |
| Header tags | A provider with header-level tags (Bukku, Dynamics BC, Zoho), on top of the line-tag gate |
| Debit and credit sides | The entity books AP bills in double entry. Each side has its own account, tax rate and tags; the line’s own fields are the debit side |
| Item assignment | The field catalogue offers ap_bill_line.external_item_id for the provider, and the entity has items |
| A bill with no tax | No tax rate is assigned anywhere |
The catalogue names Xero, QuickBooks, Zoho, Sage and Freee; every other provider, and no provider, counts as non-integrated. Item assignment is listed for non-integrated, so it runs everywhere except QuickBooks. That is v1’s behaviour, kept on purpose.
What the model is offered. Lines are grouped by their own tax rate, and each group is one call. A tax code is offered when its rate on the bill’s date is the group’s, to four places; an account is offered only with a code that can take it. A provider with no code at the group’s rate gets no call for that group. Double entry adds the zero-rate codes for the contra side. Tags are offered by group, only where selectable, in scope and level, and with no inactive tag up the chain. Items are the purchasable ones, the selectable ones if there are any, in chunks of 1000 with the most confident answer per line winning.
Tax codes are the purchase ones: internal accounting drops sales-typed codes, and a provider’s must be loaded for the purchase side. An account with an empty code is shown and matched by its id.
What each call asks for. A rate group offered both accounts and tax codes is asked for both in one call; offered only one of them, it is asked for that one alone. Only internal accounting is ever asked for tax codes alone: a provider with no account to offer is not asked at all. Line tags and header tags are asked in separate calls.
What is kept. An account the chosen tax code cannot be used with is dropped and the tax code stays. A line’s tax code must carry the line’s own rate on the bill’s date; in double entry the credit side may instead carry a zero rate. The tag answer has one field per group, so a group takes at most one tag, as in v1; a tag that is not in its field’s group is dropped. An assigned item’s own account and tax code replace the model’s, as in v1.
A header field that failed is not an extraction. A failed line amount type holds the no-tax default, so it is not taken as no tax. A failed invoice date holds today’s date, so when a tax code’s rate varies by date and the bill is not no-tax, none are offered: the account and tax call is skipped and the run records why. Flat-rate codes, internal accounting and no-tax bills are unaffected.
Contact knowledge is the contact-scoped knowledge cards in the run’s context, read for the matched contact alongside the entity-wide ones, and how the contact’s latest bills were booked, which the account, tax and tag calls are shown. The stage fetches nothing.
The aggregated description is a separate short call over the lines, cut to 50 words, as in v1. It is made whenever there are lines, whatever else is gated off.
Large bills are read whole. Every line in a rate group goes to one call, and every line to the tags call. v1 splits by page from 30 pages or 200 lines. A very large bill can therefore exceed what one call takes, which is a permanent failure and leaves its lines unassigned and recorded. Splitting by page is a follow-up.
The templates are prompts_encrypted/ap_bill/accounting/<name>. Their placeholders are fixed
by the stage:
| Template | Placeholders |
|---|---|
context |
entity_name, country_id, today |
knowledge |
cards |
candidates* |
tax_rates, chart_of_accounts, or the one its variant offers |
extract*_input, double_entry*_input |
extracted_header, extracted_lines, line_count |
tags_candidates, header_tags_candidates |
tags |
tags_input, header_tags_input |
extracted_header, extracted_lines |
items_input |
items, extracted_lines, line_count |
description_input |
extracted_lines |
extract*, double_entry*, tags, header_tags, items, description |
none |
A typical bill takes two to four calls: the description, one per tax-rate group, and one each for tags and items where gated on. They run together.
| Failure | Behaviour |
|---|---|
| Model call fails, transient | The stage fails and the job retries |
| Model call fails, permanent, or the answer does not fit the schema | That call’s lines are left unassigned, 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, tag or item the entity does not have, or one that was not offered for the line | Dropped and recorded. Never write an id the provider will reject |
| An assignment names a line that does not exist | Dropped and recorded. The model names lines and a schema cannot bound it to the real count, so a stray number must not fail an extraction that has answers to write |
| Entity has an empty chart of accounts | The account and tax call is skipped, at no cost. The description call still runs |
When the entity marks a line’s account or tax rate as priority, both are asked for again for every line in one call, as v1 refines them, after the calls above have answered. The call is shown the document text and what they assigned, and offered every account and tax code an AP bill line may take. Tags are asked for again likewise, in a call of their own, unless the provider does not auto-assign them. The answers pass the same checks, and a line whose answer has anything dropped, such as a tax code at another rate than the line’s, or a null account or tax rate, keeps what the calls above assigned it. An assigned item’s own account and tax code still replace them. A tag field marked as priority also shows the tag and header tag calls the document text, the first and last 10 pages of one over 30.
aggregation & bbox #
| Input | Every field stage’s output, plus the text layer’s geometry |
| Output | The merged extraction: header, lines, a confidence for each field, a box for each field that has one, the aggregated description, and a report of what the stages could not do |
| Model | One call per 20 lines to pick each line field’s OCR lines, or per 100 lines of a page when the document was read a page at a time; for header fields, one call only when the lookup left one unplaced |
| Budget | About 0.05¢ for a one-page bill’s line-item call (about 2,700 input tokens on Gemma, measured on 20 bills); a header call only when a field is left unplaced |
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.
The merge. Each field comes from the one stage that writes it, so nothing depends on which stage ran last:
| Output field | Source | Note |
|---|---|---|
| title, invoice number, invoice date, due date, currency, total amount, tax identification number, line amount type | header stage | Defaulted and failed values are carried as the header stage made them |
| contact id | contact stage | Only when the outcome is Matched |
| contact name | the matched contact’s own name from the entity’s contacts; on no match, the name the fallback read | As v1: the matched vendor’s name, else the extracted one |
| line description, quantity, unit price, tax rate, tax amount, line amount | lines stage | |
| account, tax rate id, tags, item, credit side | accounting stage, joined to its line by order |
Item defaults are already applied there |
| header tags, aggregated description | accounting stage |
The output uses v1’s field names and domain types (dates, decimals), and lines are keyed by
order, as in every stage. The ids a database row needs, the wire types, and the fields v2
does not produce (the invoice reference, addresses, language, location) are the write path’s
mapping to supply. Confidences take v1’s names: the header and line stages’ confidences as
they are, the contact’s match confidence on the contact id and, when a matched contact’s name was
found, on the contact name (an unmatched contact, or a name the fallback read off the document, has
none, because a confidence describes a match), the accounting confidences on the
plain account, tax rate, tag and item names, and the credit side under external_credit_* (v1
fills external_debit_* only for receivables). A failed field has none.
The report carries what the values alone do not say: the header fields that failed and
those the model left empty (a 0 total in either is a stand-in, not an answer), the lines
failures, the accounting failures and dropped assignments, and what the text layer lost without
failing (tables with no cell boxes). Validation reads it to tell a
real zero from a missing one and an empty bill from a failed call, and the write path to know
that a stage failed.
Post-processing follows v1’s. Before the merge, and before accounting, which reads the bill the same way, a negative quantity is flipped with the unit price, a bill with no line items gets an “Invoice total” line, unit prices are fitted to the line amount type (and, beyond v1, a fitted line’s read amount with them), and the confidences are halved when the lines do not add up to the total. After the merge come the integration’s tax rules and the tax groups.
Bbox is two mechanisms, not one. Either way a field’s boxes take the shape v1 stores for boxes
read off OCR text, {"primary": [{page, poly, text}, ...], "reference": []}: one box per OCR line
the value was found on, reference always empty.
- Line fields are picked by a model from the OCR lines, as v1 picks them from its OCR text, with
v1’s prompts (
line_bbox,line_bbox_input). The lines, as v1 lays them out, and every OCR line of the document, numbered, go in one call per 20 lines, and the model answers with the numbers that hold each field’s value. A document the lines stage read a page at a time (30 pages or more) is boxed a page at a time, as v1 boxes one: one call per page, with that page’s OCR lines alone and v1’s per-page prompts (line_bbox_page,line_bbox_page_input). Calls run five at a time. A field takes a box per OCR line picked for it, on whatever page it is. A figure is boxed to the fewest words of its line that say it, a rate to a run with a%, so a quantity printed beside its unit price is not boxed with it; a line that does not say the figure, or says it twice, and a description, keep the whole line. The picks are not checked against the value, as in v1. No call is made without OCR lines. - Header fields are looked up by value, from the header output and the text layer alone: no
model call and no knowledge. Each value is searched for as the document writes it: a date in any
of its forms (
14/03/26,14 March 2026,2026年3月14日), an amount under either decimal convention, an identifier with its spacing and punctuation ignored, full-width digits folded. Where several OCR lines say the value, the stronger match wins (a date or an amount is stronger as the only one of its kind in its text, so the last day of a period loses to the same date printed alone), then the one where a field of that kind usually sits (the total low and late, the rest high and early), then the shorter text. Only pages the header stage was shown are searched, and only their OCR lines, never a layout block, as the PoC boxed values from OCR text, so a value is never boxed with the address or the rows around it. The line found is then boxed to the fewest of its words that still say the value, by the same match, so a tax number printed after an address on one line is boxed alone. The title keeps its whole line. Do not budget this as free; it is the part that takes work.
The header fallback. The header lookup places what it can by value and by position first.
For what it leaves, header fields get one call with v1’s bbox prompt (header_bbox,
header_bbox_input): the merged header values as v1 shows them and the OCR lines the lookup
searched, numbered as v1 lays them out, with {"p": [...]} back per field. A document over 30
pages also gets v1’s note on which pages it is shown (truncation). The prompt’s rule 5 is
replaced, as the PoC replaced it, so that a due date the page does not print, computed from a
payment term, is given the invoice date’s line and the term’s line; the PoC’s "r" is "p" here,
because v1 stores and the webapp draws only primary. The title and the currency are never asked
for, because the title is written by the header stage and the currency may be the entity’s own,
so a model asked for their source finds one that is not. Every other pick is checked, and a
wrong box is left out: a line must share a run of three characters with an identifier, show two
of a date’s day, month and year as numbers (each number counted once), or show an amount’s whole
part as a number of its own. A due date’s picks are not checked, since the term’s line does not
print the date. A line taken is boxed whole, since the lookup found no words on it that say the
value. A transient model failure is not retried, for either kind of box: the document ships with
those boxes missing.
Reading it in the harness. stage --stage aggregate merges a case’s header and lines outputs and
finds the boxes: the header lookup with its fallback, and the line-item call. It needs header-output.json and lines-output.json;
the accounting output, the contact result, contacts.json (to name a matched contact),
accounting.json, knowledge.json, entity.json and case.json are optional and listed when missing, so it
runs without the accounting or contact stage. The readout is read off the output: the header
with each field’s confidence and box (marked FAILED or DEFAULTED where the report says so),
the lines with their figures, account, confidence and a letter for each field that has a box
(Description, Quantity, Unit price, Amount, tax Rate, Tax amount; - for a field the
line has but found no box for, a blank for one it does not have), the aggregated description,
the report, and the box coverage (header: n/7, lines with a box, a count per field), then the
calls and spend of the box calls. None of it is accuracy: there is no true box to score against,
so a person judges whether a box is the right one. The header and lines commands print no
boxes.
| Failure | Behaviour |
|---|---|
| Accounting assigned a line the lines stage did not produce | The stage fails loudly: a programming fault, not a model fault |
| A stage failed or left a field to a default | The merge carries the values and the report says which; nothing is hidden by a default |
| A field has no resolvable box | No box for that field. Never fail an extraction over a missing highlight |
| The lookup left a header field unplaced | One model call with v1’s bbox prompt, shown the header values and the numbered OCR lines, answering with line numbers or null. A number outside the list, or a line that shares nothing with the value, is dropped |
| A due date the page does not print, computed from a payment term | The invoice date’s line and the term’s line, a box each, unchecked |
| No single OCR line says a header value (it is split across lines) | No box. A layout block is never the box |
| A bbox model call fails, or its answer does not fit | The header lookup’s result stands, and the lines it was asked about keep no boxes. A missing template fails the stage, as in the header stage |
| A header field failed, or holds a stand-in (zero total, empty text) | Not looked for. No box |
| Two header fields are printed on one OCR line | Each is boxed to its own words; both get the line’s box when its words are not known |
| The model picks no OCR line for a line field, or a number that is not one | No box for that field, keep the line |
| Two fields of a line are in one OCR line (a quantity and a unit price read as one) | Each is boxed to its own words; the OCR line’s box when they are not found or the figure is printed twice on it |
| A bill with more than 20 lines | Asked in parts of 20, each part shown every OCR line |
| A document read a page at a time (30 pages or more) | One call per page, shown that page’s OCR lines alone |
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: the subtotal plus tax when prices exclude tax, the subtotal alone otherwise. When an integration’s tax rules make the bill no-tax, the total is compared under the line amount type as read, as reconciliation compares it. When the entity allows line tax overrides, the tax read on each line is used as Mark ready uses it, rather than recomputed from the rate. It also compares each line’s amount with its quantity times its unit price.
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: one re-parse, at Performance. On the last failure the outcome is needs review and the extraction is written with its numbers intact, 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. The tolerance is the rounding, compared as reconciliation compares: the total to total_rounding, a line to the line amount decimals. A total the lines give only when summed at full precision and rounded once fails here, though Mark ready accepts it |
| 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 |
Reading it in the harness. stage --stage validate validates a case’s
aggregate-output.json at the first effort and writes validate-output.json:
the outcome and each failed check. e2e validates every case after aggregation
and counts the results; with --enable-escalation, a case that escalates is
parsed again at Performance and every stage runs again, which costs another
Extend parse. score also runs the total check on the verified data and reports
how many cases it would fail.
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 asks again for the priority fields it owns. If quantity 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 | None: every field already has a call of its own. Line amount type is asked even when the contact’s latest bills settle it, and their answer stands in when the model’s cannot be used |
| line items | description, quantity, unit price, tax rate (marked through the tax rate id), tax amount; in double entry the tax fields are the debit side’s — a call per field, shown the contact’s rules and latest lines as the general call is |
| accounting | account and tax rate in one call, tags in another. Item assignment has none, as in v1 for AP bills |
A document read a page at a time is asked about again a page at a time, each page’s lines with that page alone, as v1 refines a long document. A line with no page, such as the total line of a bill with none, is shown the document as the header stage is.
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. A value that fails the stage’s checks, or is null where the prompt never asks for null, leaves the general value as it was; a tag that does not apply and a tax amount the document does not print may be null.
- It works for re-extractions as well as first passes.
- Cost: 0.10¢ across the line items and accounting stages, 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.