Stage one, as built
The intake pipeline that shipped first: OCR, deterministic form detection, vision on hand-marked forms, KB-grounded classification, and an idempotent batch runner over a warehouse status table.
The full disputes system was designed around a pipeline that already existed. Stage one (read the letter, work out what it is, persist the result) was running as a batch processor before the investigation engines, the case pack or the platform contracts were written. It shipped early because it was the highest-volume, best-defined slice: classification alone removes the first several minutes of every case.
The pipeline
One async function, deterministic end to end:
OCR (per page)
→ form detection pure functions over the text; no model
→ form resolution deterministic and authoritative
→ form analysis only for the resolved type; concurrent if several
→ knowledge-base load cached; one workbook per classification axis
→ classify text model, JSON mode, Pydantic-validated
→ persist warehouse insert + a marker file downstream depends on
Form handling. Four outcomes: a single recognised form, several (each analyser runs concurrently and the results are merged), or none, in which case classification proceeds on the OCR text alone. One form type is resolved entirely from the OCR layer’s selection marks and geometry. Two are read by GPT vision under an observe-only prompt. The form type the resolver picks overrides any form type the classifier might emit. The model is not consulted on routing.
Classification. Grounded on a canonical folder of business-owned workbooks, one per axis: the dispute reason (single, cited), the customer’s requests (multi, each cited), and the complaint issue type (single, cited, optional, tiered by how well-defined the type is). Plus the identity fields and whether an attorney or a debt-management firm is writing. Output validates into a typed result; every value must be on its governed list; every conclusion carries a citation into the letter text. The prompt is versioned by date and pinned with the workbook version it was written against.
Persistence. Two warehouse tables. A status table keyed on the document (in progress, completed, failed, with a processed timestamp) is the idempotency ledger: the runner claims a document by inserting or flipping its row, and a redelivered or re-run document is skipped if already completed or in progress. An output table holds the classification result, the citations as JSON, and the form type. Adding the complaint axis meant two new columns and a one-time DDL change ahead of the batch, which is the kind of downstream contract the marker file also represents: preserve the shape exactly, because something you don’t own reads it.
The batch runner
Iterates a watch folder, claims each document through the status table, runs the pipeline, writes the row and the marker, and sets a terminal status. Concurrency is bounded by a semaphore. Every blocking call (the OCR client, the database driver, the PDF library) is wrapped so the event loop never stalls. The bound was tuned by watching the OCR service’s rate limits rather than the CPU.
What the design kept, and what it replaced
Almost everything above survives into the full system unchanged. The classifier becomes an agent in the orchestrator graph and gains an intent axis that routes deadline clocks. The form detection stays exactly as it is. The status table becomes the idempotency ledger for the queue consumer. The marker file stays until the case-pack store replaces its reader.
What went: an LLM root agent that called the classifier as a tool (replaced by a deterministic orchestrator; the LLM agent stays as a colleague Q&A front door), service-principal secrets in environment variables (Managed Identity), and a shared database cursor (a pool).
What the code review found
The stage-one review produced the first iteration’s backlog, and most items were the gap between “works on a laptop” and “runs in a container off a queue”: vision through the abstraction layer unproven at volume; a browser-based login flow and network share paths that would not exist inside Container Apps; and a cursor shared across concurrent tasks. None were bugs in the classification. All were the reasons the platform has a document reader and a fetch layer instead of each project having its own.