Skip to content

Decision records

The architectural decisions indx has taken and the refusals it has recorded, so neither is re-litigated.

Every entry below is a decision that shaped a contract, a boundary, or a refusal — written down so it is not re-derived, and so a change that reverses one is visibly a reversal.

These records were backfilled in one pass, so the numbers follow the grouping rather than the order the decisions were taken. Each states what it did to POLICY_VERSION and to the capability snapshot ID, which is the only chronology that matters here — the git log holds the rest, one commit per slice. New records append at the end with the next number.

A record is short on purpose. The mechanism is documented once, on the protocol page or in the source it belongs to; a record carries only why that shape was chosen and what it costs. When the two disagree, the code is right and the record is stale — say so by adding a record, not by editing the old one.

One record per ## section, addressable as /developer/decisions/#adr-0007-…. This becomes a directory of pages if it ever outgrows one.

ADR-0001 — The routing ladder and CapabilityKind are closed

Section titled “ADR-0001 — The routing ladder and CapabilityKind are closed”

Status Accepted · no version change

Context. An extension could be accepted, advertised on GET /v1/capabilities, and quietly never reach a route. CapabilityKind is six members, _ESCALATION three rungs, _DEVICE_PREFERENCE four devices, and the signals _ladder() reads are two — and nothing said whether installing a distribution could grow any of them.

Decision. None of them grows by installing a distribution. A kind is a policy position with a price and a fallback place, not a thing to route to: the ladder is the product. A distribution declares the nearest existing kind, inherits its ladder position, and the deployment corrects the numbers through INDX_ROUTING_ECONOMICS.

Consequences. A hosted OCR that is nothing like the first-party one still routes exactly like OCR, which is the guarantee the ladder exists to make. Making an emitted signal meaningfulhandwriting, say — is a first-party policy change with a POLICY_VERSION bump, not an extension point: signals are open in form and closed in meaning. Stated in policy.py’s docstring, on _ESCALATION and _ladder, and on the routing page.

No revisit trigger. Every other record here is provisional in some direction; this one is not. The ladder is the product, and a version of indx whose rungs an install could add is a different product.

ADR-0002 — Economics are core-owned and keyed by kind

Section titled “ADR-0002 — Economics are core-owned and keyed by kind”

Status Accepted · no version change

Context. CapabilityDescriptor declares no cost, latency or quality. Those come from a per-kind table in indx_router/economics.py. A capability could plausibly declare its own.

Decision. It may not. Numbers stay core-owned, keyed by kind, overridable per deployment through INDX_ROUTING_ECONOMICS — and the override is keyed by capability ID as well as by kind, so one wrong install can be corrected without repricing its whole kind.

Consequences. The failure is real and accepted rather than hidden: a hosted OCR charging $0.05 a page inherits the CPU OCR’s numbers, is admitted against maximum_cost_usd on a figure a hundred times too low, and PlanEstimates.cost_usd publishes that to the caller as the plan’s price. Kept anyway, because a self-declared price is what a capability would use to buy its way into a route, and the deployment paying the bill is the only party with a reason to be honest about it.

Revisit when a deployment installs two capabilities of one kind. Kind-keyed numbers are exactly what makes two OCR distributions indistinguishable to every constraint a caller can state — see ADR-0028.

ADR-0003 — Nomination is a parser’s only door

Section titled “ADR-0003 — Nomination is a parser’s only door”

Status Accepted · POLICY_VERSION 0.5.0

Context. PARSER is in neither _ESCALATION nor the rung _ladder() puts ahead of it, so a parser entered a route through exactly one door: _nominations, which needs signature_detection=True, a SignatureDetector, and available=True. The failure was silent — _declared matches manual-review by kind alone for any media type, so a third party could ship an .xlsx parser, have it accepted, and watch the document route to a human queue while the parser sat unused.

Decision. Nomination stays the only door. A parser is only ever correct about a document something recognized first; a capability that generically reads a format declares NATIVE_EXTRACTION, however specialized its machinery. signature_detection keeps its False default, because detection still costs a read of the source.

Consequences. The dead end is loud instead of silent: a media type only a PARSER declares, planned with detection off, is an unsatisfied plan whose constraint names both the parser and the flag, and the executor refuses unsatisfied plans. examples/acme-indx-capability’s reader-plus-parser pairing is the documented shape rather than a workaround. The rung was refused on today’s evidence, not on principle: _declared filters by media type alone, so PARSER ahead of NATIVE_EXTRACTION would send every PDF page through an installed invoice parser, and with output validation presence-only (ADR-0015) a parser returning confident junk would keep the page.

ADR-0004 — Availability is not consulted at plan time

Section titled “ADR-0004 — Availability is not consulted at plan time”

Status Accepted · from the first routing policy

Context. Every installed capability can be unavailable — a missing extra, an unconfigured model, absent weights. Planning could skip those and route only to what can run right now.

Decision. It does not. A plan describes proposed work against the recorded inventory. Whether a capability can run at this moment is an execution question, answered with a 503.

Consequences. Every installed capability was available=False in the first slice and still routable, which is what let the plan artifact and the execution path be built independently. It is also what keeps a plan portable: the same plan is valid on a machine with the extras and on one without, and the difference shows up in the trace rather than in the routing. The readable-media gate still runs first, so an install carrying only manual-review answers 415 before routing rather than sending everything to a human.

ADR-0005 — POLICY_VERSION moves for the artifact, not just the decision

Section titled “ADR-0005 — POLICY_VERSION moves for the artifact, not just the decision”

Status Accepted · POLICY_VERSION 0.5.0 and 0.7.0

Context. The obvious rule — bump when a route changes — is too narrow. Slice 3’s dead-end constraint changed no previously reachable input’s route and did change the plan artifact: status, constraint and plan_id. Slice 17’s region-scope fix changed a decision function that no shipped capability could reach.

Decision. POLICY_VERSION moves whenever the decision or the plan artifact changes, so two callers never receive two different plans under one version. The capability snapshot ID moves with it, because policy_version sits inside the snapshot’s content hash — registry.py excludes only id, limits and resolvable.

Consequences. That second half was learned expensively: one pass bumped the benchmark manifest’s policy_version pins and left its capability_snapshot_id pins alone on the opposite belief, and the next run scored nothing across ten drifted rows. A guard test in indx-benchmark makes the coherence CI’s business — the manifest pins, the committed report and POLICY_VERSION must agree, and a committed report must have scored. The live snapshot ID is deliberately outside that check, because extras legitimately move it and the canonical bench install carries extras CI does not.

ADR-0006 — Entry-point discovery is the registry

Section titled “ADR-0006 — Entry-point discovery is the registry”

Status Accepted · from the first registry

Context. An import-time registry — a metaclass, __init_subclass__, a decorator — is the conventional way to collect plugins, and a TOML or JSON table is the conventional way to declare what they offer.

Decision. Neither. importlib.metadata.entry_points() over the indx.capabilities group is the whole registry, and descriptors() is the whole declaration.

Consequences. An import-time registry has to import every provider module to populate itself, which breaks the cheap-discovery contract that GET /v1/capabilities pays for, breaks the forbidden-import rule in tests/unit/test_workspace_boundaries.py, and turns structural Protocols into nominal base classes a third party must inherit. A declaration file would add a loader and a schema, lose construction-time validation, and still could not express the fields that are computed — available, unavailable_reason, devices. The values that genuinely are deployment configuration already are environment variables. See CapabilityRegistry.

ADR-0007 — A distribution owns its engine stack, and duplication is the price

Section titled “ADR-0007 — A distribution owns its engine stack, and duplication is the price”

Status Accepted · no version change

Context. A plugin may not import a sibling plugin — that is what keeps a distribution installable on its own and keeps the dependency graph in tests/unit/test_workspace_boundaries.py honest. But indx-observer-office counts an Office file’s pages and indx-capability-office-extraction reads them, and page 3 has to mean the same worksheet to both.

Decision. The shared enumeration is duplicated verbatim rather than extracted into a shared package. Three families are paired that way — Office (ooxml.py), text (plaintext.py) and the classifier taxonomy (taxonomy.py) — and a test asserts the copies differ only in the line naming the other distribution.

Consequences. The same reasoning refuses sharing PDF rasterization and text extraction across capability packages: doing so would push pypdfium2 into indx-interfaces, and the DPI defaults legitimately differ per lane. The cost is real copies that must not drift, and the drift test is what makes that a rule rather than a hope. plaintext.py reaches its settings through a relative import for the same reason — an absolute one would name a different package in each copy, and the two would stop being one rule.

ADR-0008 — Observing a format and reading it are two distributions

Section titled “ADR-0008 — Observing a format and reading it are two distributions”

Status Accepted · snapshot ID moves, POLICY_VERSION does not

Context. indx-observer-office and indx-capability-office-extraction could plainly have been one package. So could the PDF pair.

Decision. Two. What makes a format plannable and what makes it routable are two installs.

Consequences. An operator can have either without the other, which is the point: an installation that plans a format it cannot read gets an honest ladder descent to manual-review rather than a 415, and one that can read a format it cannot observe is simply never asked. It also keeps the free rungs free — office-extraction and text-extraction carry no engine and no extra, so a default install can read what it observes for those families. The cost is a second pyproject.toml per format family and the duplication ADR-0007 accepts.

ADR-0009 — A stock install observes nothing and resolves nothing

Section titled “ADR-0009 — A stock install observes nothing and resolves nothing”

Status Accepted · no version change

Context. Moving PDF observation, file:/http: loading, media-type sniffing and chunking out of core and into distributions each removed a built-in path. Keeping a first-party fallback beside each port was the safer-looking option.

Decision. No fallback. A stock pip install indx observes no format, resolves no URI, recognizes nothing from bytes, and returns no chunk blocks. Inline base64 still works with nothing installed, which is the honest line: indx resolves no URI it was not taught to resolve, and always accepts bytes handed to it directly.

Consequences. The built-in path and the extension path are one path, so the drift these ports were built to prevent cannot happen — a shared function would not have prevented it, because the shared function was still the only way in. It also made two things load-bearing that a core floor would have hidden: chunker selection is per page rather than per document, and ordering needed a rule beyond builtin, which is what fallback = True on indx-chunker-page is. The cliff is the same one capabilities always had — indx depends on no indx-capability-* package either.

ADR-0010 — resolvable and classifiers sit outside the snapshot’s content hash

Section titled “ADR-0010 — resolvable and classifiers sit outside the snapshot’s content hash”

Status Accepted · snapshot ID measured identical before and after

Context. The capability snapshot ID is a content hash, and a plan is bound to it. Advertising newly installed loaders, observable media types or classifiers inside that hash would answer every plan in flight with a 409.

Decision. snapshot.resolvable and snapshot.classifiers are advertised and excluded from the hash, following the precedent limits.max_input_bytes already set. registry.py excludes id, limits and resolvable by name.

Consequences. Gaining a scheme, a recognizable media type or a classifier widens what can be planned next without changing what any outstanding plan selected. Two named exceptions rather than one: limits is deployment configuration, resolvable is what installing a distribution adds — both widen, neither re-decides. The measured claim from slice 9: the snapshot ID was sha256:9ac3558… before and after, so the benchmark’s pins held and POLICY_VERSION stayed put. A classifier is enabled per request, so installing one moves no plan either.

ADR-0011 — Installed sorts ahead of built-in; duplicates are first-wins and quiet

Section titled “ADR-0011 — Installed sorts ahead of built-in; duplicates are first-wins and quiet”

Status Accepted · no version change

Context. Observers, loaders and chunkers declare no ID, so there is nothing to collide on the way duplicate capability IDs collide. Two of them claiming one media type or one scheme needed an answer, and so did whether an extension outranks a shipped implementation.

Decision. Installed distributions sort ahead of the ones indx ships, which set builtin = True and are stable-sorted after. Two installed entries claiming the same thing are resolved by discovery order, first-wins and quiet. indx-chunker-page sets fallback = True and sorts after everything.

Consequences. Discovery order still decides between two extensions and never decides between an extension and a shipped one, which is the reversal that needed a rule rather than luck. builtin and fallback are off-protocol markers read with a False default and verified by nothing — a third party claiming either can only cost itself precedence, so a gate would buy nothing. media_types on an observer is advertisement and never a gate for the same reason: observe still decides, because two sources of truth for one question is what a gate would create.

Revisit when any second implementation of any ID-less port appears; that is the trigger ADR-0026 states. The observer, sniffer and loader cases rank ahead of the rest, because their answers change the plan artifact rather than the output — ADR-0027.

ADR-0012 — Chunking is a port asked after reading

Section titled “ADR-0012 — Chunking is a port asked after reading”

Status Accepted · POLICY_VERSION unchanged; chunkers reach no plan

Context. blocks.build minted one chunk per page and said so in place: split on real structure once something downstream has an opinion about size. Two shapes were available — a channel on the capability that already knows the boundaries, or a separate port applied uniformly.

Decision. The port. A boundary is an opinion about retrieval, not about reading. It is asked once per encode, after every reader has produced its pages, so one implementation can draw boundaries for a document whose pages different capabilities read. Block IDs stay the executor’s to mint: a chunker answers PageChunks of ChunkPieces and page:N/chunk:M is derived by the one thing that can see the whole document.

Consequences. A capability-supplied channel would have tied boundary quality to whichever rung happened to win each page. Selection is per page, so the PDF chunker can decline a scanned page OCR read and hand it to the floor. A chunker that raises is logged and skipped rather than failing the encode — unlike an observer’s raise, it is a verdict about the chunker, not the source, and the text it was asked to cut already exists. See Chunker.

Revisit when a second non-fallback chunker exists, or a consumer states an opinion about boundary size. Either is the trigger for a chunker id, an advertisement, and a request field, in one change.

ADR-0013 — A chunker may claim an unread page only with an image

Section titled “ADR-0013 — A chunker may claim an unread page only with an image”

Status Accepted · POLICY_VERSION 0.8.0

Context. chunk_map kept only pages whose reader returned text. That filter was doing more than it looked like: it was also what stopped a chunker from asserting content for a page the ladder gave up on. Removing it to let a rendered page become a chunk removed that guard too.

Decision. A chunker may claim a page no reader could read, but only with an image. Text is refused. ChunkPiece carries text or image, exactly one, neither empty — the old rule moved onto the model rather than living as a filter.

Consequences. A page’s text is a reader’s verdict: it came down the plan’s ladder, past output validation, with a capability ID and a trace event attached. Text asserted by a chunker for a page nothing read would launder an unvalidated read into the result with none of that. Pixels claim nothing — they are the page — so they are admitted where invented text is dropped. Found by a test that failed for the right reason.

ADR-0014 — Document embedding is (text, image), text first

Section titled “ADR-0014 — Document embedding is (text, image), text first”

Status Accepted, superseding a refusal · POLICY_VERSION 0.6.0, then 0.8.0

Context. Slice 16 made document embedding text-only and stated it in one place, DOCUMENT_EMBEDDING_MODALITIES, so the router’s fault and the executor’s selection read one statement rather than spelling the same lane twice. The render lane was refused then for three named costs. Slice 18 found the case that paid them: a page nothing could read produced no chunk and therefore no vector at all, leaving a document block, a page block, and nothing to retrieve on.

Decision. DOCUMENT_EMBEDDING_MODALITIES is (TEXT, IMAGE) — text for every chunk that has any, and one rendered page for a page a reader reported failed or unreadable. The order is the refusal of the third reading of “embed the images in my documents”: a page that was read never also pays for a render to duplicate what was extracted from the same pixels.

Consequences. The measurement came first and is committed as test_clip_compatibility.py rather than described: over two admitted benchmark documents, each page’s image vector answers its own visual query better than that page’s text vector does, and on the 富山県 drawing the text lane points the wrong way. The render belongs to indx-chunker-pdf, not the executor, which is what kept the change small — chunking touches no plan, so the router prices nothing new, and pricing an EMBEDDER in economics.DEFAULTS would silently have made one nominable as a page reader, since _nominations reuses that table as the routable-kind set. A blank page is not rendered: it was read successfully.

Revisit when a caller wants a page image beside its text rather than only instead of it. The tuple is a preference order, and a preference is exactly the kind of thing a request can state — the refusal here is of the default, not of the capability.

ADR-0015 — Output validation is first-party, and its floor ships off

Section titled “ADR-0015 — Output validation is first-party, and its floor ships off”

Status Accepted · POLICY_VERSION unchanged; this changes execution, not a plan

Context. dispatch._missing asked one question — did a page come back at all — and said so in place: presence is the whole quality gate. Fallback fired on absence or an exception and never on bad output, which meant PageOutput.status and reason were being written by capabilities and read by nobody: a declared FAILED counted as an answer, kept the page, and stopped the ladder.

Decision. Validation is first-party, in indx_executor/validation.py, not a declared port. Three checks, ordered by how much each assumes: absence assumes nothing; a FAILED status assumes nothing, since the capability said so itself; a self-reported confidence under the floor assumes a number, which is why INDX_VALIDATION_MIN_CONFIDENCE defaults to 0.0 and the check is off.

Consequences. A declared port would have been an entry-point group, a protocol and a discovery path built for nobody — no third-party validator exists to be blocked, and the checks are the same three for every reading kind. UNREADABLE is deliberately not a refusal: it is a verdict about the content rather than the attempt, manual-review is its only producer, and failing over from the terminal rung would answer every scanned page on a default install with a 503. The floor ships off because nothing here has measured a threshold against a labelled corpus — and the first thing the confidence number measured was its own limit: PP-OCR scores the benchmark’s one handwritten page above two printed pages in the same document, so a floor set to catch the handwriting would refuse the print first.

Revisit when a distribution needs to bring its own validator — the module’s own docstring already names that trigger and says the port replaces this file rather than growing around it. This is also the one place ADR-0026’s dedicated-distribution rule is deliberately not followed, and it is an exception rather than an oversight.

ADR-0016 — REGION granularity is refused, not silently dropped

Section titled “ADR-0016 — REGION granularity is refused, not silently dropped”

Status Accepted · POLICY_VERSION unchanged at 0.9.0

Context. Granularity.REGION, BlockKind.REGION and Block.bbox are all public contract, and blocks.build tested only for PAGE and CHUNK. A caller asking for granularities: ["region"] got a 200 carrying a document block and no regions, with nothing saying why — and could not tell “this install draws no regions” from “this document has none”.

Decision. Refuse it. UnsupportedGranularityError is a 422 unsupported_granularity raised before the source is loaded, so a request nothing can answer costs no fetch, and its message enumerates what this installation does produce. Producing regions stays deferred until a measurement shows a region changes a route.

Consequences. The deferral was defensible and the silent no-op was not: a request for something the contract offers should be answered or refused. Granularity.REGION stays in the enum — removing it would move openapi.json and the generated client for a member the contract still intends to honor. BlockKind.REGION, RegionId, RegionEvidence and ExecutionActuals.gpu_regions stay producerless, and the refusal is what makes that visible rather than what fixes it. A sibling of EmbeddingSpaceError rather than of UnsupportedMediaError: a 415 is about the bytes that arrived, and this is about the shape of the answer asked for.

ADR-0017 — Language detection is a port asked with text, not an observer

Section titled “ADR-0017 — Language detection is a port asked with text, not an observer”

Status Accepted · POLICY_VERSION unchanged at 0.9.0

Context. language_hint sat on the router-private PreflightContext from the second slice onward and was never assigned. Nothing on that side of the system could ever have filled it: preflight does not decode content, and a language is a fact about characters.

Decision. A new port, LanguageDetector, cloned from Chunker — asked after reading, with text, changing execution output rather than a routing decision, naming no ID, joining no descriptor, advertised nowhere. language_hint is deleted rather than finally assigned.

Consequences. The answer reaches blocks and never the plan, which is why the snapshot ID is byte-identical before and after. A port rather than an import because first-party code may not import a plugin module and lingua belongs in its own distribution behind the lang extra; without it a block carries no languages key at all. The document answer is the mean of its pages weighted by the length of the text each score was computed over — a flat mean lets a six-word title page outvote a chapter, the same weighting generic-ocr’s confidence already needed. The declared language a format states (OOXML dc:language, a PDF catalog /Lang) is a second detector this port now makes possible, not a reason to have skipped the statistical one.

Revisit when the declared-language detector ships: that is the second implementation, and the resolution is per page rather than per document, so one document could legitimately be answered by two detectors with nothing saying which answered where.

ADR-0018 — A classifier runs only when the request names it

Section titled “ADR-0018 — A classifier runs only when the request names it”

Status Accepted · POLICY_VERSION unchanged at 0.9.0

Context. DocumentClassifier is cloned from LanguageDetector and differs in one way that shapes everything else: a detector is free and runs whenever installed, and a classifier costs a call — a model pass, a token, an off-box request.

Decision. Nothing runs that the request did not name. EncodeRequest.classification enables installed classifiers by ID, in the order they are asked, and the first with an opinion wins each facet. It is not a capability kind, for ADR-0001’s reason: a label routes nothing.

Consequences. Because a request names it, a classifier carries an id — the one thing a chunker and a detector do not — advertised on snapshot.classifiers outside the hash (ADR-0010). An unknown ID is a 422 unknown_classifier naming the ones that are, raised before the source is fetched. Residency is a refusal, not a skip: an external classifier under data_residency is a 422 rather than quietly omitted, because skipping would be an answer the caller believes was given and was not. Facets are free-named and no facet is enumerated anywhere in first-party code. See DocumentClassifier.

No revisit trigger — this is the template. A classifier already has the id, the advertisement outside the hash, and the request field that ADR-0026 says a second implementation obliges. Every other port that reaches that trigger should end up shaped like this one.

ADR-0019 — Caller metadata is on encode and deliberately not on plan

Section titled “ADR-0019 — Caller metadata is on encode and deliberately not on plan”

Status Accepted · POLICY_VERSION unchanged at 0.9.0

Context. A caller’s own labels — an owner, a tenant, a sensitivity classification — have to survive the round trip onto the document block. Putting the field on both requests was the obvious symmetric shape.

Decision. metadata is on EncodeRequest alone. So is classification.

Consequences. plan_id is the hash of every field of the plan it is decided into — policy.py says so deliberately, so a field added later joins the hash instead of being silently left out. A tenant label routes nothing, so putting it on PlanRequest would give two identical documents two different plans, or need a second carve-out beside request_id. indx carries the labels and enforces none of them: who may read a vector afterwards is the index’s question. The reserved keys languages and classification are refused rather than overwritten, because a label silently replaced is a label the caller believes travelled and did not.

ADR-0020 — Configuration is a settings model per distribution

Section titled “ADR-0020 — Configuration is a settings model per distribution”

Status Accepted · snapshot ID measured identical before and after

Context. Twelve hand-rolled INDX_* readers, five copies of the same read-parse-bound-or-raise routine, and three message formats for the same failure — “must be positive”, “must be at least”, “must be a number”. A third-party capability had no namespace to claim and no way to say what it needs. An env_int/env_float helper pair was the proposed fix.

Decision. Not a helper — a declaration. IndxSettings in indx-interfaces carries the shared model_config and a load() that rebuilds every message from the field location and the reason alone; each distribution declares its own subclass with its own env_prefix, in its own settings.py and nowhere else. INDX_<VENDOR>_* is the convention for a third party, and an unavailable descriptor’s unavailable_reason is where a capability names the variable it is missing.

Consequences. A helper would have been a fourth spelling of what pydantic-settings already does, and indx-loader-s3 was already written against it — so the shape was hoisted rather than invented. Messages are one format now, a credential cannot travel into a 422 a caller reads, and “what can this be configured with” is one file per distribution rather than a grep. os.environ and os.getenv are banned by Ruff so a new reader has to start there. Two exceptions are deliberate and marked in place: INDX_LOADER_HTTP_ALLOW_PRIVATE_HOSTS keeps its own truthy set so an unrecognized value leaves the guard up rather than raising, and INDX_ROUTING_ECONOMICS is a Path over a file still validated entry by entry.

ADR-0021 — No OOXML library, and no converter tier

Section titled “ADR-0021 — No OOXML library, and no converter tier”

Status Accepted · snapshot ID moves, POLICY_VERSION does not

Context. An Office file is a zip of XML. openpyxl, python-docx and python-pptx are the obvious dependencies, and markitdown, unstructured, docling and Tika read far more than three formats.

Decision. zipfile and xml.etree are the whole engine, with no extra to install a library behind. The converter tier is refused outright: each brings its own routing, its own chunking and often its own model, and installing one would put a second router inside this one.

Consequences. office-extraction is a free rung every other rung falls back from, so putting it behind an extra would leave a default install able to observe a format it cannot read. Two standard-library facts became load-bearing rather than incidental: xml.etree expands internal entities, so a part carrying a DTD is refused before it is parsed — ECMA-376 forbids one in an OOXML part, which makes the guard a format rule rather than a workaround — and the input ceiling bounds the compressed source while a zip promises nothing about what it expands to, so each part is read against a flat cap. One ceiling is marked rather than paid for: a cell comes out as its stored value, so a date is Excel’s serial number. Nobody has compared the text this produces against what the libraries produce, and that measurement is planned, not done. If a library ever wins it goes behind an extra or into a second distribution — never as a plain dependency — and it adapts to this page enumeration rather than bringing its own.

ADR-0022 — Media-type recognition is an observer’s, over whole content

Section titled “ADR-0022 — Media-type recognition is an observer’s, over whole content”

Status Accepted · POLICY_VERSION unchanged at 0.8.0

Context. indx_source.media_type recognized three magic-byte prefixes and both call sites passed data[:16], so even a longer signature added later could not match. A first-party type was recognized from its bytes over a client that lied about it; a third-party type was only ever recognized from the client’s assertion or a filename, which the module’s own docstring calls the weakest signal it has.

Decision. sniff on the SourceObserver — the observer, not the loader, because an observer is the thing that parses the format and indx-loader-file declaring %PDF- would be knowledge in the wrong distribution. It is given the whole content, not a head, so there is no second length to keep in step with anything. A method rather than a table, because .xlsx, .docx and .pptx all begin PK\x03\x04 and only the zip’s member names at the end of the file separate them.

Consequences. It is sniff and not “signature”: SignatureDetector and signature_detection already own that word for recognizing a document type, and the two are one layer apart. Text and email decline to offer one, which is an answer rather than an omission — a .txt, a .csv and a .tsv are the same characters with different separators, and “does this decode as UTF-8” would claim JSON, XML and HTML on the way past, ahead of the declared type that is actually right. A raise from sniff is logged and skipped, the opposite of a raise from observe, because the declared type and the filename are still waiting below. Nothing was added to the snapshot: resolvable.observable_media_types already reports what an installed observer looks at, and a sniffer that recognized a type it does not observe would only convert a caller’s 415 into a 422.

Revisit when a second sniffer recognizes one format. This ranks with ADR-0027 rather than with the chunker cases: the winner decides the media type the plan is bound to, so the wrong first-wins is a wrong route rather than a coarser chunk.

ADR-0023 — The deadline is cooperative, not preemptive

Section titled “ADR-0023 — The deadline is cooperative, not preemptive”

Status Accepted · no version change

Context. INDX_REQUEST_TIMEOUT_SECONDS and client disconnects both need to stop work that has run too long, and neither can interrupt a capability mid-call without a thread or process boundary around every reader.

Decision. Nothing cancels a running call. Deadline lives in indx_interfaces.context as a contextvar, and check_deadline() is called from Router.plan once the source has loaded, before each capability attempt in the executor’s ladder, and before each embedder call — never inside a reader.

Consequences. A capability already running when the ceiling passes finishes rather than being interrupted, which is marked with a ponytail: comment naming the ceiling. Past the deadline a request answers 504 request_timeout; a client disconnect trips the same mechanism from the other direction, through a watcher polling request.is_disconnected(), which only works because facade calls run in a thread pool and leave the event loop free to poll. There is no Retry-After on a 503, because nothing here tracks a recovery estimate to report.

ADR-0024 — One synchronous facade, no async twin

Section titled “ADR-0024 — One synchronous facade, no async twin”

Status Accepted · no version change

Context. The HTTP adapter is async and the CLI is not. An async facade beside the synchronous one is the usual answer.

Decision. The facade stays synchronous. The server reaches it through a thread pool.

Consequences. One surface both transports share, and the thread pool is what leaves the event loop free to poll for client disconnects (ADR-0023). The cost is a thread per in-flight request, which is the right trade while the backbone is synchronous and stateless and nothing has measured a throughput ceiling.

ADR-0025 — Which layer a choice belongs to

Section titled “ADR-0025 — Which layer a choice belongs to”

Status Accepted · no version change

Context. Three places a choice can live already exist and all three are in use, but the rule for picking between them was never written down. The result is sixteen selection points of which exactly two — classification.classifier_ids and its sample — are things a caller can state. The rest resolve by discovery order, list position or the alphabet, and no INDX_* variable reorders any of them.

Decision. A choice belongs to policy when it is what indx is willing to pay for and in what order (ADR-0001); to a deployment setting when it varies per install — what this box can reach, afford or decode; and to a request field when it varies per document or per caller, because the caller is then the only party who knows. Nothing is silent by default: where a request may not choose, the reason is one of the first two, stated.

Consequences. Two existing constraints decide the shape and neither is negotiable. plan_id hashes every field of the plan it is decided into, deliberately, so a field that routes nothing must stay off PlanRequest — the rule metadata and classification are already written against — while one that does route joins the hash on both requests, which is what embedding_space_ids does. And a request can only name what the snapshot advertises, so advertisement is a prerequisite rather than a follow-up: classifiers is the worked template, an ID advertised for naming and excluded from the content hash, enforced with a 422 that enumerates what is installed. Together these say, for any candidate, whether making it selectable is free or costs a POLICY_VERSION bump.

ADR-0026 — Dedicated logic in a dedicated distribution, and what obliges an ID

Section titled “ADR-0026 — Dedicated logic in a dedicated distribution, and what obliges an ID”

Status Accepted · no version change

Context. Nine ports and fifteen capability distributions later, the shape has held: one concern, one package, reached through an entry point and never imported. What the records did not say is when that shape obliges anything more than a package — an identity, an advertisement, a way for a caller to ask for one implementation rather than another.

Decision. Dedicated logic lives in its own distribution unless coupling forbids it. Where it does forbid it, the exception is recorded rather than quietly taken: a plugin may not import a sibling, so a shared enumeration is duplicated verbatim and drift-tested (ADR-0007); a rule both siblings already depend on belongs in indx-interfaces below them instead; and a seam with no third-party implementation to serve stays first-party until one exists (ADR-0015). A port with exactly one implementation may resolve competition by order. The second implementation is what obliges an id, an advertisement on the snapshot, and a request field — and whoever ships it owes all three in the same change.

Consequences. This is a trigger, not a backlog. Designing a tiebreak ahead of the tie is guessing, and the shape the guess would take is already known from the classifier, so there is nothing to discover by building it early. What changes is that the second chunker, the second language detector, the second observer for one media type now arrive with an obligation attached instead of a silent first-wins. It also names the cost of ignoring the rule: a distribution that adds a second implementation without an ID makes the system’s behaviour depend on install order, which is not a property anyone can test for or reproduce.

ADR-0027 — Every component that answered is named in the output

Section titled “ADR-0027 — Every component that answered is named in the output”

Status Proposed · would not move POLICY_VERSION

Context. Observer, sniffer and loader resolve first-wins with no ID, no duplicate check, and nothing anywhere that says which distribution answered. Two observers disagreeing on page count produce two different plans for one document; two loaders claiming one scheme could produce two different source_digests. Unlike a chunker’s, these choices change the plan artifact and the identity the plan is bound to, so this is not a matter of taste.

Attribution today is one component deep. TraceEvent carries planned_capability_id and actual_capability_id, so a route that fell to a fallback is fully attributable — and that is the only component with a planned side to compare against and the only one attributed at all. Block.provenance names the capability that read a page, so a chunk block reports native-extraction, the reader, and never indx-chunker-pdf, the distribution that decided where the chunk begins. The loader, the sniffer, the observer, the language detector and the per-facet winning classifier appear in no response.

Decision. The output names every component that participated. Attribution is a property of the run rather than of the decision, so it rides ExecutionTrace — beside events, which is already the “what actually happened” surface and already carries plan_id for correlation — as a components block giving each participant’s role, distribution, and what it answered for.

Consequences. It must not ride RoutePlan. plan_id hashes every plan field precisely so a field added later joins the hash instead of being silently left out, so observed_by there would make installing an observer invalidate every outstanding plan — the 409 storm ADR-0010 refuses for resolvable. media_type is on the plan because an observer’s output is what the routes were decided against; the observer’s identity is diagnostics, and they are not the same field.

plan() returns a bare RoutePlan with no wrapper, so there is nowhere on the plan response to put this: plan-side attribution needs a PlanResult wrapper, which is a wire-contract move recorded with the other deferred ones, and until then only encode can say which observer looked at a source. That matters more than it sounds. A planless encode loads and observes its source twice, once in the executor and once inside planner.plan; naming the loader and the observer on both sides is the only way a disagreement between the two passes would ever become visible.

And the ordering this settles: 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.

Revisited 2026-09-08. The encode side shipped: ExecutionTrace.components names the loader, sniffer, chunkers, language detector and winning classifiers by distribution, which discovery reads off the entry point that registered each provider and the registry answers for by object identity. One correction to the context above: execution never observes. A supplied plan was decided from an observation that already happened, and a planless encode observes inside planner.plan, so encode cannot say which observer looked at a source either. The observer is plan-side by construction. The PlanResult wrapper landed the same day: plan() returns the hashed plan under plan and the loader, sniffer and observer beside it, and a planless encode carries the planner’s observer onto its trace, so both passes of a planless encode are named and a disagreement between them is visible.

ADR-0028 — Two capabilities of one kind are ordered by ID

Section titled “ADR-0028 — Two capabilities of one kind are ordered by ID”

Status Accepted, with a known flaw · no version change

Context. _declared sorts by capability.id and _assignment takes candidates[0] as selected. So when two OCR distributions both declare a media type and both pass _admit, the alphabet picks the winner: acme-ocr beats generic-ocr for no reason connected to quality, cost or latency, and _reasons gives both the same rung, so the plan carries no signal that a tiebreak happened at all.

Decision. Keep the sort. It is load-bearing for a different reason than ranking, and signatures.py states it: two installs with the same inventory must ask in the same order, or plan_id stops being deterministic when two parsers both match. A tiebreak that varied with discovery order would be worse than one that varies with the alphabet.

Consequences. The unstated consequence is that the same sort also ranks, and the alphabet is not a quality signal. No RoutingConstraints field can separate the two, either, because economics.DEFAULTS is keyed by kind — both carry identical numbers, so an operator’s INDX_ROUTING_ECONOMICS can knock one out entirely but cannot reorder two survivors. Two honest exits exist: per-capability economics that actually differ, which the override key already supports, or a declared preference order. Neither is built, because no deployment yet installs two capabilities of one kind — and that absence is exactly why the kind-keyed table has not hurt. The escape hatch that does exist is a supplied plan, which names capability IDs per scope and which the executor honours verbatim.

ADR-0029 — Classification splits into five ports on unit and return shape

Section titled “ADR-0029 — Classification splits into five ports on unit and return shape”

Status Proposed · would not move POLICY_VERSION; EncodeRequest fields move, so openapi.json and the generated client do

Context. DocumentClassifier was asked to carry named entity extraction and cannot. Three things break at once, and each alone would be enough: a classifier is deliberately handed a truncated sample, and entity extraction over a truncated sample is silently wrong rather than merely partial; LabelScore is {label, confidence} with nowhere to put a character span; and the unit of the answer is the document, while an entity belongs to the page or chunk it was found in. Widening the one port to cover all of it produces a contract whose return type means different things depending on how it was called, which is the shape that gets misread.

Decision. Two axes, not one. The unit handed in — document, page, chunk — and the return shape — labels or spans. That is a grid, and five of its six cells ship: DocumentClassifier, PageClassifier and ChunkClassifier answer Mapping[facet, tuple[LabelScore, ...]]; PageEntityExtractor and ChunkEntityExtractor answer Mapping[label, tuple[EntitySpan, ...]]. The sixth is deliberately empty: pages already tile a document with no gaps, so a whole-document extractor would window its input internally to do what the page port does for free.

Each port inherits ADR-0018 whole, which that record’s closing line invites — an id, an advertisement outside the content hash (ADR-0010), a request field, and nothing running that the request did not name. IDs are one namespace across all five, so the registry’s duplicate rejection is cross-port: a page classifier and a chunk extractor may not share a string.

Consequences. Discovery still discriminates, but not where it looks like it does. @runtime_checkable isinstance tests attribute presence, so five same-shaped classifier protocols are mutually indistinguishable — registry._declared is safe only because it tests the provider, and page_classifiers(), chunk_classifiers(), page_entity_extractors() and chunk_entity_extractors() are four distinct method names. One class may be advertised through two hooks, which is how an implementation serves two units without duplicating itself.

Three refusals, all before the source is fetched, all 422: an unknown ID, enumerating what is installed; an external implementation under data_residency, refused rather than skipped for ADR-0018’s reason; and a chunk-targeted implementation named without CHUNK granularity, which is a contradiction rather than an empty result — the same shape ADR-0016 gives a request asking for a granularity nothing produces. Request fields name the unit as well as the ID, which is redundant against the global namespace and deliberately so: an ID in the wrong field is a precise refusal instead of a lookup miss.

Revisit when a sixth cell is asked for — a unit that is neither page nor chunk, which is what BlockKind.REGION would become if it ever gained a producer, or a return shape that is neither labels nor spans: a relation, a table, a redaction box. The empty fifth cell has its own trigger, and it is narrow: an entity that only whole-document text could support.

ADR-0030 — An entity span is a character offset into the text of the block that names it

Section titled “ADR-0030 — An entity span is a character offset into the text of the block that names it”

Status Proposed · would not move POLICY_VERSION

Context. A span has to be resolvable by whoever receives it, and the obvious anchor is wrong. Offsets into whatever string the implementation was handed address nothing if that string was assembled — classification.sample joins a truncated selection of pages with "\n\n", and an offset into that indexes a document that exists nowhere. The alternatives were an offset table shipped beside the entities, or the executor rewriting offsets into document coordinates.

Decision. EntitySpan carries the surface text, a [start, end) character range, and a confidence on the same self-report footing as LabelScore. The range indexes the text field of the block the entity names, and nothing else. Every block kind already carries text, so a page span indexes the page block’s text and a chunk span the chunk block’s, uniformly, and the client resolves either against data it already received.

Consequences. No offset table, no coordinate rewriting, no new wire field — the anchor is a block ID the response already contains. It also settles what cannot be done: a truncated or assembled input cannot carry spans, because a sample is not any block’s text. That is the rule rather than an artifact of removing sampling (ADR-0033); an implementation that samples internally must still answer in its input block’s coordinates. Character offsets rather than bytes is not a neutral choice either: Japanese in UTF-8 makes the two differ by a factor of three, and a client reading text in Python or JavaScript indexes characters.

Revisit when a chunker hands back text it rewrote — normalized whitespace, rejoined hyphens, a stripped running header — so the offset no longer indexes the text the block carries. indx-chunker-pdf merges PDFium runs into lines today, and that merge is where this breaks first.

ADR-0031 — Entity results ride the document block, and chunk blocks stay inert

Section titled “ADR-0031 — Entity results ride the document block, and chunk blocks stay inert”

Status Proposed · would not move POLICY_VERSION

Context. Per-chunk extraction wants a per-chunk home, and there is none. ChunkPiece has three fields — text, image, bbox — and no metadata; blocks.build threads document_metadata and page_metadata and has no chunk channel; and the absence is asserted, not incidental: test_executor.py pins metadata == {} on a chunk block under the words a chunk is a cut of a page and inherits nothing. Opening that channel is a small diff and a large decision, because chunks are the block kind that multiplies fastest.

Decision. The channel stays shut. Entity results are written to the document block’s metadata under a third reserved key, entities, keyed by the block ID each entity was found in. Chunk blocks keep carrying text, bbox, embeddings and provenance, and nothing else.

Consequences. An entity already has to name its block to be resolvable (ADR-0030), so storing it on that block as well is redundant, and the redundancy would cost per-chunk dictionary allocation on documents where chunks number in the thousands. The price is that a consumer reading one chunk block in isolation sees no entities and must look at the document block — acceptable, because the aggregation (ADR-0032) lives there anyway and a caller wanting entities is already reading it. entities joins languages and classification in RESERVED_METADATA_KEYS, so a caller supplying it on EncodeRequest.metadata is refused (ADR-0019).

Revisit when a second annotation wants a per-chunk home — per-chunk languages, per-chunk classification. One annotation does not pay for opening a metadata channel on the block kind that multiplies fastest; two do. This is ADR-0026’s second-implementation trigger applied to a storage location rather than to a port.

Revisited 2026-09-08. The second annotation arrived with the port split: a chunk classifier’s answers. The channel stays shut anyway, and the reason is the one above rather than a new one. Both annotations already name their block to be resolvable, so one block-keyed map on the document block serves both – chunk_classification beside entities – with no per-chunk dictionary on the block kind that multiplies fastest. The trigger that would open the channel is narrower than this record first put it: a consumer that reads one chunk block in isolation and needs an annotation on it, which nothing does yet.

ADR-0032 — Document entities are counts over normalized surface forms, not identities

Section titled “ADR-0032 — Document entities are counts over normalized surface forms, not identities”

Status Proposed · would not move POLICY_VERSION

Context. “Entities with counts at the document level” needs an identity rule before it means anything, and the three candidates are not variations of one idea. Raw surface form makes 「ABC株式会社」 and “ABC株式会社” two entities, which is wrong in a way every Japanese document exhibits. Full coreference makes 「山田太郎」, 「山田」 and “Yamada Taro” one entity with a count of three, which is a research problem and a different feature. Between them sits normalization.

Decision. The document block’s entities carries a count per (label, NFKC-normalized surface form), with the raw surface forms kept beside the count rather than discarded. Coreference is out of scope and stays out: a count over a normalized string is deliberately not an identity, and nothing here resolves mentions to entities.

Consequences. NFKC is one standard-library call and it earns its place on Japanese alone — full-width and half-width renderings of one company name are the same company, and no amount of model quality fixes a merge that never happens. It is also the first use of unicodedata anywhere in this workspace, so it belongs in the shared helper (ADR-0034) rather than in each implementation, where three copies would normalize three ways. Keeping the raw forms is what makes the normalization inspectable: a caller who disagrees with a merge can see what was merged. And naming the refusal matters more than the rule — a count that looks like an identity is exactly what a consumer will treat as one unless the contract says otherwise.

Revisit when a caller asks which mentions are the same entity. The answer then is a resolver as its own port, not a widening of this one; a linker exists precisely because a normalized string is not an identity, and pretending otherwise here would make the wrong answer harder to notice.

ADR-0033 — Sampling leaves the contract and becomes the implementation’s

Section titled “ADR-0033 — Sampling leaves the contract and becomes the implementation’s”

Status Proposed · POLICY_VERSION unchanged; a removal from EncodeRequest, so openapi.json and the generated client move

Context. TextSample bounds what a classifier sees, in the contract: a page-selection strategy, two contract-hard ceilings, five INDX_CLASSIFICATION_SAMPLE_* variables, an executor function that joins the selection, and a CLI flag. It made sense when one port existed and its implementations were three. It does not survive the split. The bound is stated in pages, which means nothing to a chunk-targeted implementation; the ceilings make a whole document literally inexpressible, so the largest thing a classifier can be shown is fifty pages; and the joined output cannot carry spans at all (ADR-0030). Worse, the shape assumes every implementation wants the same bound, when a word-list scan wants none, a transformer wants a token window, and a hosted model wants a cost ceiling.

Decision. Sampling leaves the contract entirely. TextSample, ClassificationRequest.sample, MAX_SAMPLE_PAGES, MAX_SAMPLE_CHARACTERS_PER_PAGE, the five INDX_CLASSIFICATION_SAMPLE_* variables, the executor’s sample() and --classification-sample all go. An implementation is handed the full text of its unit and samples for itself if it needs to, configured by its own settings model (ADR-0020), using a dependency-free helper in indx-interfaces (ADR-0034).

Consequences. This reverses a plan, and says so rather than quietly dropping it: the DocumentSampler port — sampling as a port chosen per classifier — is withdrawn, and the Planned entry that proposed it is rewritten in place to record the withdrawal rather than deleted. It also narrows ADR-0025, which counted “exactly two” caller-statable selection points, classification.classifier_ids and its sample; there is now one. That record is not edited — this one supersedes the count, per this file’s own rule.

The cost is real and lands on the implementations. A hosted classifier handed a two-hundred-page document and no bound will spend a caller’s money, so every non-trivial implementation now owes a sampling default it did not previously need, and the shipped three acquire one in the same change. The benefit is that each one can be right: a token window where there is a token window, a cost ceiling where there is a bill.

Revisit when two implementations sample identically enough that the duplication, rather than the coupling, is the cost. That is when the helper becomes a port — the same trigger ADR-0026 states, applied to sampling rather than to a capability.

ADR-0034 — indx-interfaces may carry dependency-free behaviour

Section titled “ADR-0034 — indx-interfaces may carry dependency-free behaviour”

Status Proposed · no version change

Context. Sampling and NFKC normalization are both wanted by implementations that may not import one another, and the workspace has exactly one precedent for that situation: duplicate the module verbatim and drift-test it, as taxonomy.py is duplicated three ways across the classifiers (ADR-0007). Applying it here would put a fourth and fifth copy of two behaviours in every annotation distribution, and three subtly different notions of what the model saw is precisely what makes two implementations non-comparable.

Decision. indx-interfaces may carry behaviour, fenced. The fence is: standard library only, no state, no I/O, no INDX_* read. A function that meets all four is a rule the packages below already share rather than an implementation, and belongs beneath them — ADR-0026 already names that exception, this record gives it an edge.

Consequences. It reverses two written statements. protocols/index.md’s shared rule “Implementations live outside indx-interfaces, preserving the workspace’s dependency direction” gains an exception clause, and CLAUDE.md’s description of the package as *“leaf Pydantic contracts

  • structural Protocols”* stops being complete. The dependency direction itself is untouched: everything already depends on indx-interfaces and it still depends on nothing, so nothing is inverted, and test_workspace_boundaries.py’s graph is unchanged.

The risk being taken is a slope, and the fence is where it stops. A helper that needs a third-party import is an implementation and belongs in a distribution; one that holds state is a service; one that reads an environment variable belongs to a settings model (ADR-0020).

Revisit when a second helper is proposed, or the first one needs an import from outside the standard library. Either says the package has become a utility library, and the rule then needs restating rather than stretching one more time.

ADR-0035 — Two NER lanes ship with nothing that can tell them apart

Section titled “ADR-0035 — Two NER lanes ship with nothing that can tell them apart”

Status Proposed, with a known flaw · no version change

Context. The entity extraction lanes are specified as a fast one and a more accurate one, and this repository cannot check the second claim. benchmarks/case.schema.json has no expected text and no expected fields; a case carries routing labels and nothing about output. score.py reports route_accuracy, read_acceptability, signature_accuracy, fallback_accuracy, cpu_only_rate and cost, all of which are about which capability ran rather than what it produced. economics.quality is a constant in a table. So “more accurate” would be a claim on exactly the footing actuals.quality already sits on, which the benchmark page already describes as measured against nothing.

Decision. Ship the lanes anyway, and state the gap where the claim is made rather than where it is convenient. The distinction the lanes are chosen on is cost and dependency, which is observable — one ships in a default install, one needs an extra and a dictionary, one needs a model, one bills per token — and the accuracy ordering is stated as expected rather than measured.

Consequences. Being explicit is the whole content of this record: an unqualified “more accurate” in a feature entry would be the one claim in the documentation set with nothing behind it. The lanes are still worth shipping unmeasured, because a deployment choosing between them is usually choosing on what it is willing to install and pay for, and that axis is real. The benchmark’s existing known-gap entry gains the second thing waiting on it — the Office reader comparison was the first.

Revisit when benchmarks/case.schema.json gains expected text or expected fields for a labelled subset. That is the gap the benchmark page already records, and the moment it closes, the accuracy ordering becomes a measurement and this record becomes a statement about how it was made.

Revisited 2026-09-08. The trigger fired: schema 1.3 added expected_text and expected_fields, ten scopes carry them, and score.py reports text_recall and field_accuracy. The decision stands unchanged, because the lanes do not exist yet and no labelled document is one they would differ on. When they ship, their accuracy ordering is measured against this subset or against labels added for it, and stated as measured; “expected rather than measured” ends there.

Revisited again, later on 2026-09-08. The four lanes shipped: patterns, vibrato, ONNX and LLM. The labelled subset still carries expected text and fields and no entity labels, so nothing has measured one lane against another, and the ordering stays expected. What would end it is a case whose expected_fields name entities with their labels, scored against what each lane returns.

Revisited a third time, 2026-09-08. Schema 1.4 adds expected_entities, six scopes carry 30 of them, and the benchmark runs each installed lane alone over the same plan: ner-llm 80% (a hosted gpt-5.4-nano, the only lane that costs money), patterns 50%, onnx 27%, vibrato 3% recall. The ordering is measured. It is measured on forms, where fixed shapes win, and vibrato’s number is mostly a granularity IPADIC cannot express; the record’s title no longer holds and its warning does. What would move it now is width, not a new signal.

ADR-0036 — The contract carries the ubiquitous language, and a test pins it to the site

Section titled “ADR-0036 — The contract carries the ubiquitous language, and a test pins it to the site”

Status Accepted · no version change; openapi.json and both generated clients move, descriptions only

Context. The reference the demo host serves at /scalar renders openapi.json, and that file carried almost no prose. info had a title and a version, there were no tags, every operation’s summary was one word, no operation had a description, and 43 of 50 schemas had none — RoutePlan, Block, EncodeRequest and ErrorEnvelope among them. A reader could see the shape of every payload and learn nothing about what an endpoint did or what the words meant. The prose already existed one directory away, in the glossary the site publishes, so the gap was distribution rather than authorship.

Decision. The text goes in at its source, in three places chosen by what owns each kind. The introduction, the tag groups and the ubiquitous language are indx_app_server/reference.py, wired into FastAPI(description=…, openapi_tags=…). An operation’s summary and description sit on its route decorator in app.py, beside the code that answers it. A schema’s description is the Pydantic class docstring in indx-interfaces, because FastAPI already copies one into the schema and a second place to write it would be a second place to forget.

GLOSSARY in reference.py holds seventeen terms whose sentences are copied verbatim from glossary.mdx, and tests/unit/test_api_reference.py fails when the two disagree. It is the same bargain the duplicated format enumerations strike (ADR-0007). The copy is deliberate and the check is what makes it safe. The same file fails on an operation with no description or no tag and on a schema with no description, so the next endpoint or model cannot ship undocumented.

The OpenAPI document is English only. It is one artifact every caller of every deployment receives, and the site is the bilingual surface; each glossary bullet links to the page that carries both languages, and the canonical glossary link points at /developer/glossary/.

Consequences. Documentation edits now move openapi.json and both generated clients, so a prose change goes through just api::gen-client and just docs::gen-client and is committed with what caused it. That is the cost of having one source rather than a hand-written reference beside a generated one. POLICY_VERSION is untouched, because a description changes neither the decision nor the plan artifact.

The one drift the test found is fixed rather than encoded. The canonical glossary defined nomination where the site defines signature nomination, so the canonical entry is renamed to match. The glossary bullets link to site-relative paths, which resolve on the demo host that serves the site and the API from one origin and dangle on a bare indx serve. That is the right trade for the reader who is actually reading a reference.

Revisit when the reference outgrows what info.description can hold. Scalar renders its ## headings as sidebar entries, which is what makes the glossary navigable, but a description that wants sections beyond an introduction and a vocabulary is asking for the site instead.

ADR-0037 — Enrichment is a third return shape: a summary and tags, on the classifier’s three units

Section titled “ADR-0037 — Enrichment is a third return shape: a summary and tags, on the classifier’s three units”

Status Accepted · does not move POLICY_VERSION; EncodeRequest gains a field and the snapshot a list, so openapi.json and the generated clients move

Context. Once classification and entity extraction had their own ports, what the feature list called model-backed enrichment was the residue neither shape could carry: a summary is prose and not a choice from a set, and a tag is a word the text need not contain and no taxonomy enumerated. ADR-0029’s revisit trigger named this case, “a return shape that is neither labels nor spans”, and it has fired.

Decision. A third row on the grid. Enrichment is {summary: str | None, tags: tuple[LabelScore, ...]}, each part optional and an absent part “no opinion”; DocumentEnricher, PageEnricher and ChunkEnricher answer it for the classifier’s three units, and one class may be declared through all three hooks. Tags reuse LabelScore rather than a new type: a tag is a label with no facet and an open vocabulary, and the number beside it is the same self-report. Everything else is inherited whole: an id in the one namespace, now eight ports wide; snapshot.enrichers outside the hash; EncodeRequest.enrichment naming IDs per unit; the three refusals before the fetch under invalid_enricher.

Both parts in one port, not a summariser and a tagger. A model answers both in one call, and a deployment asking for both should pay once. The merge rule is the facet rule with the facet replaced by the part: the first enabled enricher with a summary wins the summary for that unit, the first with tags wins the tags, and an implementation that can only give one part gives that part.

Where the answer lands follows ADR-0031: the document block under enrichment, each page block under the same key, and chunk answers on the document block under chunk_enrichment keyed by chunk block ID. Both keys are reserved.

The floor tags nothing. indx-enrich-extractive, the lane a default install carries, returns the sentences that carry most of a unit’s own vocabulary, verbatim and in document order, and leaves tags empty, because coining a word the text does not contain takes a model, and inventing one from term frequency would publish a claim about subject matter no measurement backs. indx-enrich-llm gives both, behind the llm extra and its own INDX_ENRICH_LLM_* prefix.

Consequences. chat.py is now a three-way drift-tested copy. The extractor and enricher pages in the playground share one shape, a unit selector and a box on the review tab. The trace names an enricher with the parts it won as its facets, which stretches that field’s name and not its meaning: a part, like a facet, is one question the unit was asked.

Revisit when a fourth return shape is asked for that is not prose, not labels and not spans: a relation between two spans, a table, a redaction box. Or when a summary needs a location, which is the point at which it stops being a summary.

ADR-0038 — A family’s engine adapter is a library beneath the plugins, not a copy per lane

Section titled “ADR-0038 — A family’s engine adapter is a library beneath the plugins, not a copy per lane”

Status Accepted · no version change; no wire change

Context. Three distributions speak to a chat model through LiteLLM: indx-classifier-llm, indx-ner-llm and indx-enrich-llm. Each carried the same chat.py, byte-identical but for the line naming the other copies, pinned by the drift test that guards the format enumerations. The copy existed because a plugin may not import a sibling (ADR-0007), and the third copy made the cost visible: a fix to how a reply’s cost is read is three edits and a test that fails until all three agree.

Decision. indx-llm is a workspace distribution beneath the plugins, beside indx-interfaces in the layering: it declares no capability, joins no snapshot, and depends on nothing, LiteLLM included, which client() resolves at call time behind each lane’s own llm extra. The three lanes depend on it and the copies are gone. First-party code may not import it, for the reason it may not import a plugin: an engine adapter in the router or the executor is an engine in the contract. tests/unit/test_workspace_boundaries.py pins both edges under LIBRARY_MODULES.

ADR-0007 stands. Its bargain is about engine stacks: a distribution owns its dependency so a deployment that does not want one receives none of the code that would drive it. This library carries no dependency, so nothing is received that was not asked for, and the reason for the copy does not apply. The format enumerations the observer and reader pairs share (ooxml.py, plaintext.py, rfc822.py) stay copies for now; they are the next candidates for the same route.

Consequences. One place to fix how a reply is read. A fourth LLM lane is an import. The dependency graph gains a leaf that is neither contract nor plugin, which is the one new category this record adds.

Revisit when a shared adapter needs a dependency of its own. That is an engine stack again, and ADR-0007 says it belongs to a distribution, not a library.

ADR-0039 — One image carries the API, every engine and the site, and it is public without authentication

Section titled “ADR-0039 — One image carries the API, every engine and the site, and it is public without authentication”

Status Accepted · no version change; no wire change

Context. indx ships to AWS as an image built in the deploy workflow, tagged by commit, run on Fargate behind an ALB that Cloudflare fronts. The question was what the image holds. The extras are per distribution (ADR-0007), so an image could carry any subset, and every engine that downloads weights on first use (fastembed, the two ONNX lanes, IPADIC for vibrato) would otherwise pay that download on every cold task. The deployment is also the documentation site: examples/app2.py serves the built site, the playground and the real API from one origin, and that is the host a visitor reaches.

Decision. One Dockerfile builds one image, linux/amd64 only, with every extra except s3 and with the weights fetched at build into caches the runtime reads offline (HF_HUB_OFFLINE). s3 is out because the deployment has no bucket: no loader for a scheme nothing resolves, and no S3 statement on the task role. amd64 only because vibrato publishes no aarch64 wheel; on an Apple Silicon machine the build runs under QEMU. The site is built in its own stage and copied in last, so a docs-only change reuses every Python layer. scalar-fastapi moves from the dev group to a demo group of its own, which is what lets the image install the demo host without the test tooling. scripts/warm_weights.py constructs each engine rather than listing files, so the model names stay each distribution’s; run with the network off, the same script is the proof that the caches are complete, which is what just infra::image::smoke does.

The server has no authentication, and this record says so rather than hiding it behind the security group: the ALB admits Cloudflare’s published ranges only, and Cloudflare Access on the hostname is the gate an operator turns on before the DNS cutover. That is a perimeter, not authentication, and the API stays unauthenticated behind it.

Consequences. A multi-gigabyte image and a cold start that loads weights rather than fetching them. A smaller lane is a build argument (INDX_NO_EXTRAS), not a second Dockerfile. Anyone who can reach the origin can call every capability; the cost ceiling on the task is the only brake (ADR-0040 adds one for Bedrock).

Revisit when the server gains authentication, at which point the perimeter is a convenience rather than the whole defence, or when an arm64 wheel for vibrato appears and Graviton becomes the cheaper task.

ADR-0040 — Bedrock is off by default, and a budget action is the brake when it is on

Section titled “ADR-0040 — Bedrock is off by default, and a budget action is the brake when it is on”

Status Accepted · no version change; no wire change

Context. The deployment is public and unauthenticated (ADR-0039). Five lanes reach Bedrock through LiteLLM (the llm classifier, ner-llm, enrich-llm, generic-vlm and the hosted-text space), and each call is billed. Anyone with the hostname could run the bill up. Each lane already advertises itself only when its model variable is set, and the core stack already set those variables and the task role’s Bedrock statements only when a model was configured; what was missing was a switch named as one, a proof of the off state after a deploy, and any bound on spend in the on state.

Decision. enable_bedrock is a Terraform boolean, false by default, fed from PROD_BEDROCK_ENABLED. Off, the task carries no model variable and the role no Bedrock statement, and a model named while it is off is a plan-time validation error. On, the stack adds an AWS Budgets monthly cost budget on the Bedrock service with an automatic action that attaches a Deny bedrock:* policy to the task role at 100 percent of actual spend, notifying the alarm topic at 80 percent forecast and 100 percent actual. The brake lives in the account and not in the application: it needs no code in any lane, it holds for a lane added later that forgets a limit, and it holds even if the process is compromised. scripts/aws/prod-capabilities-guard.sh asserts, from the live capability snapshot, that each Bedrock lane is present exactly when its model is set; the public smoke runs it on every deploy and cutover.

Consequences. AWS refreshes budget data a few times a day, so the deny lands hours after the threshold and the month’s overshoot is those hours of calls: this stops a runaway bill, not a request. The deny stays attached until an operator resets the action; a new month does not lift it. Flipping the switch is a plan and a deploy. Per-request limiting, when wanted, is a Cloudflare rate-limiting rule on the hostname, which is an operator setting and not in this repository.

Revisit when the server gains authentication and the lanes can be gated per caller, or when a lane needs a per-request cap that hours of lag cannot give.

ADR-0041 — The weights are a published image, keyed by the files that name them

Section titled “ADR-0041 — The weights are a published image, keyed by the files that name them”

Status Accepted · no version change; no wire change

Context. The image bakes 1.3 GB of weights so a container never downloads (ADR-0039), and the build fetched them from Hugging Face every time the dependency layer changed: 34 seconds on a good day, a failed build on the day Hugging Face is down, in CI and in the production deploy alike. The same 1.3 GB rode the Actions cache as a layer of every pull request, a third of a budget shared with the environment’s own layers.

Decision. The weights are a build target of their own, FROM scratch holding the three caches, and the only stage that reaches Hugging Face. Its tag is a content key, w-<sha256 of the five files that name a model> (scripts/weights_key.sh), and each workflow publishes it to GHCR only when that tag is absent (scripts/aws/ensure-weights-image.sh), then hands the reference to the build as WEIGHTS_IMAGE. Hugging Face is therefore reached once per key across every workflow and every run. The proof that the weights are complete moves into the image: the runtime stage reruns scripts/warm_weights.py with the network off, where the container will run, and a build whose published weights do not fit its code fails there rather than at first use. Locally the same target is built once from Hugging Face (just infra::image::weights) and named by the build argument’s default, so a machine that cannot reach GHCR never needs to.

The key deliberately excludes the lockfile: a dependency bump does not change what a model is, and re-downloading a gigabyte for every bump was the cost this record removes. An engine that changes its cache layout fails the offline proof, which is the signal to republish.

Consequences. A second registry in the build, GHCR, which ADR-0039’s “one registry” line had avoided; it is the one GitHub Actions can write to without a secret. A private package per key accumulates in GHCR and is pruned by hand. The build no longer depends on a third party being up, and a pull request’s layer cache carries the environment alone.

Revisit when a weight must be pinned to a revision rather than to a repository’s current files, at which point the key names the revision too, or when the deploy should build without GitHub at all.

ADR-0042 — URI sources are a per-deployment allowlist, and the public host enables none

Section titled “ADR-0042 — URI sources are a per-deployment allowlist, and the public host enables none”

Status Accepted · no version change; no wire change

Context. The deployed host planned file: and http: URIs for any caller. A probe of file:///etc/os-release was read off the task’s disk and refused only on media type, because INDX_LOADER_FILE_ROOTS was unset and the loaders are permissive until configured, for the reason their settings record (a default-deny gets turned off wholesale rather than configured). indx-loader-http’s private-host guard held, but on Fargate the lane is still a request made from inside the VPC on a stranger’s behalf. Nothing on the public host needs a URI: the API takes bytes inline, and the playground’s samples can travel the same lane an upload does.

Decision. INDX_URI_SCHEMES, a field on indx-source’s settings, names the schemes a caller may use. Unset means every scheme an installed loader declares; a list means exactly those; none means no URI source at all. It is enforced once, in load_uri, where the scheme is dispatched, and resolvable_schemes returns the enabled set, so the snapshot’s resolvable.schemes and the 415 a refused scheme enumerates are the same answer. A disabled scheme is the existing unsupported_source, with the message naming the variable. resolvable stays outside the snapshot hash, so no plan in flight is invalidated. Production sets none and closes INDX_LOADER_FILE_ROOTS to the samples directory as a second layer: a deployment that later enables file still reaches only what the image ships. The playground’s samples reach the API inline, the way an upload does, and are committed government documents under examples/samples/; the corpus never enters the image. The capability guard and the public smoke assert the empty set and a refused file: URI; a @hardened acceptance lane runs the security scenarios against a server started with the production variables, locally and in CI, and the same scenarios carry @deployed for the run against the host.

Consequences. A public deployment cannot be asked to fetch anything. A caller who wants a URL read runs their own instance or is given an allowlist by an operator. Local development, the CLI, the benchmark and the acceptance suite keep file:, because the variable is unset there. The loaders’ own defaults are unchanged, so the second layer is only as good as the operator who sets it, which is why the smoke asserts the first.

Revisit when the server gains authentication, at which point an allowlist can be per tenant rather than per deployment, or when a bucket is attached and s3 is enabled by name.

ADR-0043 — Slide geometry is a chunker, and a process chart is a signature plus a parser

Section titled “ADR-0043 — Slide geometry is a chunker, and a process chart is a signature plus a parser”

Status Accepted · no version change; no wire change · second cut superseded by ADR-0044

Context. The Denso half of the sprint’s stretch item is one slide in a customer deck, a 工程系統図 drawn with native PowerPoint shapes, and a PoC that already reads it deterministically (65 nodes, 103 edges, every symbol labelled). indx had the reader for the deck’s text (office-extraction) and, since #140, a chunker that puts every text shape and picture back at its box (indx-chunker-pptx, with a typed bbox_reason for a chunk that has none). Neither says what the drawing means, and the customer file may not enter the repository.

Decision. Three cuts. First, what a slide’s shapes are is the chunker’s business and what they draw is a parser’s: indx-capability-process-chart is the invoice’s shape, a PARSER nominated by a signature ahead of the ladder and never routed to generically. The signature is page-scoped, so process-chart-parser leads the route for the recognized slide alone and the deck keeps office-extraction; the parser returns the same text the reader would have and adds metadata["process_chart"], a graph of nodes and edges keyed by the shapes’ own cNvPr ids, with connections taken from the connectors’ stCxn/endCxn references and the one heuristic, which text labels which symbol, marked as one. Recognition is two required signals and one that raises confidence (symbols joined by connectors, a family of symbol kinds, a label on every symbol), all three the drawing’s own structure and none a word. The PoC’s title-only gate was the customer’s convention and not the format’s: the same deck draws three more charts under other titles, and the gate had hidden them. A first draft kept the title as the third signal, with a term list, and the same deck showed it fired on the one slide the list was written from (ADR-0045). Second, the DrawingML walk is duplicated verbatim into both distributions like ooxml.py and the drift test pins the copies: shape 25 has to be the same shape to the chunker that boxes it and the parser that reads it, and a plugin may not import a sibling. A connector’s ends ride Shape.line rather than its bbox, because a straight line has no area and validate_bbox requires one. Third, a customer document is evidence in a PR body and never a fixture: a synthetic deck (tests/fixtures/office-process-chart.pptx) exercises every branch, and the acceptance numbers over the customer deck are stated in the PR. POLICY_VERSION is unchanged, since no rung and no plan field moved; the new available descriptor moves the snapshot ID, which is the binding that invalidates plans, the way the invoice’s did.

Consequences. process_chart is a parser-written key like invoice, outside RESERVED_METADATA_KEYS, and a caller who did not ask for signature_detection sees nothing new. A slide that draws a flow of symbols under any title is nominated, at a lower confidence when some of its symbols carry no label. The playground can overlay nodes and edges with the arithmetic it uses for chunks. LibreOffice rendering of a slide stays a demo-host convenience and never a dependency of the image. SmartArt, chart parts, per-cell table geometry and a shape inventory on the page block are named out of scope until a benchmark label asks.

Revisit when a second drawn convention (a P&ID, an org chart) wants the same walk, at which point the symbol table becomes what a parser declares and the walk a library beneath the plugins like indx-llm (ADR-0038).

ADR-0044 — The Office package layer and the DrawingML walk are a library beneath the plugins

Section titled “ADR-0044 — The Office package layer and the DrawingML walk are a library beneath the plugins”

Status Accepted · no version change; no wire change

Context. After ADR-0043, ooxml.py was four verbatim copies (the observer, the reader, the chunker and the parser) and drawingml.py two, each pinned by the drift test, because a plugin may not import a sibling (ADR-0007). ADR-0038 had already answered this shape for the chat adapter: a library beneath the plugins that carries no dependency is not an engine stack, so ADR-0007’s bargain does not cover it, and that record named ooxml.py as the next candidate. Both files are standard-library only.

Decision. indx-ooxml is a workspace distribution beside indx-llm in the layering: package.py is the zip-of-XML layer (open, the part cap, the DTD refusal, part enumeration, relationships) and drawingml.py the shape walk. It declares no capability, joins no snapshot and depends on nothing. The four Office plugins and the two DrawingML readers depend on it; the six copies and their two drift-family rows are gone. LIBRARY_MODULES in the boundaries test holds both libraries under one rule: a library imports neither the contract, nor a plugin, nor another library, and first-party code imports no library. Tests moved with the code where they tested the code alone (test_drawingml.py); the observer’s and the reader’s tests stay where they were, because they test each plugin’s own translation of a ValueError into its refusal. slide_text stays in the reader and in the parser: it is the reader’s rule, three lines, and the parser’s equality test guards it.

Consequences. One place to fix the part cap or a relationship rule, and a fifth Office reader is an import. ADR-0007’s list of copied enumerations shrinks to plaintext.py with its settings.py, rfc822.py and taxonomy.py. The first carries a settings model (INDX_TEXT_ENCODING), which makes it a different question than a dependency-free module; the other two are the next candidates for this route.

Revisit when the library wants a dependency or a setting of its own. The first is an engine stack and ADR-0007 sends it back to a distribution; the second is a configuration owner, and a settings.py lives in its distribution.

ADR-0045 — A recognition signal is the format’s structure, never one document’s vocabulary

Section titled “ADR-0045 — A recognition signal is the format’s structure, never one document’s vocabulary”

Status Accepted · no version change; no wire change

Context. The first draft of the process-chart signature carried a title term list (工程系統図, 工程図, 工程フロー, “process flow”, “process chart”) as its third signal. The customer deck it was written from draws four process charts; the term fired on one, the slide the list was copied from, and the two structural signals separated all four. A signal that fires only on the document it was derived from has measured nothing. Review caught it; nothing in the repository would have.

Decision. A signature’s signal is a property of the document type, and the bar for one is evidence from more than the document that motivated it. Concretely:

  • A signal is structural (a shape convention, a connector reference, a field the format declares) or it is vocabulary the document type carries by definition: an invoice says “invoice” or “請求書” because that is what an invoice is, and indx-capability-invoice’s four signals are that kind. A word one author put on one document (a slide title, a department’s name for a drawing) is neither, and is not a signal.
  • A signal is shown firing on at least two independent documents of the type before it ships: a fixture and a second document, or two fixtures from different producers. The PR body names them per signal. The customer deck and the synthetic deck are the pair for process-chart.
  • A signal that fires on only one of the documents at hand is deleted or replaced, not weakened into a confidence booster: confidence is the share of independent signals matched, and a signal that is not independent of one document inflates it. A signal may count in proportion (the share of a chart’s symbols that carry a label); it may not be a word that fires once.
  • Anything a deployment would legitimately want to vary (a plant’s own name for a drawing) is not a signal at all; it is data on the output, the way title rides the chart, and a caller filters on it.

Consequences. process-chart keeps title as data and its third signal is a label on every symbol, which is what the drawing’s structure states about itself. A reviewer asks two questions of a new signal: what is the type-level reason it fires, and on which two documents did it. The rule is a review rule and a protocol rule (the signature-detector section of the provider protocol), not a test, because the second document is usually one that cannot enter the repository.

Revisit when a labelled corpus exists to measure signals against, at which point a signal’s precision on that corpus replaces the two-document bar.

Contract-level and unanswered. Recorded here so they are decided deliberately rather than discovered during the work that trips over them.

  • Vectors on every block, or chunks only as today? The document vector is deferred because it has no consumer. Classification was expected to be that consumer and turned out to consume text instead (ADR-0018). What a chunk is is now an installed chunker’s answer; whether the boundary the shipped chunkers draw is the right embedding window is a measurement retrieval still owes.
  • Does one shared embedding space cover all priority modalities, and at what quality? Half answered: clip-vit-b32 covers text and image on both sides now, and the measurement that opened the document image lane compares two documents against two captions, which is discrimination and not retrieval quality. CLIP’s text tower is weak enough on Japanese that the same measurement is evidence against the shared space’s text half. A ranked measurement over a labelled set is what would answer this.
  • Text and vectors are versioned separately only if re-embedding never re-reads, which needs a text cache keyed on the source digest. Nothing here stores anything today.
  • Persistence and separate router/executor deployments stay deferred until the synchronous stateless backbone has measured baselines — but 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.

ADR-0046 — PDFium is one process-wide lock, in a library beneath the plugins

Section titled “ADR-0046 — PDFium is one process-wide lock, in a library beneath the plugins”

Status Accepted · no version change; no wire change

Context. In #148’s docs CI the demo host died twice, silently, between the NASA encode and the search live spec, and passed on rerun. pypdfium2’s own metadata says PDFium is not thread-safe, and the server reaches the facade through a thread pool (ADR-0024), so two requests can be inside it at once. Six call sites opened it directly, each in its own with PdfDocument(...) block: the native reader, the invoice reader, the OCR and vision renderers, the PDF chunker, and the demo host’s page count and render. Eight threads rendering pages while two encode the same file crashed the process on three runs out of three, twice with a segmentation fault and once with a corrupted heap, and printed nothing first. That is the shape of the CI failure.

Decision. indx-pdfium is a workspace distribution beside indx-llm and indx-ooxml (ADR-0038): one re-entrant lock and one document(source) context manager that takes it, imports pypdfium2 at call time, opens the document and holds the lock for the whole block. It declares no capability, joins no snapshot and depends on nothing; pypdfium2 stays each lane’s own dependency, and so do its DPI and pixel ceiling (ADR-0007 still holds for the rendering). A lock has to be one object per process, which is what rules out a copy per distribution and makes this a library rather than a helper. Every PDFium call, page access and rendering included, belongs inside the block; encoding the result belongs outside. Ruff refuses pypdfium2.PdfDocument everywhere but the library (banned-api), so a seventh call site cannot bypass it. The same eight-thread run passes three times out of three with the lock in place.

Consequences. PDF work serializes per process. A deployment that wants PDF pages read in parallel runs workers, not threads, which is the boundary the crash already imposed without saying so. The OCR engine keeps its own lock for its own reason. Text encoding and PNG encoding stay outside the lock, so what is serialized is the engine and not the request. The dependency graph gains a third library, and the rule for one is unchanged: a library imports neither the contract, nor a plugin, nor another library.

Revisit when a lane needs a document held open across requests, at which point the lock’s scope is a session and not a block, or when pypdfium2 ships a thread-safe build.

ADR-0047 — A reader that located its lines says so, and a chunker cuts them

Section titled “ADR-0047 — A reader that located its lines says so, and a chunker cuts them”

Status Accepted · no version change; no wire change

Context. The MHI half of the sprint’s stretch is a one-page rotated raster scan of a rebar test report with sixteen boxed ground-truth regions. With the ocr extra, generic-ocr read it well (827 characters at 0.97 confidence, the certificate number, title and supplier legible), and the page came back as one chunk with no_geometry: the reader joined the recognizer’s lines into text and threw their boxes away, and indx-chunker-pdf cuts only text-layer pages. Every scanned upload the deployed host receives had the same gap. The benchmark refuses one-page documents, so the scan is evidence in a PR body and not a case.

Decision. PageOutput gains lines: tuple[TextLine, ...], where a reader whose engine knows positions states each line it located, TextLine(text, bbox) with the one box rule every box follows. It is a field on the port model and not a metadata key: typed, never on the wire (the page block keeps text, and the chunks carry the boxes, so nothing is stated twice), and PageOutput is not an HTTP model, so no client regenerates. generic-ocr fills it from the recognizer’s quadrilaterals, normalized against the image it rendered. indx-chunker-lines, a builtin chunker for any media type, cuts one chunk per line at its box and disclaims a page whose reader stated none, so the floor still takes it. Two alternatives were refused: the reader emitting chunks itself (readers read and chunkers cut, and a third-party OCR lane now gets geometry by filling one field), and a chunker re-running OCR to find boxes (a second engine run per page for something the first already knew). Cutting the reader’s own lines launders nothing: they are the reader’s verdict, reached down the ladder and past output validation, which is the asymmetry chunk_map keeps.

Consequences. On the scan, 119 boxed chunks where there was one unplaced; fourteen of the sixteen regions contain chunk centres, the two misses being red company seals with no text; the playground draws a scan the way it draws a PDF. no_geometry no longer names OCR-read pages. A vision-model lane states nothing, since it returns no boxes. A reader may state lines and a different text, and the chunks are the lines: chunk text is the chunker’s to write, as the protocol already says.

Revisit when a reader knows more than lines (words, blocks, reading order), at which point TextLine is the leaf of a small tree rather than the whole answer.

ADR-0048 — A DXF drawing is a native text layer whose pages are its layouts, read through ezdxf

Section titled “ADR-0048 — A DXF drawing is a native text layer whose pages are its layouts, read through ezdxf”

Status Accepted · no version change; no wire change

Context. The sprint’s last stretch item, “CAD harvesting from the Denso PoC”, named code that does not exist: the PoC’s README excludes CAD from its scope and its dataset holds no CAD file. The work was redirected to native DXF support. A DXF file states, in plain group codes, every TEXT and MTEXT, the attributes on a block reference (a title block is one), a dimension’s rendered measurement in its own block, and the layouts a CAD program shows as pages. No real drawing with reuse terms was in reach (JSCE’s samples are SXF under a bare copyright notice), so the fixture is synthetic and generated.

Decision. DXF takes the Office shape (ADR-0008, ADR-0044). indx-observer-dxf makes image/vnd.dxf plannable: a page is a layout, the model space and each paper-space layout in tab order, the division the format states; a layout with text is usable, one with no entity is usable and empty (a blank sheet, read as empty), one with geometry and no text is missing, and since no rung renders a drawing that is the honest cliff to manual review. indx-capability-dxf-extraction is the native rung for the type: free, immediate, every text located, so indx-chunker-lines (ADR-0047) cuts a drawing into boxed chunks with no further work. What the two share, which layouts are pages and where the text sits, is indx-dxf, a library beneath the plugins that declares no dependency and imports the contract nowhere; it imports ezdxf at call time, and the two distributions declare ezdxf as a hard dependency, for the reason native-extraction gives for PDFium: the native rung for a format belongs on a default install, not behind an extra, and a distribution owns its engine stack (ADR-0007). A parser of our own was drafted and refused: ezdxf already answers binary files, code pages, MTEXT formatting codes, block transforms, dimension rendering and font-metric extents, each of which a hand-written reader would only approximate. Observation loads the whole file, bounded by the source ceiling; a drawing has no cheaper structure than its entities. The fixture generator pins the header values ezdxf re-mints on write and runs under a fixed hash seed, because ezdxf orders two of its objects by set iteration, so a regeneration is a no-op diff.

Consequences. Twelve media types observe and read. A drawing’s title block, parts list and notes are chunks with rectangles, retrievable and drawable in the playground like a PDF page’s lines. The snapshot ID moved with the new descriptor, and the benchmark was re-pinned and re-run. Dimensions read as their rendered value and never as the <> placeholder. DWG is not read: ezdxf does not open it, and a converter is an operator’s tool, not a dependency.

Revisit when a customer drawing arrives that the fixture’s structure does not cover (a paper-space viewport’s scale, a table entity, an external reference), or when a signature over drawings (a title block recognized as such, ADR-0045 applying) is worth nominating.

ADR-0049 — The roadmap targets the product blueprint, and the data store is the export target

Section titled “ADR-0049 — The roadmap targets the product blueprint, and the data store is the export target”

Status Accepted · no version change; no wire change

Context. The public roadmap measured eight steps against KR1, four targets set in a note that its author never reviewed, so the page was ordering work toward numbers nobody had agreed to. On 2026-09-10 a product blueprint landed, an interactive mock of the web app indx is meant to become: an embedding foundation (spaces, a data store, features and tags) and the applications built on it (a playground, a few-shot library, endpoints), on a chosen deployment target. The blueprint’s data store keeps records, vectors and provenance, which reads against the non-goal “indx does not own your index”.

Decision. The roadmap is read screen by screen against the blueprint, served verbatim from docs/website/public/target/ so every section links to the screen it describes; the KR1 step list moves to developer/milestones and stays there for the record, and the benchmark keeps computing the KR1 block because those numbers are measured whether or not the targets were agreed. The blueprint’s store is the export target the fourth open question already names, and it lives in the customer’s environment, which is how the non-goal survives as a location rule rather than a refusal to persist. Spaces, the feature schema and the few-shot library are the first consumers of the persistence decision, so that decision is taken once with all three visible, in Phase 7 of the build order, and not earlier for any one of them.

Consequences. Every screen carries a state: Planned with nothing usable behind it, WIP when the service already answers what it would show, and Done only once a person can use the screen in the web app, which no screen has earned. The ordering section puts the persistence decision ahead of everything on Layer 01. The playground’s feature-first framing and a deployment screen over /v1/capabilities need no decision and can start now.

Revisit when a dated successor to the blueprint lands in public/target/, or when the persistence decision is taken and the store’s location rule needs its own record.

ADR-0050 — The requester names the component, and the ladder is the default rather than the decision

Section titled “ADR-0050 — The requester names the component, and the ladder is the default rather than the decision”

Status Accepted · no version change; no wire change

Context. KR1 asked for a router and a plan, and the wish has since become simpler: the requester decides which NER lane, one or one per language, which OCR, which LLM, which enricher, if any. Most of that already exists. classification.*_ids, extraction.*_ids, enrichment.*_ids and embedding_space_ids name components per request, and several extractor IDs all run with the first lane to answer a label winning it per block. Three things the requester cannot name: the reader, which the ladder chooses and the five RoutingConstraints only shape; the LLM model, one deployment variable per lane; and the chunker, list order until the build order’s Phase 6. The question raised was whether this needs new endpoints, a /v2/, or a new executor.

Decision. Choice reaches the requester through ADR-0026’s shape and nothing else: an ID, an advertisement on the snapshot, a request field. Three additions, each additive with a default that keeps today’s behaviour. A plan-time allowlist of capability IDs and a no-fallback flag on RoutingConstraints, so the plan names one reader and comes back unsatisfied naming the exclusion otherwise; the executor is untouched, because it already runs only what the plan named. One advertised ID per allowed model for each LLM lane, from a list setting, so the existing ID fields pick the model and the deployment’s list is the allowlist the blueprint’s Deployment screen shows. An extractor lane per language, an executor filter over the language it already writes under LANGUAGES_METADATA_KEY. No /v2/: the plan/encode split survives, and “pin the reader, refuse fallbacks” is a plan with one candidate. No new executor: it dispatches four families by ID today and runs what the plan names. Each addition bumps POLICY_VERSION where the decision changes and regenerates the client through the contract check.

Consequences. The ladder is what runs when the requester says nothing, and a request that names everything gets exactly that or a refusal that says why. KR1’s routing accuracy stays a benchmark number rather than the product’s promise. The three additions are recorded as planned entries in the catalog and sit in the roadmap’s first step, because none depends on the store.

Revisit when the first of the three ships, or when a request needs to name something that carries no ID, the language detector or the observer, which would oblige an ID first.

ADR-0051 — The web app lives in frontend/, ships as a static export the host serves at /app, and layers apps → screen → ux → ui

Section titled “ADR-0051 — The web app lives in frontend/, ships as a static export the host serves at /app, and layers apps → screen → ux → ui”

Status Accepted · no version change; no wire change

Context. ADR-0049 made the roadmap a walk through the product blueprint, and its first step names two pieces of pure front-end work over requests that exist: the playground’s feature-first framing and a read-only Deployment screen filled from GET /v1/capabilities. Nothing in the repository could hold them. The docs site is Astro and Starlight, right for prose and for the sandbox island and wrong for an application with a shell, routes and state. A July attempt at a frontend/ workspace was reset before it landed, and its lessons survived only as notes. The questions were where the app lives, how it is served, and what keeps it from growing into one directory of components as the blueprint’s eight screens arrive.

Decision. A pnpm workspace under frontend/, with the app at frontend/apps/web so a later frontend sits beside it rather than in the Python packages/. It is a Next.js static export with basePath: /app, and examples/app2.py mounts frontend/apps/web/out there, ahead of the docs site’s catch-all at /, inside the one production image (ADR-0039). No Node runtime and no CORS: the API has none by design, and one origin is what the export needs. Every screen is its own package, indx-screen-<section>, and the layering is fixed and checked by frontend/scripts/check-boundaries.mjs the way test_workspace_boundaries.py pins the Python workspace: web → screen-* → ux → ui, with indx-api-client a leaf the app and the screens may use, indx-ux presentational and host-agnostic (every label a prop), and indx-ui the shadcn primitives from one preset. The TypeScript client, the react-query hooks, the zod schemas and the MSW handlers are generated by orval from the root openapi.json and committed, and just frontend::client-check fails on drift, the shape api::contract-check already has. The locale is always in the path and each package ships its own messages typed against English, so a missing Japanese string is a type error. Light and dark are both first-class from the first screen.

Consequences. The export forbids what a static site cannot do: no middleware or proxy, no server actions, no per-request rendering, no route without generateStaticParams. next dev proxies /v1 and /health to a local API so development stays same-origin as well. The image gains a Node stage and the runtime a second static directory; the deploy target is unchanged. just frontend::ci and .github/workflows/ci-frontend.yml are the gate, on the paths that can change the answer. What is deliberately not there yet, with its trigger: a task runner when topological pnpm -r runs get slow, Storybook when ux has states a screen cannot reach, a published client when a consumer outside this repository appears, an auth package at the roadmap’s endpoints step, and with it the question of a Node runtime.

Revisit when authentication needs a request to look at, which is the day a static export stops being enough, or when a second frontend appears and the shared packages want an owner.

ADR-0052 — The web app grows screen-first over a browser-only store, and each screen swaps it for the API on its own

Section titled “ADR-0052 — The web app grows screen-first over a browser-only store, and each screen swaps it for the API on its own”

Status Accepted · no version change; no wire change

Context. ADR-0051 gave the web app a home and one real screen. The blueprint has nine, and the question was whether to wire each to the API as it is built or to see all nine first. Most of what the blueprint shows has nothing to answer it yet: no store, no spaces, no library, no endpoints a person creates. Wiring would have meant inventing endpoints ahead of the persistence decision the build order’s Phase 7 reserves, or leaving most screens empty while the look is still being decided.

Decision. Every screen renders sample data and mutates a store kept in the browser, indx-mock: the blueprint’s own state, as a reducer persisted to localStorage under indx-app-mock-v1, with the sample records keyed by locale-neutral ids and their copy in both languages. Every mutation raises a sonner warning that it is mocked, so no one mistakes the demo for the product. The package sits beneath the screens in the layering, web → screen-* → indx-mock → ux → ui, and also holds the dialogs two screens open, because screens never import each other. One exception: the Deployment screen keeps the GET /v1/capabilities table the first screen already had, below its blueprint content. The locale root is the overview, so /app/en/ is the landing and the demo host’s per-locale redirects are gone.

Consequences. The look can be iterated on the served export with no Python change and no new endpoint. Wiring a screen later is local to it: its useMock() reads become generated hooks from indx-api-client, and the mock package shrinks by that screen’s records. The store is not a design for persistence and must not become one; the persistence decision stays with Phase 7.

Revisit when the first screen is wired to a real endpoint, or when the persistence decision lands and the sample records have a home outside the browser.

ADR-0053 — A cloud target is one Terraform stack: a registry, one container, one port and a Cloudflare perimeter, and the model lanes are its variables

Section titled “ADR-0053 — A cloud target is one Terraform stack: a registry, one container, one port and a Cloudflare perimeter, and the model lanes are its variables”

Status Accepted · no version change; no wire change

Context. The October 1 checkpoint asks for a deployment story across three clouds, and only AWS had one: three stacks, a dispatch-only workflow and a perimeter built around a hostname that is already live (ADR-0039, ADR-0040). Azure and GCP could have been that again, or something smaller. The second question was what the model lanes cost in code, and the answer was nothing. Every LLM lane hands its configured string to LiteLLM verbatim, so azure/<deployment> and vertex_ai/<model> are new values for variables that already exist, not new adapters, and nothing in packages/ changes to reach a third cloud.

Decision. A cloud target is one Terraform stack, applied by hand, holding a registry, one container on one port, a perimeter, a budget alert and the DNS record. infra/azure/terraform is one Container App on a Consumption environment whose ingress admits Cloudflare’s published ranges; infra/gcp/terraform is one Cloud Run service with its run.app URL closed behind a global load balancer whose Cloud Armor policy admits the same ranges and refuses a foreign Host. Scale to zero is the default and min_instances, 0 or 1, is the one knob, rather than a second template for a warm deployment: the cost is a cold start the first caller after an idle period is expected to lose, and the page states it. TLS at the origin is a Cloudflare Origin CA certificate, because the perimeter is Cloudflare — the origin’s certificate is only ever seen by the proxy, so it need not be publicly trusted, and no validation challenge has to pass through the door the perimeter exists to close. Provider credentials are ambient, the shape AWS_REGION and the task role already have: az login and gcloud auth application-default login for Terraform, an API key held as a Container App secret for Azure OpenAI, and the runtime identity for Vertex AI as for Bedrock. Each stack’s budget notifies and does nothing else; ADR-0040’s budget action, which detaches a role, stays AWS-only because neither cloud has one, so outside AWS the brake is an operator turning the switch off. What is deliberately out of every template: a customer’s VNet or VPC and its peering, private endpoints or Private Service Connect, a WAF ruleset, identity federation, and the AI service resource itself — the Azure OpenAI account, the Vertex AI quota. Those carry quota requests, region and data-residency decisions and per-customer security; they are implementation work, and a template that guessed at them would be wrong for every deployment.

Consequences. A third target costs one stack and one page, in both languages, and no Python. The AWS workflow stays the only automated path: Azure and GCP have a plan and an apply an operator runs, with no reviewer gate, no rollback artifact and no smoke of their own — their proof is just test::bdd::deployed and just docs::e2e-deployed against the host. Because just infra::terraform::validate globs infra/*/terraform* and .github/workflows/infra.yml runs it on pull requests, a fourth stack is gated the day it is added. The AWS stack gained INDX_EMBED_DIMENSION on the way, which it had been missing: a hosted embedding space with no dimension is not advertised at all, and silently, so its Bedrock embedding space could never have been reached.

Revisit when a target has to be deployed by something other than a person at a terminal — which is a workflow, an approval and a state lock per cloud — or when a customer’s networking or the AI service resource has to be inside the template rather than beside it.

ADR-0054 — Azure and GCP are deployed by a dispatch-only workflow each, isolated by name from AWS and from one another

Section titled “ADR-0054 — Azure and GCP are deployed by a dispatch-only workflow each, isolated by name from AWS and from one another”

Status Accepted · no version change; no wire change

Context. ADR-0053 made Azure and GCP one stack each and left them to a person at a terminal, and named the moment to revisit that: when a target has to be deployed by something other than a person, which is a workflow, an approval and a state lock per cloud. That moment is the first real deployment. The AWS workflow already holds the shape (ADR-0039, ADR-0040): dispatch only, plan from a read-only identity, deploy behind a GitHub Environment whose required reviewer is the approval, a fresh plan that must match the approved one, and a public smoke plus the @deployed scenarios after the apply. Three clouds share one Cloudflare zone, and the one thing that must never happen is one cloud’s workflow taking the hostname another cloud serves, because each stack writes its own DNS record.

Decision. Two workflows, deploy-azure-prod.yml and deploy-gcp-prod.yml, each a copy of the AWS shape with the parts a stack does not have removed: no cutover, because the record is in the stack, and no rollback job, because a rollback is deploy with image_tag naming a tag the registry already holds, which every apply already knows how to do. Every name that selects a target is per cloud and falls back to nothing shared: the variables and secrets are AZURE_PROD_* and GCP_PROD_* beside the AWS PROD_*, the Environments are production-azure, production-azure-plan, production-gcp and production-gcp-plan, and each cloud identity trusts only its own Environments’ immutable OIDC subject, so a token minted for one workflow cannot authenticate another. The hostname is any name under indx.jp, because a hostname need not say which cloud serves it, and the three stacks write records in one zone, so each workflow refuses a name equal to another cloud’s hostname variable before anything runs; deploy runs only when the operator has typed the name. The one credential the three do share is Cloudflare’s, because the zone is one zone and a token that edits it edits it for every hostname in it: each workflow reads its own *_CLOUDFLARE_API_TOKEN first and the secret the AWS workflow already holds otherwise, and the Origin CA key is one key per account either way. The identities are federated, not keys: an Entra application with a federated credential per Environment, and a Workload Identity Federation provider whose attribute condition is the same subject. Creating them is a one-time command sequence on the page, by hand, the shape the state container already had. The plan summary, the capability guard and the weights image are the AWS scripts reused where they are cloud-neutral; what is new is one comparison script and one public smoke the two clouds share.

Consequences. A deploy on Azure or GCP is a reviewer approving a plan they have read, then a run that builds the image into that cloud’s registry, re-plans, refuses on drift, applies, waits for the revision, proves the perimeter (the origin refused directly on Azure, a foreign Host answered 403 on GCP) and runs the acceptance lane. Applying by hand stays possible and is the same stack. The cost is the GitHub setup a third time, and one more page section per cloud, in both languages. The AWS workflow is unchanged: its inline plan comparison was not migrated to the shared script, because a production path is not refactored in the change that adds its siblings.

Revisit when a fourth cloud makes the third copy of the workflow the wrong shape, at which point the three become one reusable workflow with the cloud as its input, or when a rollback has to restore something an image tag does not name.

ADR-0055 — The Origin CA key leaves: GCP proves its certificate by DNS, and Azure keeps Origin CA on a zone-scoped token

Section titled “ADR-0055 — The Origin CA key leaves: GCP proves its certificate by DNS, and Azure keeps Origin CA on a zone-scoped token”

Status Accepted · no version change; no wire change

Context. ADR-0053 gave both new stacks a Cloudflare Origin CA certificate at the origin and had them authenticate that call with the account-wide Origin CA key, held in GitHub as CLOUDFLARE_API_USER_SERVICE_KEY, on the stated ground that the Origin CA endpoint was the one Cloudflare API a scoped token could not reach. Three things were found before either cloud was deployed. Cloudflare deprecated service-key authentication on 2026-03-19 and retires it on 2026-09-30; the replacement is an API token carrying SSL and Certificates / Edit, scopable to one zone. The Terraform provider every stack pins (cloudflare/cloudflare 4.52) has authenticated cloudflare_origin_ca_certificate with a token since 3.32, so the ground was false for this code from the start. And an account-wide key in a repository secret was the wrong shape regardless: it cannot be scoped, and it opens every zone the account holds, where the DNS token beside it opens one.

Decision. The key goes, from both workflows and both pages, and the two clouds part ways on the certificate. GCP takes the AWS shape: a Google-managed certificate proved by Certificate Manager DNS authorization — the stack writes one unproxied _acme-challenge CNAME with the DNS token it already holds, Google reads it, issues and renews, and no private key exists for the state to hold. hashicorp/tls leaves that stack. Azure keeps Origin CA, because it has no such path: Container Apps issues and renews a managed certificate only for a CNAME that points straight at the app’s FQDN — Microsoft names Cloudflare as the intermediate CNAME that blocks issuance and renewal — and only when DigiCert can reach the app, which the ingress allow-list forbids. Its one Cloudflare token therefore carries DNS / Edit and SSL and Certificates / Edit on the zone, and the shared AWS token falls back only once it carries the second. What was not chosen for Azure: Let’s Encrypt over DNS-01, publicly trusted and token-only, but ninety days long on a dispatch-only workflow, which is an origin that goes down unattended; and a certificate issued once by hand and stored as a secret, which puts the key in two places.

Consequences. Each cloud holds one Cloudflare secret, scoped to the zone, the shape AWS already had. GCP’s state carries nothing secret; Azure’s still carries the origin’s private key, because the environment certificate takes the pair, and its page keeps saying so. A GCP deploy returns before the certificate is ACTIVE, and the public smoke’s retry is what waits for it. The deploy account on GCP gains roles/certificatemanager.editor, and certificatemanager joins the APIs the stack enables. ADR-0053’s sentence on TLS at the origin, and ADR-0054’s clause that the Origin CA key is one key per account, stand as the record of what was decided then; this record supersedes both.

Revisit when Container Apps validates a managed certificate by a DNS record, at which point Azure takes the GCP shape and the Origin CA resource leaves the last stack that has it. The Azure half of this record is superseded by ADR-0064, which took the origin out from behind the proxy instead, and the GCP half’s DNS token by ADR-0065, which has the operator write the records.

ADR-0056 — The web app takes the host’s root, the docs site moves under /docs/, and the locale root is the sales page

Section titled “ADR-0056 — The web app takes the host’s root, the docs site moves under /docs/, and the locale root is the sales page”

Status Accepted · no version change; no wire change

Context. ADR-0051 served the web app at /app beside the docs site at /, and ADR-0052 made each locale’s root the overview, because the site was the product’s front and the app a blueprint behind it. The Sep 14 review asked for a sales site at the front rather than documentation, with the app as what is shown, and the sprint’s first story puts a one-page account of the advantage at the root of the production host with the docs one click away. Two things stood in the way. The docs write every internal link from the site root and set no base, so moving them under a prefix was either a rewrite of a thousand links across a hundred files or a build-time step. And FastAPI serves its Swagger UI at /docs on every deployment, which is the prefix the docs wanted.

Decision. The export is built for the root: basePath leaves the Next config and examples/app2.py mounts frontend/apps/web/out at / last, with / redirecting to /en/. The docs site is built with base: /docs and mounted there; the content keeps writing links from the site root and a Sätteri HAST plugin prefixes href and src at build, skipping the routes the demo host serves beside the site, so an author never writes /docs/ and the content stays grep-able by route. The one hand-written prefix is the homepage hero, which Starlight leaves alone. create_app() takes the reference pages’ paths as keyword arguments and the demo host moves them to /api/docs and /api/redoc; indx serve keeps FastAPI’s defaults and the exported contract is unchanged. The locale root becomes the sales page, a screen package like the others rendered without the app shell, and the overview moves to /overview/. Terraform on every cloud routes the whole hostname to one origin and does no path routing, so the layout is the demo host’s alone and ships with no infrastructure change. What was not chosen: a one-time rewrite of the content’s links, which would have put /docs/ in every future link and a thousand-line diff in one pull request; and another prefix for the site, which would have left /docs naming a form over a schema on a host whose API reference is /scalar.

Consequences. /app/… stops answering; no link outside the repository pointed there. The smoke scripts follow the redirects and assert HTML at /, /en/deploy/ and /docs/, and the AWS one additionally asserts the front page’s marker. The plugin cannot tell a content link into the web app’s locale roots from a docs locale link, which is the question the docs playground’s redirect (Sprint 1, story 2b) answers before any such link is written. ADR-0051’s /app mount and ADR-0052’s overview-as-locale-root stand as the record of what was decided then; this record supersedes both.

Revisit when the docs need to link into the app from content, or when a second host layout appears (a docs-only static host), at which point base becomes a build argument.

ADR-0057 — The web app shows nothing the API did not return: the browser-only store is retired and a screen without a backend is disabled in the navigation

Section titled “ADR-0057 — The web app shows nothing the API did not return: the browser-only store is retired and a screen without a backend is disabled in the navigation”

Status Accepted · no version change; no wire change

Context. ADR-0052 let every screen render sample data over a store kept in the browser so the nine blueprint screens could be seen before any was wired, and named as its revisit trigger the first screen wired to a real endpoint. Six are wired now: the overview and the spaces screens draw the embedding spaces the capability snapshot advertises, the features screen the lanes the install offers, the endpoints screen the contract as served, the deployment screen the snapshot itself, and the playground runs POST /v1/encode. What remained of the store was the part that pretended: the playground’s eight other features answered from fixed examples, two of them in a shape the API does not have, the overview’s counts and endpoints were invented, and the data, library and guide screens read nothing but the store. The sprint’s rule for the week is that a prospect sees nothing the API did not return.

Decision. indx-mock is deleted, and with it the browser store, the sample records, the dialogs and the topbar’s environment pill. A screen the API answers keeps only the part it answers, and says the API is unreachable when it is, with nothing shown in its place. A screen the API cannot answer yet (data, library, guide) has no package and no route: its sidebar entry stays, disabled and carrying a “not yet available” hint, so the blueprint stays visible and nothing on it pretends. The playground is one Encode run with no feature to pick; the features that had no backend (few-shot detection, relationships) leave the app and stay on the roadmap as planned. The layering is web → screen-* → {live, ux} → ui, one package shorter, and a screen test mounts its screen with the package’s own messages rather than through a shared helper. What was not chosen: keeping the store for the three unanswered screens, which would have kept the mock package, its layering edge and its notice for three screens nobody can use; and hiding those sections, which would have hidden the blueprint.

Consequences. ADR-0052’s store and its sample data are gone; its record stands as what was decided then, and this record supersedes that part (ADR-0056 already superseded its locale-root clause). frontend/scripts/check-boundaries.mjs drops the indx-mock edge. A backend for one of the disabled sections takes it out of the DISABLED set in the shell’s nav and gives it a package again. The persistence decision stays with the build order’s Phase 7, as ADR-0052 said.

Revisit when the first of the disabled sections has something to answer it, at which point it becomes a screen package over indx-live like the others.

ADR-0058 — An export format lives in the distribution that owns the graph, and the demo host is what serves it

Section titled “ADR-0058 — An export format lives in the distribution that owns the graph, and the demo host is what serves it”

Status Accepted · no version change; no wire change

Context. Step 5 of the product story is a customer’s own output format, and its first instance is a .drawio file: the process-chart parser already writes a slide’s symbols and connectors to the page block’s metadata["process_chart"] (ADR-0043), and a manufacturing engineer already has diagrams.net open. What was missing is only the writer. Three places could hold it. The browser could write the XML beside chart.ts, which parses the same graph already. indx-executor could write it, which would make it a property of every deployment. Or the capability distribution that reads the graph could write it, which leaves the question of how a browser reaches a package no first-party module may import.

Decision. indx_capability_process_chart.drawio.to_drawio() writes the file, taking the wire dict rather than the Chart dataclass, because the caller that wants a file is on the other side of the API and has only what to_json() put there. examples/app2.py serves it at POST /drawio, tagged as the demo host’s the way /renders and /samples are, and the playground’s Chart tab posts the graph it already holds and saves what comes back. The contract gains nothing: openapi.json has no export operation, no response that is not JSON, and no format field.

Three things follow from that placement. A format is a property of the thing that understands the format, so the writer sits beside the reader and a second chart dialect would be a second function there rather than a branch in the executor. The workspace boundary holds unchanged, because examples/ is outside the tree tests/unit/test_workspace_boundaries.py scans – the same licence examples/samples.py already uses to reach indx_dxf and indx_pdfium for a render. And the file is byte-stable by construction: no modified attribute, no agent string, no compression, two decimal places, so tests/fixtures/office-process-chart.drawio can be compared byte for byte and a drifting writer is a failing test rather than a surprise in someone’s editor.

Refused. Writing the XML in the browser. It is the smaller diff, and it makes the export a property of the demo UI: a CLI user, an API user and a second front end would each get nothing, and the format would be decided by whichever surface was written last.

Refused. An export operation in the indx contract this week. ADR-0045 already records that a recipe store and an export target are the two things that want state and that the decision gets made when one of them is built rather than postponed again. One file format behind a demo-host route is not enough evidence to shape that operation, and shipping the wrong one would be harder to withdraw than to add.

Consequence. A deployment of indx serve writes no .drawio file. The graph is on the wire for anyone who wants to write one, and this repository’s writer is one import away, but it is not an endpoint until the recipe work says what the endpoint should be.

Revisit when a second export format appears, or when the recipe store lands – at which point the question is no longer “where does this writer live” but “how does a deployment say which formats it offers”, which is the operation ADR-0045 deferred.

ADR-0059 — Mermaid draws the chart in the playground, and the capability writes the source

Section titled “ADR-0059 — Mermaid draws the chart in the playground, and the capability writes the source”

Status Accepted · no version change; no wire change · extends ADR-0058

Context. The Chart tab listed a process chart as text – role, label and shape:N per node – and, after ADR-0058, offered the graph as a .drawio download. A reader who does not open diagrams.net saw eleven lines and had to imagine the diagram. The docs sandbox has drawn the same chart since it shipped, as an overlay on the slide and as a node-link diagram over a hand-written layout: back edges by depth-first search, weakly connected components, levels by longest path from the sources. That sandbox is unreachable since the playgrounds were merged and is the first thing Sprint 2 deletes, so its drawing was never going to reach the app by itself.

Decision. The tab draws the chart with Mermaid, and the source is written by the capability that read the graph – indx_capability_process_chart.mermaid.to_mermaid(), beside to_drawio() for the reason ADR-0058 gives. The demo host’s POST /drawio becomes POST /chart/{format} over drawio and mermaid, since one graph written two ways is one route with a parameter and not two routes. The browser asks for the source, hands it to Mermaid, and shows what comes back.

The symbols survive the move, which was the thing worth checking before choosing: Mermaid 11.3 and later take a typed shape per node, and tri, circle and diam are the triangle, the circle and the diamond a 工程系統図 is drawn with. tri puts its apex at -h, which is up in SVG’s coordinates and the way the slide draws a material. An assembly is two nested triangles, which Mermaid has no shape for; it is the triangle it is built from with a thicker stroke, the same compromise the drawio writer already makes.

One Download menu replaces the single button, with four items. Two are files the capability writes – the .drawio and the Mermaid source, which is copied rather than saved because it is meant to be pasted into a wiki that renders it. Two are the drawing on screen: the SVG Mermaid returned, and that SVG through a canvas as a PNG. Those two are not exports and do not belong to the capability: they are a picture of what this surface drew, so the browser is exactly the right place to make them.

Refused. Porting the sandbox’s layout into the app. It is around 260 lines of graph algorithm to own, in a second copy, drawing a graph that a maintained library draws; and the port would have been a React rewrite of Preact rather than a move.

Refused. Generating the Mermaid source in the browser. It is twenty lines and it would make the app’s drawing disagree with what a CLI user gets the moment either changed. ADR-0058 refused this for the .drawio file and the argument does not weaken for a second format.

Consequence. The drawing depends on a demo-host route, so a deployment of indx serve shows the counts and the node list and no picture. That is the honest shape of it: the graph is on the wire for anyone who wants to draw it, and this repository’s writer is one import away, but the contract still has no export surface. The node list stays under the drawing rather than being replaced by it – it is what accounts for the picture, the stray text and the region the flow leaves out, and it is the accessible form of a diagram that is one image to a screen reader.

Mermaid joins the web app’s workspace, dynamically imported so only the playground’s route carries it. It cannot run under jsdom – the docs site needs a hundred lines of DOM shim to parse one diagram – so the unit tests mock it and assert the source reaches it, and the web app’s Playwright run draws it in a real browser and fails if the slide’s own labels are not in the SVG.

Revisit when a second surface wants the drawing, or when the recipe store lands and the question becomes how a deployment says which formats it offers – the operation ADR-0045 deferred and ADR-0058 declined to guess at.

ADR-0060 — The sales page may draw target output, labelled as such

Section titled “ADR-0060 — The sales page may draw target output, labelled as such”

Status Accepted · no version change; no wire change · narrows ADR-0057

Context. The locale root showed a headline and six steps, each with a screenshot of a real run. That was accurate, but it described what indx does without showing it. Landing concept H (examples/landing-claude/h-routed-by-indx.html) shows it instead. Three pages from the samples (the ministry notice’s page 22 and scanned page 5, and the process-chart slide) sit on a stage. Each one first shows the route indx chose for it. Hovering a block on the page then shows the block indx hands over: a whole paragraph with its defined terms, a table with normalized cells, or a diagram’s nodes and edges. Those blocks are what the contract should return, not what it returns today: encode answers with line chunks, and the paragraph, table and diagram shapes were merged from them by hand. ADR-0057 says the web app shows nothing the API did not return.

Decision. The sales page is ported to React as concept H, with its own palette, type and layout, a light and dark switch, and English and Japanese copy. Its stage may draw target output under three conditions:

  • The output is labelled as target output on the stage and in the page footer.
  • The pages and their text are the repository’s sample files, never invented documents.
  • The routes shown for each page are ones the router takes for that page today.

ADR-0057 still governs every workspace screen. The sales page is not one of them: it is rendered without the shell (ADR-0056), and it makes the case for the product rather than reporting on a host.

Refused. Drawing today’s line chunks on the stage. The line chunks are honest, but they cannot show the one thing the page argues, that a block keeps its meaning.

Refused. Keeping the six proof screenshots beside the new stage. They answered a different page, and keeping them would mean two stories. The screenshots, e2e/capture.spec.ts and just frontend::capture are removed; git keeps them.

Consequences. screen-home carries its own stylesheet, scoped under [data-screen="home"], and the demo’s data lives in demo-data.ts beside the copy. The page’s claims about output shape are claims about the roadmap, so a change to the planned block kinds is also a change to this page.

Revisit when encode returns paragraph, table and diagram blocks. The stage should then draw a real run of the same three pages, and this exception ends.

ADR-0061 — The production image carries LibreOffice, so the deployed playground draws slides

Section titled “ADR-0061 — The production image carries LibreOffice, so the deployed playground draws slides”

Status Accepted · no version change; no wire change · reverses one sentence of ADR-0043

Context. ADR-0043 kept LibreOffice rendering of a slide as a demo-host convenience and never a dependency of the image. But the image is the demo host: the deployed playground is examples/app2.py running in it. A PowerPoint upload there showed its chunks over a blank page while the same deck on a laptop with soffice on PATH showed the slide behind them, so the deployed playground looked broken on the one format its process-chart story is told with.

Decision. The runtime stage installs libreoffice-impress, and examples/samples.py finds soffice on PATH the way it does on a laptop. A deck names the fonts its author had, which a Linux host lacks, so the image also carries the fonts that stand in for them: Noto CJK for 游ゴシック, Meiryo and MS Gothic, replacing the one IPA font the drawing renders used, and Carlito, Caladea and Liberation, which share the metrics of Calibri, Cambria, Arial, Times New Roman and Courier New so a line wraps where PowerPoint wrapped it. Nothing else changes: the conversion stays behind the demo host’s /renders route, one at a time, each with its own profile directory, and a deck LibreOffice cannot convert still falls back to the page without an image.

Refused. Rendering slides from the DrawingML walk. indx_ooxml.drawingml locates shapes; it does not paint theme fills, pictures, gradients or text layout, and a partial painter would show a slide that is not the one in the file.

Consequences. The apt layer is about 473 MB, measured on linux/amd64. The API still never calls LibreOffice: only the demo host’s page images do, so indx serve and the contract are unchanged, and a deployment built from another image loses slide images and nothing else.

Revisit when image size or cold start becomes a cost on a target, at which point the conversion moves to a sidecar or a render job the demo host calls.

ADR-0062 — A worksheet renders as one image, and a Word document only as its one section

Section titled “ADR-0062 — A worksheet renders as one image, and a Word document only as its one section”

Status Accepted · no version change; no wire change · extends ADR-0061

Context. After ADR-0061 the deployed playground drew a deck’s slides, and a workbook or a Word document still showed no image. LibreOffice converts both, but its pages are printed pages, and the page indx counts is the division the format states (ADR-0043’s observer): a worksheet, a Word section. A worksheet prints across as many sheets of paper as its cells need, and a Word section, usually the whole document, prints as however many pages its layout takes. An image per printed page would put every image after the first on the wrong page.

Decision. The demo host exports a workbook with LibreOffice’s SinglePageSheets, so each worksheet is one PDF page however far it runs. A Word document with one section renders as one page: its printed pages stacked, the first 20 of them. One with several sections renders nothing, because LibreOffice does not say where a section’s pages begin. Any Office file whose printed pages do not match the pages indx counts renders nothing, which is also what a deck or workbook with a hidden slide or sheet gets, since the export leaves those out.

Refused. Showing printed pages beside indx’s pages as a separate strip. The playground draws indx’s pages; a second pagination next to them is a disagreement the reader has to resolve.

Consequences. The image adds libreoffice-calc and libreoffice-writer, and the apt layer grows from about 473 MB to 573 MB. A long Word document’s image is tall (about 21,000 px and several megabytes for 20 text pages at 96 DPI), and since a Word chunk carries no rectangle it is a picture above the chunk list rather than a backdrop under boxes.

Revisit when a Word reader locates its text, or when multi-section documents turn up in the samples, at which point the section boundaries come from the layout and not from the file.

ADR-0063 — The requester pins the reader with an allowlist and a no-fallback flag

Section titled “ADR-0063 — The requester pins the reader with an allowlist and a no-fallback flag”

Status Accepted · does not move POLICY_VERSION; RoutingConstraints gains two fields, so openapi.json and the generated clients move · the first of ADR-0050’s three additions

Context. ADR-0050 left the reader as the one component a request could not name: the ladder chose it and the five constraints only shaped the choice. Sprint 1’s mixed-page sample showed one document routed to two readers, and the question it left open was how a caller says which one.

Decision. Two fields on RoutingConstraints, both applied at plan time. capability_ids is an allowlist: empty means the ladder decides, as before, and otherwise a capability not on it is refused in the same place as the device and budget constraints. That covers nominated parsers, every rung and manual-review alike. Among the IDs it admits, the ladder still ranks them, and the allowlist’s own order means nothing. fallbacks_allowed=false keeps only each page’s selected candidate. An allowlist of one plus no fallbacks is a plan that names one reader per page. A page the allowlist leaves with nothing comes back as capability_ids: no capability is eligible for N of M pages, and an ID the snapshot does not hold as capability_ids: <id> is not in capability snapshot <id>. Both are unsatisfied plans, not 422s, the way an unknown embedding space is.

Refused. A version bump. Every request 0.9.0 accepted still plans byte for byte the same, and one carrying either field was a 422 before, so no input has two plans under one version, which is the case a bump exists to prevent. Bumping would have moved the snapshot ID and re-pinned the benchmark for no decision that changed. Also refused: a per-page pin. capability_ids applies to the whole document, and a page no ID on it reads is refused rather than routed elsewhere.

Consequences. The executor does not change. It runs selected and then fallbacks, and with none left, a failed read runs the route out, which is the existing 503 naming the page and the reader. That 503 used to mean only an install missing its terminal fallback. Now it can also mean a caller who refused fallbacks. The CLI and the web app expose neither field yet.

Revisit when a caller needs different readers for different pages of one document, which would make the allowlist per scope, or when ADR-0050’s two remaining additions ship.

ADR-0064 — Azure serves its hostname directly on a managed certificate, and its Cloudflare record is DNS-only

Section titled “ADR-0064 — Azure serves its hostname directly on a managed certificate, and its Cloudflare record is DNS-only”

Status Accepted · no version change; no wire change

Context. The Azure workflow had never been dispatched when its hostname was named: everything.az.indx.jp, two labels below the zone, like GCP’s everything.g.indx.jp. Cloudflare’s Universal certificate covers indx.jp and *.indx.jp and nothing deeper, which was verified at the edge before anything was applied: a handshake with the server name az.indx.jp gets the zone’s certificate, and one with everything.az.indx.jp gets an alert. The proxied shape ADR-0053 and ADR-0055 gave the stack could serve that name only with Advanced Certificate Manager on the zone, a paid feature, and it also needed a Cloudflare token carrying SSL and Certificates / Edit for the Origin CA certificate at the origin. The operator declined both: the token should edit DNS and nothing else, the way the AWS token does.

Decision. The record is DNS-only, made by hand, and the origin is the public host. The operator creates an unproxied CNAME to the app’s FQDN and the asuid TXT in the dashboard; the stack knows no DNS provider. The ingress carries no allow-list, and the certificate is Azure’s managed one, which Container Apps issues and renews for exactly that shape: a CNAME that resolves straight to the app, and an app DigiCert can reach over HTTP. The Origin CA certificate, the tls and cloudflare providers, the environment certificate, the custom-domain resource and the IPv4 ranges leave the stack, because the provider has no resource for a managed certificate and the records are not Terraform’s. The workflow’s deploy runs az containerapp hostname bind --validation-method CNAME once the revision is healthy, which adds the hostname, finds or issues the certificate and is a no-op afterwards; before that it resolves the two records against the app’s FQDN and verification ID and, when they differ, prints both records in the job summary and fails, which is how the first deploy ends, since the values exist only once the app does. The public smoke expects the origin to answer a direct client with a 200 rather than a refusal; the script grew that third expectation beside refused and 403, and the GCP path is untouched. What was not chosen: Advanced Certificate Manager and the wider token, which keep the perimeter for a monthly fee and a second permission; a one-label hostname, which keeps the edge certificate but still needs the Origin CA token; a token with DNS / Edit alone, which lets the stack write the records but is a credential the operator did not want to hold for two records; and a self-signed origin certificate under a per-hostname Full encryption mode, which needs a zone-settings permission instead and pins a dashboard setting nobody’s stack owns.

Consequences. Azure has no perimeter: the API is unauthenticated (ADR-0039) and reachable by anyone, and Cloudflare Access is not available on a DNS-only record, so authentication, when it comes, is the app’s or Azure’s. It holds no Cloudflare credential, and its state holds nothing secret. The first deploy is two cycles, one to print the records and one to bind, and every deploy after that binds nothing. Cold starts fail at the ingress rather than at the proxy. The Container App is named <project>-app, because the earlier <project>-indx-everything is thirty-six characters against Container Apps’ limit of thirty-two, found by the first local plan; the workflow reads that name, the environment’s and the resource group’s from Terraform outputs rather than deriving them. The environment declares the Consumption workload profile, because the first deploy’s 400 said a Consumption-only environment stops at 2 vCPU and 4 GiB and the profile goes to 4 and 8 while still scaling to zero. The record must stay unproxied and the ingress open, or the next renewal fails months after the change. ADR-0055’s Azure half is superseded. GCP has the same two-label hostname and still sits behind the proxy; it is not touched here.

Revisit when the hostname needs Cloudflare in front of it again, for Access or for the WAF, at which point the zone needs Advanced Certificate Manager and the stack takes back an origin certificate, or when Container Apps proves a managed certificate by a DNS record.

ADR-0065 — GCP serves its hostname directly through its load balancer, and its Cloudflare records are DNS-only by hand

Section titled “ADR-0065 — GCP serves its hostname directly through its load balancer, and its Cloudflare records are DNS-only by hand”

Status Accepted · no version change; no wire change

Context. GCP’s stack had never been dispatched when Azure went live (ADR-0064), and it had the same two problems: everything.g.indx.jp is two labels below the zone, which Cloudflare’s Universal certificate does not cover, and the stack wrote its two records with a Cloudflare token the operator does not want to hold. Its perimeter was a Cloud Armor policy admitting Cloudflare’s ranges, which without the proxy would refuse everyone.

Decision. The records are DNS-only, made by hand, and the load balancer is the public origin. The operator creates an A record to the balancer’s static address and the _acme-challenge CNAME the Certificate Manager DNS authorization issues; the stack knows no DNS provider, and the cloudflare provider, both records, the zone variable and the whole Cloud Armor policy leave it. The balancer, the DNS authorization, the certificate and its map stay: Google proves the certificate by reading the challenge record and never reaches the origin, so the closed run.app URL stays closed, and the balancer’s one certified hostname is the only name the service answers to. The workflow’s deploy resolves both records against the stack’s outputs after the service is ready and, when they differ, prints them in the job summary and fails, which is how the first deploy ends, since the address and the challenge exist only once the stack does; when they match it waits for the certificate to be ACTIVE before the smoke, whose origin check now expects a 200 from the address. What was not chosen: Cloud Run domain mapping, which needs no balancer but which Google’s own pages call not recommended for production and name asia-northeast1 among the regions where it adds high latency, and which needs the domain verified in Search Console under a person’s account with the deploy identity added as an owner; and Cloud Armor kept for the Host rule alone, a monthly cost for a refusal the closed run.app URL already makes.

Consequences. GCP has no perimeter: the API is unauthenticated (ADR-0039) and reachable by anyone, and Cloudflare Access is not available on a DNS-only record. The global forwarding rule is billed by the hour whether or not a request arrives, which is the price of Google’s supported path over domain mapping. The deploy account loses roles/compute.securityAdmin. The service’s invoker check is disabled rather than granted to allUsers, because the organization’s domain-restricted-sharing policy refuses that member, which the first apply found; the backend service names no timeout, which the same apply found the API refuses over a serverless NEG. The first deploy is two cycles, one to print the records and one to wait for the certificate, and every deploy after that checks the records and waits for nothing. Both records must stay unproxied, or the next renewal fails months after the change. ADR-0055’s GCP half is superseded in what writes the record, not in how the certificate is proved. All three clouds now sit under one zone with three shapes: AWS proxied, Azure and GCP direct.

Revisit when the hostname needs Cloudflare in front of it again, for Access or for the WAF, at which point the zone needs Advanced Certificate Manager and the Cloud Armor policy comes back, or when Cloud Run domain mapping leaves preview.