Build order
What order the planned work should be built in, read from the dependency graph the repository already records.
The readable version of this page, one outcome per step and no dependency graph, is milestones. This page is the reasoning behind its order. The roadmap reads the same work against the product blueprint, screen by screen, and the sprints pages track it week by week.
features.mdx marks work as Planned but says nothing about what order to build it
in. The information is there — it is just scattered across four places that never appear on one
screen: the **Revisit when** triggers in decision records, the
benchmark’s known gaps, the deferred section of
refactoring, and twenty ponytail: comments in the source.
This page reads that graph in one place. Almost every edge below is already stated somewhere in the repository, and the line is quoted where it is. One thing here is a recommendation rather than a reading — bundling the pending wire-contract breaks — and it is marked as such.
What is actually planned
Section titled “What is actually planned”badge-planned appears twenty-three times in features.mdx, and that is not twenty-three features.
One occurrence is the legend paragraph explaining the badge. One is an inline badge inside an
entry that is otherwise implemented — “Region blocks are refused, not dropped”, where the refusal
ships and only region production is planned.
Twenty-one entries, plus that inline half: twenty-two items. They are numbered below in the
order they appear in features.mdx, and the inline one is R.
Eleven more, numbered 22 to 32, came from the sales page on 2026-09-17. The front page draws
target output (ADR-0060),
and each block field it shows that encode does not return became a features.mdx entry:
22. heading blocks and a section path, 23. page furniture, 24. a block that continues across a page
break, 25. a diagram as a block, 26. OCR corrections per block, 27. defined terms,
28. cross-references, 29. image blocks with a caption; and three playground entries, 30. the run on
top with the page beside its output, 31. hover a block to read its output, 32. a route header per
page. They are appended rather than interleaved so the numbers above keep meaning what other pages
cite. The web app’s per-screen entries are sequenced on the roadmap, not here.
The three roots
Section titled “The three roots”Everything else hangs off these. Each is named as a blocker in the repository, not inferred here.
Root 1 — a labelled subset in benchmarks/case.schema.json
Section titled “Root 1 — a labelled subset in benchmarks/case.schema.json”Until schema 1.3 there was no ground truth anywhere in the benchmark: a case carried routing labels
and nothing about output, and score.py reported which capability ran rather than what it produced.
Ten labelled scopes now carry expected_text and expected_fields, and the report measures text
recall and field accuracy against them. economics.quality is still a constant in a table. What is
left of this root is coverage – the six unscored media types have no label, and neither does any
document two extractors would differ on – which is what the known-gaps entry now asks for. Its
earlier wording said what to do and who was waiting:
Add expected text or expected fields to
case.schema.jsonfor a small labelled subset, and report a quality figure measured against it. This is also what the Office reader comparison is waiting on, and what the entity extraction lanes are waiting on.
The schema change fired ADR-0035’s
revisit trigger verbatim; the labels that would tell two NER lanes apart are the coverage still owed. It also releases the second open question’s “ranked measurement over a labelled set”, and
the ponytail: at office-extraction/reader.py:75, whose upgrade is gated on “a benchmark label
needs a formatted date rather than the value”.
Root 2 — attribution, and then a PlanResult wrapper
Section titled “Root 2 — attribution, and then a PlanResult wrapper”ADR-0027 states this as a law rather than a preference:
attribution comes before selection. A caller cannot name what the system will not identify, so every selection trigger in ADR-0026 depends on this landing first.
Encode-side attribution needs nothing — EncodeResult already wraps its plan. The plan side is
blocked, and refactoring records the blocker as the single biggest one in the file: plan() returns
a bare RoutePlan, plan_id hashes every field it has, so there is nowhere to put a name that must
not change the hash. The PlanResult wrapper fixed it, in Phase 3, and broke the wire once.
Root 3 — measured baselines on the backbone
Section titled “Root 3 — measured baselines on the backbone”Three separate pages gate on the same phrase. Core user features: “Features 5–7 remain target requirements until the backbone has measured baselines.” Goals: “They wait until the backbone has measured baselines.” And the fourth open question defers persistence and the router/executor split on it. Baselines are produced by the corpus run, which is why Phase 5 sits where it does.
One API break, not four
Section titled “One API break, not four”This is the recommendation, and it is worth more than any reordering of the leaves.
Four pending changes each move openapi.json and the generated client: the port split
(ADR-0029),
the sampling removal
(ADR-0033),
the PlanResult wrapper, and the deduplication of Granularity and BlockKind — the same four
members under two names. Refactoring already states the rule for its own deferred list:
Changing any of these costs an
openapi.json+ generated-client regeneration … act only when the API is moving anyway.
Shipped separately that is four breaking releases and four client regenerations. Shipped together it is one. Every other decision on this page is reversible; this one compounds.
Those four shipped together in Phase 3. The same rule now applies to the blocks the sales page
promises: new block kinds (heading, paragraph, table, diagram, image, page_header), a
section path, table cells, a diagram’s nodes and edges, and references each move
openapi.json. Recommendation: settle the block shape for all of them in one ADR and ship
Phase 6’s contract change once, even if the chunkers that fill each kind land one by one.
The graph
Section titled “The graph”Five tracks. Only the Contract track is strictly serial. Dashed edges are work waiting on a trigger that has not fired, so nothing schedules them.
Green is delivered and amber is partly delivered, as of 2026-09-08. Phases 0, 2, 3 and 4 are on
main in full: the contract track is closed, and the hygiene audit’s four actionable sections have
landed. Phase 1 has the labelled subset and the scoring path, with the corpus
sourcing for the six unscored media types still owed. The declared-language detector left Phase 4
for the unscheduled list, waiting on a measurement. Everything unfilled is not started.
Phases
Section titled “Phases”Phase 0 — Hygiene
Section titled “Phase 0 — Hygiene”Track C, no dependencies, start now.
Not a features.mdx entry, but it is the substrate everything else is built on. Refactoring
estimated the whole audit at roughly 450–500 net lines; measured over the two pull requests that
carried it, source files only, it was 99 net lines removed against 200 lines of new test, with
no architectural change.
The correctness-adjacent items are done, and so is the per-call I/O section: the alphabetical device
trap, the duplicated residency rule, the first unbounded base64 decode, the classifier billed for
facets another classifier already took, the economics file re-read per candidate, the facade
singleton, the snapshot index rebuilt seven times, and the plan expanded twice per encode. The
ponytail: whose trigger had already fired went with them — a planless encode loaded and observed
its source twice, in the executor and again inside plan(), producing two SSRF verdicts that could
disagree; the planner port now takes the source the caller already holds.
The deletions and the shrinks followed: twenty identical providers behind one optional Plugin
base, the facade injection kwargs and the two protocols they orphaned, the correlation filter, one
content_digest and one number for the copies of each, the economics file validated by a strict
model, and the registry’s fourteen-wide tuples replaced by frozen objects. What is left in
refactoring is section 5, which moves the wire contract, and section 6,
which is the record of what was rejected.
Phase 1 — The measurement root
Section titled “Phase 1 — The measurement root”Track A, no dependencies, start now, longest lead time.
Add expected text and expected fields to case.schema.json for a small labelled subset and report a
quality figure against it. Done, schema 1.3. Then entity labels and a language per scope, scored per
installed extractor lane so ADR-0035’s ordering becomes a number. Done, schema 1.4: six scopes, 30
entities, and the ordering is measured on this corpus of forms. The “standard workload” denominator is
defined and reported, and the three cases the methodology recorded as missing are admitted as
synthetic fixtures. The run from an install carrying
--extra vlm is committed, and one generic-vlm baseline page is executed and billed beside the
table’s price. The engineering half of this phase is done; what remains is the sourcing half below.
Split this phase in two, because half of it is not engineering. The schema and the scoring path
are ordinary work. Corpus sourcing is externally blocked: of six readable-but-unscored media
types, four fail because the document does not appear to exist publicly — ten Japanese government
.docx files all declare one w:sectPr where the page rule needs two, both public text/plain
resources carry zero form feeds, and the e-Gov catalogue holds no TSV and no TIFF at all. Start the
sourcing on day one; it does not compress later.
This phase is also most of 3. read difficult enterprise data, whose counting rule is that a media type counts only when this install observes it, some capability reads it, and an admitted benchmark case scores it.
Phase 2 — Attribution, encode side only
Section titled “Phase 2 — Attribution, encode side only”Track B.
18. say which component answered, for all of them. Delivered: the components array on the
trace names the loader, sniffer, chunkers, language detector and winning classifiers by
distribution. The observer is not among them, and cannot be on this side – execution never
observes, so that name is the plan response’s to give, and it landed with the PlanResult wrapper
in Phase 3. Encode side alone here, as planned.
It goes here because it gates the whole ADR-0026 family. Every “the second implementation obliges an ID, an advertisement and a request field” trigger waits on it, so building any selection feature first means building a name for something the system cannot identify.
Phase 3 — The one API break
Section titled “Phase 3 — The one API break”Track B. The pivot of the whole plan.
One coordinated release, one openapi.json regeneration, one client regeneration:
- 13. sampling leaves the contract — a removal, so do it first inside this phase.
- 10. classifiers per page and per chunk.
- 11. entity extraction over pages and chunks.
- The
PlanResultwrapper, which completes Phase 2’s plan side and clears Root 2 permanently. Delivered. GranularityandBlockKinddeduplicated. Delivered: oneBlockKind, both ends.
Order inside the phase is sampling removal, then ports, then implementations: ADR-0033 notes that each shipped classifier acquires a sampling default it did not previously need, and that is cheaper while those files are already open.
Phase 4 — Implementations on the new ports
Section titled “Phase 4 — Implementations on the new ports”Track B, feeding Track D.
- 12. NER for Japanese and English, in four lanes. Needs Phase 3 for the ports and Phase 1 for
the accuracy claim — ADR-0035’s revisit trigger is literally “
case.schema.jsongains expected text or expected fields”. Build in dependency order: patterns, vibrato, ONNX, LLM. All four are delivered:indx-ner-patterns,indx-ner-vibrato,indx-ner-onnxandindx-ner-llm. - 10. classifiers per page and per chunk, the implementations: the three shipped classifiers declared through all three hooks. Delivered.
- 15. model-backed enrichment, the residue neither port shape covered: a summary and tags,
which became the third return shape on the grid
(ADR-0037).
Delivered:
indx-enrich-extractiveas the floor andindx-enrich-llmas the model lane. - 16. the language a format already declares was here as a conditional, and the condition has not been met: the entry calls it “the follow-up if the statistical answer proves weak on short documents”, and nothing has measured that. Moved to unscheduled, with that measurement as its trigger.
Phase 5 — Corpus and scale
Section titled “Phase 5 — Corpus and scale”Track D, needs Phase 1’s harness.
- 19. corpus runs and incremental results — progress, cancellation, failed-source isolation, CPU-only rate and cost per 1,000 documents. This is what produces Root 3.
- 14. classify a corpus on CPU, which says so outright: “waits on the corpus run below”.
- 1.
gs:andaz:sources. The loaders themselves depend on nothing and can be built in any earlier phase by a spare pair of hands; only the prefix fan-out belongs here, because “fanning out over a prefix is the planned corpus run”.
Phase 6 — Chunking, once a second chunker exists
Section titled “Phase 6 — Chunking, once a second chunker exists”Needs Phase 1 and Phase 3.
These four land as one change, and the sequencing is forced from two directions.
8. sentence and paragraph chunk boundaries creates the second non-fallback chunker. That fires ADR-0012’s trigger, which by ADR-0026 obliges an ID, an advertisement and a request field in the same change — which is 7. name the chunker in the request and 6. chunker selection beyond list order. Add 5. chunk-size control, the lever whose default 8 sets. Which boundary actually retrieves better is a Phase 1 measurement, not a preference.
It must come after entity extraction, and this is the trap in the graph. ADR-0030’s revisit trigger names precisely this change:
a chunker hands back text it rewrote — normalized whitespace, rejoined hyphens, a stripped running header — so the offset no longer indexes the
textthe block carries.indx-chunker-pdfmerges PDFium runs into lines today, and that merge is where this breaks first.
Building 8 before spans exist means the anchoring rule is designed against a moving target. Building it after means 8 opens ADR-0030 for review as a scheduled, known cost.
The sales page widened this phase from boundaries to blocks. Beside 8 it now carries
22. heading blocks and a section path, which is what ends a paragraph honestly;
23. page furniture, a repetition across pages and so a document-level pass;
24. continuation across a page break, which needs 8’s paragraphs and the same document-level
pass; and 25. a diagram as a block, which moves the process chart’s graph from page metadata
into a chunk with a box per node and edge – a ChunkPiece carries text or an image today, so this
is the contract change. 9. table structure blocks leaves the unscheduled list for this phase:
its trigger was a consumer of rows, and the sales page and the playground’s hover now are one.
Then Phase 6b, over those blocks: 26. OCR corrections, 27. defined terms, 28. cross-references and 29. image descriptions. Three of the four rewrite or point into a block’s text, which is ADR-0030’s trigger again, so they come after the block shapes settle, never beside them. 28 also needs the heading and table blocks it points at, and 29 is a model call behind an extra, named per request like the enrichers.
Track E — the web app, start now
Section titled “Track E — the web app, start now”30. the run on top, the page beside its output, 31. hover a block to read its output and
32. a route header per page are front-end work over the line chunks, the plan and the
provenance encode returns today, so nothing gates them. They gain finer targets (a cell, a node, a
connector) as Phase 6 lands. When encode returns paragraph, table and diagram blocks, the sales
page draws a real run of its three pages and ADR-0060’s exception ends; that is Track E’s last step
and Phase 6’s visible proof.
Phase 7 — After measured baselines exist
Section titled “Phase 7 — After measured baselines exist”Track D. Root 3 is satisfied at the end of Phase 5, which releases all of this.
- 17. agent-readable export and 2. recipes and similar-format reuse, taken together. The fourth open question explains why: “a recipe store and an export target are the first two things that want state, so that is where the decision gets made rather than postponed again.” The persistence decision is made here, once, with both consumers visible — not twice.
- 20. operational maturity — authentication, rate limiting, metrics, packaging, idempotency keys. Its own gate: “the HTTP adapter stays a thin transport until the backbone’s baselines are measured.”
- 21. deferred architecture — region optimisation, broad modality coverage, RAG export, persistence, separate router and executor deployments.
Full assignment
Section titled “Full assignment”Every one of the thirty-three, so nothing is silently dropped.
| Phase | Track | Items |
|---|---|---|
| 0 · Hygiene | C, now | refactoring sections 3–4 |
| 1 · Measurement | A, now | 3. read difficult enterprise data; the labelled subset and known gaps |
| 2 · Attribution | B | 18. say which component answered, encode side |
| 3 · API break | B | 13. sampling removal · 10. page and chunk classifiers · 11. entity extraction · PlanResult · Granularity/BlockKind |
| 4 · Implementations | B → D | 12. NER four lanes · 15. model-backed enrichment · 10. classifiers on every unit |
| 5 · Scale | D | 19. corpus runs · 14. classify a corpus on CPU · 1. gs: and az: |
| 6 · Chunking and blocks | after 1 and 3 | 8. sentence and paragraph · 22. headings and sections · 23. furniture · 24. continuation · 25. diagram blocks · 9. table blocks · 7. name the chunker · 6. selection · 5. size control |
| 6b · Meaning on blocks | after 6 | 26. OCR corrections · 27. defined terms · 28. cross-references · 29. image descriptions |
| 7 · Post-baseline | D | 17. export · 2. recipes · 20. operational maturity · 21. deferred architecture |
| E · Web app | now | 30. run on top, page beside output · 31. hover to output · 32. route header; then the sales page draws a real run |
| Unscheduled | — | R. region production · 4. adopt an Office library · 16. declared language |
Unscheduled
Section titled “Unscheduled”Do not put these on a date. Build each when its named event happens.
- R. region production, the inline badge. “
RegionEvidencestays unproduced until a measurement shows a region changes a route, and inventing one here would publish an address nothing decided.” Phase 1 could produce that measurement; it is not designed to. - 4. measure the Office readers against a library straddles the boundary. The measurement is
Phase 1’s. Whether to adopt
openpyxl,python-docxorpython-pptxis decided by the result, and whatever wins, “the page enumeration may not move” — a library that skips chart sheets would silently disagree with the plan the document was routed under. - 16. the language a format already declares. Deferred on 2026-09-08. It is ADR-0017’s named second detector, so it fires ADR-0026 and owes an ID, an advertisement and a request field in the same change. Its own entry makes it conditional on the statistical detector proving weak on short documents, and the labelled set does not score language detection yet, so nothing has said so. The trigger is that score; until it exists the detector would cost a contract change for a signal no measurement asked for.
What this ordering refuses
Section titled “What this ordering refuses”- Do not start with the NER lanes. They are the most visible item and they depend on both of the first two roots. Built first, the accuracy claim is unfalsifiable — ADR-0035 already says so — and the ports underneath them are still moving.
- Do not ship the port split before attribution. ADR-0027’s ordering is not advisory; it is the reason ADR-0026’s triggers have an order at all.
- Do not take the wire breaks one at a time. That is the single largest avoidable cost here.
Related reading
Section titled “Related reading”- Features — every entry this page sequences
- Decision records — the revisit triggers most of these edges come from
- Refactoring — Phase 0, and the deferred wire-contract list
- Benchmarks — the known gaps, and Root 1
- Core user features and Goals — Root 3