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 meaningful —
handwriting, 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-interfacesand it still depends on nothing, so nothing is inverted, andtest_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
titlerides 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-b32covers 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.