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.
Revisited 2026-09-18. The trigger had fired twice over: indx-chunker-pptx and
indx-chunker-lines are non-fallback chunkers beside the PDF one, and the sales page states an
opinion about boundary size. ADR-0067 gives
the chunker its name and its advertisement; the request field follows before any chunker competes
for the same pages.
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.
Revisited 2026-09-24. The date ceiling was paid for without a library, once the table harness
labelled a workbook and the grid chunker needed the cells a person sees. indx_ooxml.sheet reads
xl/styles.xml for the styles whose number format draws a date or a time, built-in (including the
East Asian date formats 27–36 and 50–58) or custom, and renders those serials as ISO 8601 in the
reader’s text and in the chunker’s cells alike. Every other format still yields the stored value:
thousands separators and fixed decimals draw the same number, while a serial and its date are
different text. Merged ranges are read too, by the chunker, as cell spans. Both stay inside
zipfile and xml.etree, so the decision stands.
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).
Revisited 2026-09-24. Per-cell table geometry left the out-of-scope list when a chunk could
state a table (ADR-0070).
The walk now gives a table shape its cells, each at the box its a:gridCol width and a:tr height
give once scaled to the frame, gridSpan and rowSpan as spans, and pptx@2 (opt-in) states them
on the table chunk. The process-chart parser ignores them, and version 1’s chunks do not move.
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.
ADR-0066 — gs: and az: are two more loader distributions, and they honour the vendor’s ambient credentials
Section titled “ADR-0066 — gs: and az: are two more loader distributions, and they honour the vendor’s ambient credentials”Status Accepted · no version change; resolvable gains a scheme per loader
Context. indx-loader-s3 showed the shape an object-store loader has: a distribution outside
the dependency tree, the SDK behind an extra, a settings model, a bucket allowlist. The feature page
listed gs: and az: as planned and said what each owed: credentials through INDX_*, “a
deliberate answer on whether the vendor SDK’s ambient ones are honoured”, an allowed bucket or
container list, one object and not a prefix. Two things had no precedent to copy. Azure puts a
storage account above the container, which neither S3 nor GCS has, and both SDKs read a whole
object in a way that is not one streamed response.
Decision. Two distributions, indx-loader-gs and indx-loader-az, named after the scheme as
S3’s is, with extras gs and az and prefixes INDX_LOADER_GS_ and INDX_LOADER_AZ_. Ambient
credentials are honoured, for the reason they are on S3: an explicit INDX_* credential wins, and
when it is unset Application Default Credentials or DefaultAzureCredential applies, so a service
account on Cloud Run or a managed identity on Container Apps needs no copied key. The allowlist is
the fence that second layer requires, unset meaning any, with one warning per process when neither
an allowlist nor an explicit credential is set. An explicit GCS key is the JSON in the variable and
never a path, because configuration is environment variables and a path is a second surface on
disk. A GCS endpoint override with no explicit key is anonymous: the SDK does the same under
STORAGE_EMULATOR_HOST, and the alternative sends the process identity’s token to whatever the
endpoint names. An az: URI is az://container/blob and the account is configuration
(INDX_LOADER_AZ_ACCOUNT, or the connection string), one account per deployment: the URI keeps
S3’s two parts and the allowlist stays a flat list, where az://account/container/blob would make
it pairs and let a connection string contradict the URI. A query or fragment is refused on both,
as ?versionId= is on S3, because ?generation= and ?snapshot= would silently fetch the live
object. Both read in 4 MiB chunks rather than S3’s 64 KiB: each chunk is one ranged request, so the
bound is a round trip as well as memory, and both SDKs’ defaults (40 MiB, and 32 MiB for a first
read) are no bound at all. Each bounds its failure by the operator’s timeout and not the SDK’s
patience: GCS retries under a deadline of that length, and Azure’s retries are off, because its
first backoff alone is fifteen seconds. The lines the three loaders share – the comma-separated list, the
allowlist check, the warning – are copied and not extracted: a plugin may not import a sibling,
and a fourth library beneath the plugins for thirty lines is an edge the graph does not need.
Consequences. fake-gcs-server and Azurite join Floci: just infra::gs::up and
just infra::az::up, pinned by tag and digest, and @gs and @az run on every CI run as @s3
does, keyed on INDX_TEST_GS_ENDPOINT_URL and INDX_TEST_AZ_ENDPOINT_URL. The production image
leaves both extras out with s3, for ADR-0039’s reason: the deployment has no bucket, and
ADR-0042 keeps URI sources off on every public host. google and azure join the imports banned
at module scope. The az extra carries azure-identity as well as the storage client, which is
what honouring the ambient chain costs. A GCS failure is reported by exception class (NotFound)
rather than by a service code, because that is what the SDK offers, so a missing bucket and a
missing object read alike.
Revisit when a deployment reads from two Azure accounts, at which point the account joins the
URI or the settings become a list; when the corpus run lands and needs a prefix listed, which is a
second method and not a change to fetch(); or when a third copy of the shared lines drifts.
ADR-0067 — A chunker is named by an ID and a version
Section titled “ADR-0067 — A chunker is named by an ID and a version”Status Accepted · no version change; chunkers joins the snapshot outside its content hash
Context. ADR-0012 left the chunker without a name and said what would end that: a second non-fallback chunker, or a consumer with an opinion about boundary size. Both have happened. Three non-fallback chunkers ship, and the planned paragraph, heading and table boundaries are a second answer for the same PDF pages the line chunker already claims, where the pptx and lines chunkers never competed with it. That is the case ADR-0026 exists for: two implementations resolved by install order is behaviour nobody can test for. It is also a case the other ID families do not have. A classifier’s second implementation is a different engine; a chunker’s is the same job done better, and the old answer has to stay installed beside the new one, because which boundary retrieves better is a measurement and a measurement needs both.
Decision. Chunker gains id: ChunkerId and version: int, one or greater. The ID names what
is cut (pdf, pptx, lines, page) and the version which way of cutting it; a better boundary
for the same pages is a new version of the same ID, in whatever distribution carries it, and the
old version keeps shipping. The pair is unique across an install and the registry refuses a
duplicate, a chunker that states neither, and a chunker ID that an annotator also claims: every ID
a request can carry lives in one namespace. The version is an integer and not the string a
descriptor carries, because “the newest version of this ID” is then a comparison and not a parsing
rule. The snapshot advertises every installed pair on chunkers, sorted, outside the content hash
for resolvable’s reason and more strongly: a chunker changes how execution output is cut, which no
plan records. Component gains version, stated by a chunker and by nothing else, so the trace
says which version cut each page. Refused: a version folded into the ID (pdf-v2), which makes two
answers to one question look like two unrelated things and leaves “the default for PDFs” with
nothing to hang on; and the distribution’s release number as the version, which moves on every
release whether or not a boundary did.
Consequences. The order chunkers are asked in does not change, so no output changes. ADR-0026
asks for the ID, the advertisement and the request field in one change, and this is two of the
three. The obligation is that no competing implementation ships without all of them, and none
does: the request field (EncodeRequest.chunking, naming pairs in the order they should be asked,
a version optional) lands before the first second version of any ID, with the marker that keeps an
installed new version out of the default order until a measurement promotes it. A third-party
chunker without an id and a version stops an install from building its snapshot, loudly, which
is the price of a name a request can rely on.
Revisit when a version needs to say more than an integer can (a model revision, a parameter set), at which point the embedders’ fingerprint is the shape with precedent; or when two distributions want to ship the same version of one ID, which would mean the ID was too broad.
Revisited 2026-09-24. The pair gained two advertisement-only fields, media_types and
summary, because a person choosing between versions in the playground was shown pptx@1 for a
PDF and pdf@2 with nothing saying what version 2 does. Both are read off the chunker as optional
attributes, like builtin, and neither gates anything: chunk_map still asks whoever is named,
and a chunker that claims nothing costs a page nothing, which is observable_media_types’ rule.
The playground offers whole sets, one version per family with a choice, and preselects the
newest: it is where a person goes to see what a new version changes, while the install’s default
stays the versions nobody opted in to.
ADR-0068 — A request names chunkers as a preference, and a new version waits to be named
Section titled “ADR-0068 — A request names chunkers as a preference, and a new version waits to be named”Status Accepted · no version change; EncodeRequest.chunking and invalid_chunker join the wire
Context. ADR-0067 gave the chunker its name and owed the request field before a second version
of any ID ships. Two questions had no precedent in the annotator families. What naming means: a
classifier named is the whole list, because nothing runs that was not named, but chunking has a
default that runs for everyone, and one chunker never serves a whole corpus. pdf claims nothing
on a slide deck, and a PDF’s unread pages are claimed only by the version that renders them. And
how a new version ships at all: the workspace and the production image install every distribution,
so a second version that joins the order on install changes every caller’s output the day it
merges, before anything has measured it.
Decision. EncodeRequest.chunking.chunkers is an ordered list of {id, version?} and states a
preference: the named chunkers are asked first, in order, and the installation’s own order follows
for every page they did not claim. A request therefore says only what it has an opinion about, and
[{"id": "page"}] still forces the floor, because the floor claims every page. An ID without a
version resolves to the version asked by default, and to the newest where none is. A version that
sets opt_in = True, off the protocol like builtin and fallback, is left out of the
installation’s order and runs only when named; the snapshot says so with default: false. At most
one version of an ID may be a default and the registry refuses two. The refusals are one family,
422 invalid_chunker, raised before the fetch: unknown_chunker, unknown_chunker_version, and
granularity_required. Refused: an exhaustive list, under which naming pdf version 2 returns no
chunks for a deck and no image for an unread page unless every new version reimplements what the
old one already does; and an extra per version, which moves nothing, because CI and the image
install every extra.
Consequences. A request that names nothing gets the output it got before this change, with any
number of versions installed; that is the property the opt-in marker exists for and the executor’s
tests pin it. A named version that raises is skipped like any chunker and the default order serves
its pages, so the trace, which names the version that cut each page, is where a caller checks that
its preference held. Promotion is moving the marker from one version to another, in one line, when
a measurement says so. The registry keeps returning every installed chunker; what a run asks is
the executor’s decision, in indx_executor.chunking.
Revisit when a caller needs the preference to be exclusive (a benchmark that must fail rather
than fall back), which is a flag on chunking and not a change of default; or when a deployment
wants its own default order, which is the INDX_CHUNKERS setting the feature page plans.
Revisited 2026-09-18. The second trigger was taken at once, because the request field alone
leaves a deployment unable to adopt a version for callers that name nothing. INDX_CHUNKERS is that
order, with the request field’s meaning, behind the request’s names. An entry nothing installed can
serve raises naming the variable before the fetch: a 422 would blame a request that named nothing.
ADR-0069 — Paragraphs are version 2 of the PDF chunker, in their own distribution
Section titled “ADR-0069 — Paragraphs are version 2 of the PDF chunker, in their own distribution”Status Accepted · no version change, no wire change
Context. The line chunker cuts where the glyphs sit: a wrapped sentence is two chunks and a paragraph a dozen, so each vector carries a fragment. The feature page called merging lines into paragraphs the candidate for a better default and said which boundary retrieves better is a measurement. ADR-0067 and ADR-0068 exist so that the candidate can ship beside what it may replace. What was left to decide is where it lives, what it shares, and ADR-0030’s trigger, which names this change: a chunker that rewrites text.
Decision. indx-chunker-layout ships pdf version 2, opt_in. A new distribution rather than
a second class in indx-chunker-pdf, because this is the one that grows (headings, furniture,
continuation, tables all need one chunker to claim the page, since a page has one writer) and the
line chunker is the one that must not move. The two share indx_pdfium.lines and
indx_pdfium.normalized_box, moved beneath them for that purpose. Grouping is geometry alone, every
threshold a multiple of the line’s own height: a vertical gap, a change of size, a first-line
indent, text order going back up the page, or a previous line that stopped well short of the
page’s text margin ends a paragraph. The last rule is what keeps an invoice’s fields apart, and
its ceiling is stated in the code: on a two-column page every left-column line reads as short, and
the answer degrades to version 1’s lines, never to something worse. Lines join with a space, or
with none where either side is CJK; a hyphen at a line end is left alone, because rejoining the
wrong one invents a word the document never carried. INDX_CHUNK_LAYOUT_MAX_CHARS closes a
paragraph that would run past it. Version 2 renders nothing: a page no reader read is version 1’s,
which a named version leads and never replaces.
ADR-0030 holds. A span is an offset into the text of the block that names it, a chunk extractor
is handed exactly the chunk block’s text, and the page block keeps the reader’s text, so a joined
paragraph moves no offset anyone holds. What it does mean is that a chunk span under version 2 and
one under version 1 are not comparable by offset, which they never were across chunkers.
Consequences. Nothing changes for a request that names nothing. The paragraph is a chunk
block with the union box; the count of lines it joined, and the paragraph kind the sales page
draws, wait for the block-shape decision. Promotion to the default is one line and needs a
retrieval measurement that does not exist yet.
Revisit when the retrieval harness reports both versions, which decides the default and should move the thresholds; or when a two-column document matters, at which point columns are found before lines are grouped.
Revisited 2026-09-18. The first trigger fired: indx-bench retrieval reports both versions.
Over 14 queries on two documents in minilm-multilingual, paragraphs reach recall@1 29%,
recall@5 57% and MRR 0.43 against the lines’ 21%, 50% and 0.33, with a hit of 201 characters
against 60. The direction is the one the feature page guessed and the sample is too small to act
on, since one query is seven points, so version 2 stays opt_in and the thresholds stay where
they are. What promotes it is the same table over enough queries that one of them is not a column.
lines has a version 2 in the same distribution, for the same reason and by the same function:
the lines an OCR reader located on PageOutput.lines, grouped into paragraphs. Its boxes are
fractions of the page, so the indent rule compares a width fraction with a height fraction; the
ceiling is stated in the code, and the reader stating the page’s size is the fix.
Revisited 2026-09-25. Tables grew here, as the decision said they would. pdf version 3 is
version 2’s paragraphs plus ruled tables (ADR-0070’s layout.table): indx_pdfium.rules reads the
page’s path segments inside the lock, and indx_chunker_layout.tables turns the ones that cut two
rows and two columns into a grid, a missing rule becoming a span. It is a third version rather than
a change to the second because it moves the second’s output wherever a table was: those lines
leave the paragraphs.
ADR-0070 — A chunk states its role and a table its cells, beside the text and not as a block kind
Section titled “ADR-0070 — A chunk states its role and a table its cells, beside the text and not as a block kind”Status Accepted · wire change, additive
Context. A table reaches a caller today as text: a worksheet’s rows tab-separated, a delimited
file as it was written, a PDF table as lines in reading order. The sales page draws one table
block with its columns and a box per cell (ADR-0060), the build order asks for the block shape to be
settled once rather than a wire change per kind, and every table source – CSV, TSV, XLSX, DOCX,
PPTX and a PDF page – needs the same answer before a chunker can give it. The obvious move is a
table member on BlockKind, and it is the wrong one: BlockKind is also what a request’s
granularities names, so a new member would be a granularity a caller could ask for and nothing
could honour, and each later kind (paragraph, heading, diagram) would move the enum again.
Decision. A chunk carries an optional layout: a ChunkLayout with a role (paragraph or
table today) and, for a table, a Table of rows, columns, header_rows and cells. A
TableCell names its 1-based row and column, a row_span and column_span for a merged
cell, its text, and a bbox where the format has positions. Cells may be sparse, may not
overlap and must fit the grid; the models refuse the rest where a chunker builds them. A chunker
sets it on ChunkPiece.layout and the executor copies it to the chunk block untouched; only chunk
blocks carry one, and never an image chunk, because a rendered page is everything on it.
The chunk’s text stays the plain rendering of the same content: a table’s rows on lines and its
cells tab-separated, which is what the Office reader and the slide walk already emit. So an
embedder, an extractor’s offsets (ADR-0030) and a caller who reads only text see what they saw
before, and the structure is beside the text rather than instead of it. A table longer than a chunk
should hold is split by rows, and each part repeats the header rows in its text and its cells, so
every chunk reads on its own.
A later role – a heading with a level, a diagram with nodes and edges, page furniture – joins
ChunkRole and adds its own optional field to ChunkLayout, which is additive on the wire.
Consequences. One additive wire change, three regenerated clients, and no producer: the
chunkers that fill layout land one per format after this. A cell carries the printed text only;
a normalized value beside it (0円 → 0 JPY) is the sales page’s other promise and waits for the
meaning-on-blocks work, because it rewrites what a cell says.
Revisit when a consumer needs a role this shape cannot state without a second structure beside
table, or when a table must be addressed across chunks as one object rather than by its repeated
header.
ADR-0071 — Kept documents sit behind two ports, a record store and a vector index, scoped by an owner every call names
Section titled “ADR-0071 — Kept documents sit behind two ports, a record store and a vector index, scoped by an owner every call names”Status Accepted · wire change, additive (contract only; the implementations and routes follow)
Context. The playground keeps what it encoded in the browser: an IndexedDB store per tab profile, searched there by cosine and BM25. A document encoded on one device is gone on another, the ranking lives in TypeScript where no other client can reuse it, and the blueprint’s data store and search screens have nothing on the server to answer them. ADR-0049, ADR-0052 and ADR-0057 all left persistence to one decision in Phase 7, with spaces, the feature schema and the few-shot library as its first consumers, and this is that decision’s first half. The backends it has to survive are named already: an in-memory store for the demo host now, pgsql with pgvector and a vector database such as qdrant later, and nobody wants the protocol rewritten when they arrive.
Decision. Two ports in indx-interfaces, not one. DocumentStore keeps a
StoredDocument – a StoredDocumentSummary (digest, name, media type, page and chunk counts,
the spaces its vectors are in, and when it was created, updated and expires) beside the whole
EncodeResult, so a reader can re-render it. VectorIndex keeps a ChunkPoint per chunk – its
text, page and box, and one BlockEmbedding per space it was embedded in, possibly none – and
answers a ChunkQuery with ChunkHits, best first. The cut is where the backends split:
pgsql implements both, qdrant only the index and pairs with something else for the records, and
the in-memory store implements both. One port would have made qdrant fake a record store, or been
split later, which is the refactor this record exists to avoid.
A point is a chunk rather than a vector, because the playground finds a chunk by its words even
when no embedder ran, and a point per vector would have lost every chunk encoded without one. A
ChunkQuery is one leg: a vector in the space it names, or a text. Both later backends answer
both legs natively (pgvector and tsvector; qdrant’s dense and sparse vectors), so the lexical leg
is in the contract from the start rather than a second port later. It is one leg per query rather
than both because a similarity and a term score share no scale: the caller runs each leg and fuses
by rank, and keeps each leg’s own score to show, which a backend’s fused number would have hidden.
The owner is an OwnerId and never a field on these models: it is the first argument of every
method but purge, so a backend cannot forget to scope a call and nothing on the wire can name
another owner. Where it comes from is the transport’s business – an anonymous cookie on the demo
host, an authenticated principal once the server has one (ADR-0042’s trigger) – and neither
changes the ports. Within an owner a document is keyed by its source digest, so keeping the same
bytes again replaces it and a retried call converges.
Expiry is a deployment setting written as expires_at on both ports, because an index that holds
only points cannot join to the records to learn it. It is enforced on read: get, list and
search never return anything past it, and purge(now) only reclaims space. A backend that
purges lazily – a pg cron job, a qdrant payload filter – is therefore still correct. The ports
are synchronous, like the facade (ADR-0024).
Backends are discovered through their own entry-point groups, indx.document_stores and
indx.vector_indexes, and chosen by name per deployment, so the two can differ. They are not
indx.capabilities providers: a store is never planned, and joining the capability snapshot
would move its hash for something no plan depends on. What was not chosen: keeping the records in
the browser and only the vectors on the server, which leaves the list on one device; and storing
under the caller’s EncodeRequest.metadata, which is the caller’s labels (ADR-0019), not an
access boundary.
Consequences. ADR-0049’s “indx does not own your index” now reads: not for longer than a deployment’s TTL, and not at all unless the deployment names a backend. This record adds the contract only. The in-memory backend is its own distribution, the server’s routes and owner cookie follow it, and the playground moves off IndexedDB last; each is a later change against these ports. A later backend passes the same conformance suite the in-memory one does. The in-memory backend bounds what it holds in bytes as well as in documents, and drops its oldest when the process nears its memory limit, because a count does not bound memory.
Revisit when a consumer needs a document kept under two digests (versions of one file side
by side), when records outgrow holding the whole EncodeResult, or when the server gains
authentication and an owner becomes a principal rather than a cookie.
ADR-0072 — The demo host is a package, and the one composition root that may import plugins
Section titled “ADR-0072 — The demo host is a package, and the one composition root that may import plugins”Status Accepted · no wire change
Context. The host that serves the documentation site, the web app and the API on one origin
grew in examples/app2.py and examples/samples.py to about 800 lines: sample files, page
renders through PDFium, LibreOffice and ezdxf, an encode replay cache, chart export and the
static mounts. examples/ sits outside the workspace-boundary scan, which is what let it import
indx_capability_process_chart and indx_store_memory, and outside coverage, because its tests
ran it in a subprocess. The production image runs it, so the code with the least testing was the
code every visitor reaches.
Decision. The host is the workspace package indx-app-demo (indx_app_demo), with
create_demo_app(root) as its one entry: the API from create_app(), then samples and renders,
chart export, the replay header, and the site and web app mounted last. examples/app2.py is one
call, handing over the checkout it sits in, so uvicorn examples.app2:app stays the command the
image, just demo::site and Playwright run. A path is an argument rather than a setting: the
image installs the package into site-packages, where its own file says nothing about the
checkout, and the checkout is where app2.py already is. What was module state (the replay
cache, the held uploads, their page cache, the sample index) is built per app, so a test builds
as many isolated hosts as it needs. The package is a composition root: it may import plugins and
the libraries beneath them, because what it serves is what they write, and nothing may import it
back, which test_workspace_boundaries.py checks.
Consequences. The host counts toward the 95% gate. scalar-fastapi is its dependency rather
than a root demo group. First-party code still may not import a plugin; this package is the
named exception, and a second host that needs one is where this is revisited.
Revisit when a second deployable needs the renders or the chart export, which would move them behind a port of their own.
ADR-0073 — The roadmap targets the context-layer blueprint, and nothing on a screen is sample data
Section titled “ADR-0073 — The roadmap targets the context-layer blueprint, and nothing on a screen is sample data”Status Accepted · no version change; no wire change
Context. ADR-0049’s revisit trigger fired on 2026-09-28: a dated successor to the blueprint
landed in public/target/, 2026-09-28-context-layer.en.html. It reframes the product: routers,
one per job, sit between a customer’s own types, labels and spaces and every downstream task
(Parse, Extract, Tag, Summarize, Search, Compare), and every correction becomes data that makes
them better. Its navigation is Improve, Use and Operate rather than the 2026-09-10 layers, and its
source says every score in it is “an illustration rather than a measurement”. Much of it is
metrics, charts and sample documents that a product can only show once something produced them.
Decision. The roadmap is read against the context-layer blueprint, every screen of it planned,
ordered from what is front-end work over today’s API to what needs a component nobody has started.
A screen nothing answers yet is a disabled sidebar entry or a placeholder page that says in words
what will come; it never shows a number, a chart, a sample document or a sample answer the service
did not produce, which is ADR-0057’s rule stated for the whole blueprint. The states keep their
meaning, and Done is judged against what the new blueprint’s screen does, so a web-app screen
built for the 2026-09-10 blueprint does not carry it over. The previous roadmap moves to
developer/roadmap-2026-09-10 and the build order is kept beside it for the record.
Consequences. No screen is Done. The first phase is the web app’s navigation realigned to
the blueprint, with today’s screens mapped onto it and the rest disabled. The persistence decision
ADR-0049 deferred is partly taken by ADR-0071; what the new blueprint adds to it (types, labels,
router versions, run records) waits on a persistent store backend beside memory.
Revisit when a dated successor lands in public/target/, or a screen reaches Done and the
blueprint’s version of it has moved on.
ADR-0074 — A router binds into the plan by ID and version, and every branch it may take is named there
Section titled “ADR-0074 — A router binds into the plan by ID and version, and every branch it may take is named there”Status Accepted · nothing is built yet; the step that builds it moves POLICY_VERSION,
openapi.json and the generated clients
Context. The context-layer blueprint (ADR-0073) puts a router, one per job, between a
customer’s types and every downstream task. Its route for a page depends on the page’s type.
Roadmap phase 2 builds it in two steps. The first is read-only: the Routers screen draws the
ladder from the snapshot and previews a document’s route from POST /v1/plan. The second is a
router that is stored and versioned, and that choice needs Phase 3’s types. The risk the
architecture note ranks first sits between the two steps. A type is often known only after a page
is read, and letting that answer steer execution is where “execution only uses routes the plan
named” could quietly erode. This ADR decides that seam before step 2 is built.
Decision.
- A router is request data.
PlanRequestandEncodeRequestgain arouter. It is either a reference (ID and version), which a store above the facade resolves, or an inline configuration. The router and the executor do no I/O and import no store, the wayRoutingConstraintsarrives today. No router means the ladder, as before. - The plan names the router.
RoutePlangainsrouter: {id, version} | null, besidesource_digest,capability_snapshot_idandpolicy_version, andplan_idhashes it. That changes the artifact, soPOLICY_VERSIONmoves (ADR-0005). A plan made under one router version is refused byencodeunder another, the same way a stale snapshot is refused. - The reader follows a type only on plan-time evidence. That evidence is a format signature
(ADR-0003’s nomination door), the media type, or a label a person approved for the same
source_digest, passed in as request data. Without such evidence the ladder chooses the reader. - Everything after reading branches, and every branch is in the plan. For each type it knows, the router’s plan lists what that type would use: its chunkers, its extractor, classifier and enricher IDs, and its spaces. It also lists a default branch. The plan names the recognizer that picks a page’s branch, and the executor asks it after the page is read. A page whose type is not in the list, or whose answer falls below the router’s threshold, takes the default branch and is marked for review. The executor never runs a lane, a chunker or a space that no branch of the plan names. The trace records which branch each page took.
- The “not sure” threshold applies after reading, to the recognizer’s answer. The planner still reads no confidence.
Refused.
- A reader chosen from a type learned by reading the page. It would mean reading before planning, and planning never invokes expensive processing.
- An executor that resolves a router reference itself, or that picks a branch the plan did not list.
- A router as an entry point. A router is a customer’s data, not an installed distribution, so it never moves the snapshot hash.
Consequences. Step 2 waits for Phase 3’s types and for a persistent store beside memory.
The Routers screen’s canvas grows a row per branch; today it draws one row. Every plan’s
plan_id changes with the version bump, including the plans of callers that name no router, and
the pinned benchmark snapshot moves with it. The ladder stays the default.
Revisit when a signal cheap enough to compute at plan time can recognize a learned type before any page is read, which would let the reader follow it. Also revisit when a job needs a branch chosen per region rather than per page.
ADR-0075 — Document types belong to the installation, and only the operator’s token writes one
Section titled “ADR-0075 — Document types belong to the installation, and only the operator’s token writes one”Status Accepted · no version change; the wire gains /v1/types, so openapi.json and the
generated clients move
Context. Routers step 2 (ADR-0074) needs a customer’s document types, and the Data & labels
screen defines them. Today the only owner is the indx_owner cookie: one per browser, expiring
with the document TTL, and anyone can mint one. A type keyed by it would vanish after a day and
be invisible to a colleague, while a router over it is meant to be shared and kept. There is no
authentication yet to say whose workspace a request belongs to.
Decision.
- One workspace per installation. Types are shared by everyone on the host and never expire.
They sit behind a third store port,
SchemaStore(put,get,list,delete), shaped likeDocumentStore: the owner comes first on every method, and the server passes one constant workspace. When authentication lands, a workspace per account is a change of that argument, not of any backend. - The backend is an entry point in
indx.schema_stores, named byINDX_SCHEMA_STORE, like the two stores of ADR-0071. It never joins the snapshot hash.memoryandsqliteship it. - Only the operator writes.
PUTandDELETE /v1/types/{id}needAuthorization: Bearer <INDX_WORKSPACE_TOKEN>, compared in constant time. A missing or wrong token is401. A deployment that sets no token is read-only, and a write is403 workspace_read_only.GET /v1/typessays whether writes are taken (writable), so a screen can say why it cannot save. - A type names its signature by its parser.
signatureis the capability ID of the parser whose format signature recognizes the type, such asinvoice-parser, checked against the snapshot’s parsers. It is not the signature’s ID, because the snapshot lists every installed parser and no signature. One that is not an installed parser is422 invalid_type. - A type is a key, a label and fields. Keys and field names match
^[a-z][a-z0-9_]{1,39}$. A field’s kind is one oftext,date,amount,id,companyorcategory.
Refused.
- Types owned by the cookie, which expire and are private to one browser.
- A type on
EncodeRequestorPlanRequest. A router carries types into a request in step 2; a bare type there would decide nothing. - A generic key-value store for workspace state. The ports split by aggregate (types now, labels and routers later), the way ADR-0071 split records from points.
Consequences. A deployment that wants types kept across a restart names sqlite (or, next,
postgres) for INDX_SCHEMA_STORE. The demo host falls back to memory, writable only with the
token. Nothing counts examples or measures accuracy per type until labels exist (Phase 3b).
Revisit when authentication names who is asking, so each account or team gets its own workspace, or when an operator needs to delegate writing types without sharing one token.
ADR-0076 — A reviewed answer is a label the installation keeps, copied out of the document it came from
Section titled “ADR-0076 — A reviewed answer is a label the installation keeps, copied out of the document it came from”Status Accepted · no version change; the wire gains /v1/labels, so openapi.json and the
generated clients move
Context. Phase 3b is the review queue: a person approves, corrects or rejects what a lane
answered, and the verdict is kept as a labelled example for the datasets (3c) and the
experiments (Phase 4) to read. What a lane answered lives in a kept document, which belongs to
the indx_owner cookie and expires with the TTL (ADR-0071). A labelled example must outlive
both: it is the installation’s data, like its types (ADR-0075), and a dataset built last month
must still hold it when the document it was read from is gone.
Decision.
- A fourth store port,
LabelStore(put,get,list,delete), an entry point inindx.label_storesnamed byINDX_LABEL_STORE, owned by the installation workspace, never expiring, never in the snapshot hash.memory,sqliteandpostgresship it. - A label copies what it points at. It carries the document’s digest and name, the block ID, the page and box, and the answer itself: an entity’s label, character range and surface, or a facet’s name and label at the document, a page or a chunk. Nothing in it needs the kept document to be read again.
- A verdict is one of three.
approved,correctedwith the right value (the surface for an entity, the label for a facet), orrejected. A correction is required withcorrectedand refused with the other two. - The ID is the target. A label’s ID hashes the digest and what it answers (the block, and the entity’s label and range or the facet’s name), so reviewing the same answer again replaces the verdict rather than adding a second one.
- A label may name a type’s field (
type_id,field), checked against theSchemaStore; one that names no stored type or field is422 invalid_label. - Only the operator writes, with the same token as types:
401without it,403 workspace_read_onlywhen the deployment sets none.revieweris a name the client states, not an identity the server checked. - There is no queue table. The queue is a kept document’s answers less the labels whose digest is that document’s, computed where it is shown.
Refused.
- A label that references the kept document instead of copying it, which dies with the cookie’s TTL.
- A queue the server keeps. It would be a second record of what the kept result already holds, and would drift from it when a document is kept again.
- A verdict on the whole document’s answer at once. A dataset needs the unit a person checked.
- Labels in the
SchemaStore. The ports split by aggregate (ADR-0075).
Consequences. A deployment keeps labels across a restart by naming sqlite or postgres
for INDX_LABEL_STORE. The deployed hosts set no token and stay on memory, so they show no
labels and take none. A label says which answer it judged but not which lane gave it. The kept
result names each lane’s answers (ADR-0079); the label does not copy that name.
Revisit when authentication names the reviewer, when a label must point at a region no lane answered (an entity the lanes missed), or when datasets (3c) need a label to name the split it belongs to.
ADR-0077 — A dataset is a set of reviewed documents with a purpose, and no document both teaches and grades one type
Section titled “ADR-0077 — A dataset is a set of reviewed documents with a purpose, and no document both teaches and grades one type”Status Accepted · no version change; the wire gains /v1/datasets, so openapi.json and the
generated clients move
Context. Phase 3c builds datasets from the labels of ADR-0076: one set to teach and one to evaluate, so that Phase 4 can score a route on pages a person checked. A label judges one answer; an experiment scores a page. And a page that taught a route must not also grade it, or the score measures memory.
Decision.
- A fifth store port,
DatasetStore(put,get,list,delete), an entry point inindx.dataset_storesnamed byINDX_DATASET_STORE, owned by the installation workspace and written with the operator’s token like types and labels.memory,sqliteandpostgresship it. - A dataset is a key, a name, a purpose and documents. The purpose is
trainingorevaluation. It may name the type its documents are, checked against theSchemaStore. Each document is a digest with its name copied, so a dataset outlives the kept document too. - Membership is by document. Adding a document brings every verdict kept on its digest; a dataset never holds half a document’s labels.
- The evaluation set is kept apart. A digest in an
evaluationdataset may not be in atrainingdataset of the same type, and the reverse. Breaking it is422 invalid_dataset, naming the digest. - A dataset is a live view. Its examples are the labels its documents have now:
GET /v1/datasets/{id}/labels. A correction made tomorrow is in tomorrow’s dataset. Phase 4 records the labels each run scored, which is what makes a run reproducible.
Refused.
- Datasets inside the
LabelStore. A dataset changes when a person curates it, a label when a person reviews; the ports split by aggregate (ADR-0075). - Membership by label, which lets one document sit half in training and half in evaluation.
- Frozen versions now. Nothing reads a version until an experiment runs, and the run is where the snapshot belongs.
- A split named on each label, the event ADR-0076 said would reopen its shape. The split belongs to the dataset.
Consequences. A reviewer tags a verdict with the type and field it fills, so an evaluation set can be scored field by field. Nothing measures anything yet: a dataset shows its document and example counts, which are counts of stored rows. The blueprint’s other sources (an LLM oracle, synthetic variations) are Phase 9.
Revisit when an experiment needs a dataset frozen apart from a run, when a dataset must hold queries or pairs rather than documents (search and spaces, Phase 6), or when two types share pages.
ADR-0078 — A router version is a branch per type, a default branch and a recognizer, kept by the installation and resolved before the facade
Section titled “ADR-0078 — A router version is a branch per type, a default branch and a recognizer, kept by the installation and resolved before the facade”Status Accepted · POLICY_VERSION 0.10.0; the wire gains router on PlanRequest,
EncodeRequest and RoutePlan, and /v1/routers
Context. ADR-0074 decided how a router binds into the plan and left its shape to Phase 2’s second step. Phase 3 has since given the installation its types, the verdicts people approved and a persistent store, which is what that step was waiting for.
Decision.
- A router version is data. It is a key, a version number and a spec: a label, one branch
per document type the workspace holds, a default branch, and optionally a recognizer. A
branch names a reader to pin, chunkers, classifier, extractor and enricher IDs per unit, and
embedding spaces. Every ID must be installed when the version is published, or the publish is
422 invalid_router. - Versions are immutable. Publishing writes version n+1. Restoring an old version publishes its spec again as a new version, so a plan that named a version can always be read back.
- A sixth store port,
RouterStore, inindx.router_stores, named byINDX_ROUTER_STORE, owned by the installation workspace and written with the operator’s token.memory,sqliteandpostgresship it./v1/routersserves it. - The server resolves, the facade receives data. A request names
router: {id, version?}; without a version it is the latest. Before the facade, the server turns the reference into the resolved router: the version’s spec, each branch’s format signature (its type’s parser), and the digests whose approved verdict on the recognizer’s facet names that type. The router and the executor do no I/O. A bare reference that reaches the facade is422 invalid_router. - The router names the lanes. A request that names a router and also its own chunkers, classifiers, extractors, enrichers or spaces is refused as an invalid request (422).
- The plan names the version and lists every branch.
RoutePlan.routeris{id, version};RoutePlan.brancheslists each branch with the pages typed before reading and the evidence (signatureorlabel);RoutePlan.recognizeris the recognizer. A branch’s reader pin applies only to those pages. Every other page is read by the ladder. - The executor picks one listed branch per page. A page typed before reading keeps that branch. Otherwise the executor asks the recognizer’s classifier about the page, and takes the branch of the top label on its facet when that label is a listed type and its confidence reaches the threshold. Otherwise the page takes the default branch and is marked for review. It runs only that branch’s chunkers, lanes and spaces on the page, and the document-unit lanes of the branch most pages took. The trace records each page’s branch, evidence, confidence and review mark.
- A plan made under one version is refused under another,
409 plan_conflictwithparam="plan.router", like a stale snapshot.
Refused.
- A router active by default for the installation. A plan changes only when its caller names a router.
- Editing a version in place, which would change what an old plan meant.
- A recognizer threshold read by the planner. The threshold applies to an answer the page gives after it is read (ADR-0074).
- A signature ID on the type. A type names its parser (ADR-0075); a parser that recognizes two types sends a page typed by signature to the first branch that names it, by type key.
Consequences. Every plan’s plan_id moves with the version bump, including plans that name
no router, and the benchmark’s pinned snapshot moves with it. The Routers screen edits, publishes
and restores versions; the Data store and the route preview name a router or none. Phase 4 can
promote a winning route by publishing a version.
Revisit when a type needs a branch per region, when a recognizer is learned from examples (Phase 5), or when links become a branch step (Phase 7).
ADR-0079 — Every lane’s own answer is kept beside the merge, and every named lane is asked
Section titled “ADR-0079 — Every lane’s own answer is kept beside the merge, and every named lane is asked”Status Accepted · no version change; block metadata gains the reserved key lanes;
openapi.json does not move
Context. The executor merges the annotation lanes a request names per unit: the first
enabled classifier with an opinion wins each facet (ADR-0018), the first extractor each label,
the first enricher each part (ADR-0037), and the losers’ answers are dropped. The trace names
only the winners (ADR-0027). A classifier that declared a facets attribute was skipped, never
called or billed, once earlier classifiers had taken all its facets. The Extract, Tag and
Summarize screens show what one lane answered, the answers it lost included. Filtering the merged
answer by the trace would show a lane answering less than it did, and guessing the rest is the
invented data ADR-0073 refuses.
Decision.
- The merge is unchanged.
classification,chunk_classification,entities,enrichmentandchunk_enrichmentkeep what each lane won, and the trace still names winners only. - Each lane’s whole answer rides beside the merge, under the reserved metadata key
lanes,{lane ID: {...}}, where{...}holds the merged keys that lane answered, in their shape: on the document blockclassification,chunk_classification,entities({by_block, counts}, the counts over that lane’s spans alone),enrichmentandchunk_enrichment; on a page blockclassificationandenrichment. A lane with no opinion has no entry, and a block no lane answered has nolaneskey. - Every lane a request names is asked. The
facetsattribute and the skip are gone, so a classifier’s answer no longer depends on what was asked before it. lanesis reserved. A caller supplying it inEncodeRequest.metadatais refused like the other reserved keys.
Refused.
- Attributing answers by guessing from the trace. The trace names who won, not what the losers said.
- A per-lane record in the trace. The trace is attribution, not output.
- Keeping the
facetsskip. A lane’s answer would depend on what was asked before it, andlaneswould show a lane silent that was never asked. - An opt-in request flag. It is one more knob, and every screen that reads a lane would set it.
Consequences. A request naming lanes that overlap costs more, because every one is called
and billed. The document block’s metadata grows with each lane asked. Nothing routes on the new
key, so the plan, the snapshot hash and openapi.json (Block.metadata is a free object) do not
move.
Revisit when the metadata of many lanes becomes too large to carry on every block, or when a lane’s answers must be compared across documents (Phase 4).
ADR-0080 — The stateful domain is a package beside the facade, and the server is transport only
Section titled “ADR-0080 — The stateful domain is a package beside the facade, and the server is transport only”Status Accepted · no version change; no route, model or variable moves; the pinned dependency
graph gains indx-workspace
Context. What an installation and its visitors keep grew inside indx-app-server: Library
(kept documents and search, ADR-0071) and Workspace (types, labels, datasets and routers,
ADR-0075 to ADR-0078), beside the FastAPI routes. Neither imported FastAPI. Phase 4 adds
experiments, promotion and the first work that outlives a request, and a second transport would
have had to import the HTTP adapter to reach any of it.
Decision.
indx-workspaceholds the stateful domain.Library,Workspace,load_backendand the settings that name stores and the operator’s token move there unchanged.- It depends on
indx-interfacesalone. A backend is reached through its entry point. A facade operation the domain needs, such asembedfor a search, is handed in as a callable, so the package imports neither the facade nor a stage. indx-app-serveris transport. It keeps the routes, the request deadline, the error envelope, and the owner cookie with its hash (owner.py): who is asking is a property of the request.INDX_STORE_COOKIE_SECUREmoves toServerSettingsfor the same reason.- The boundary test pins it.
indx-workspacemay not import the facade, a stage, a transport, a plugin or a library beneath the plugins, and the facade may not import it.
Refused.
- A dependency on the facade. The domain would then sit above
indx, and a test of it would need a registry. - Moving the owner cookie with the store. A CLI or a job has an owner and no cookie.
- A package per store port. The ports already split by aggregate; what composes them is one
domain, and
resolvereads three of them at once.
Consequences. ty now checks this code, which it skipped while it lived in a package excluded
for FastAPI’s stubs. The demo host names indx-workspace as a dependency. The server’s tests keep
exercising the domain through the routes, so the new package has no test directory of its own
yet.
Revisit when a workspace per account replaces the installation’s one, or when the domain needs the facade for more than a callable.
ADR-0081 — A kept document’s source is kept beside it, and joining a dataset copies it to the installation
Section titled “ADR-0081 — A kept document’s source is kept beside it, and joining a dataset copies it to the installation”Status Accepted · no version change; the wire gains readable on a dataset summary, so
openapi.json and the generated clients move; a deployment that names no source store behaves as
before
Context. Phase 4 scores candidate routes on an evaluation dataset, and each candidate reads the documents again. A kept document is its result only (ADR-0071): the request’s source is dropped after the read. A dataset’s document is a digest and a name (ADR-0077). So nothing an experiment could read exists once the request ends.
Decision.
- A seventh store port,
SourceStore(put,get,list,delete,purge), an entry point inindx.source_storesnamed byINDX_SOURCE_STORE.memory,sqliteandpostgresship it. - It keeps the source as it was asked. A
StoredSourceis a digest, the request’sSourceand an optional expiry. An inline source carries its bytes. A URI source is kept as the URI with its checksum set to the digest, so a later read of bytes that moved is refused by the existing422 checksum_mismatch. A re-read is an ordinary request built from the stored value. - Keeping a document keeps its source, under the same owner and with the same expiry. Forgetting the document forgets it.
- Joining a dataset copies the source to the installation, with no expiry, the way a label copies what it judged (ADR-0076). The copy is taken from the caller who adds the document, so the operator adds what they kept. A digest no dataset holds any longer loses its copy.
- A dataset says how many of its documents are readable:
readableon its summary, a count of stored rows. A document added before this decision, or kept by someone else, has no source and is not counted. - Unset, nothing is retained. The demo host falls back to memory only when a workspace token is set, because without one no document can join a dataset.
Refused.
- The source inside
StoredDocument. Every Use screen fetches that record, and none of them wants the bytes back. - Asking for the files again at each experiment. A run could then not resume or repeat without the files at hand.
- Keeping only a URI. Uploads could not be evaluated, and the deployed hosts resolve no URI.
- An object store now. The stored value is the model’s JSON in one column, like every other port; the bytes are base64 inside it.
Consequences. A deployment that names a source store holds a visitor’s bytes for the
document’s lifetime, where it used to discard them after the read, and holds an evaluation
document’s bytes until its dataset lets go. The memory backend counts those bytes against its
ceiling and drops expiring sources first. A source is at most INDX_MAX_INPUT_BYTES, a third
larger as base64.
Revisit when evaluation sets outgrow a JSON column, when a visitor must be able to refuse retention per document, or when authentication lets a dataset take a source from an owner other than the caller.
ADR-0082 — An experiment freezes what it scores, runs each candidate as the router version it would become, and promotion publishes that version
Section titled “ADR-0082 — An experiment freezes what it scores, runs each candidate as the router version it would become, and promotion publishes that version”Status Accepted · no POLICY_VERSION change; the wire gains /v1/experiments and origin
on a router version, so openapi.json and the generated clients move
Context. Phase 4 compares candidate routes for one document type on the documents a person checked, then makes the winner the router’s next version. The benchmark harness scores repository cases offline, and its “fields right” is a substring test on page text: it never reads what an extractor answered, nor a label (ADR-0076). A dataset is a live view (ADR-0077), so a score over it means nothing a week later unless the run records what it scored. A router version is a branch per type (ADR-0078), which is already the shape of a candidate.
Decision.
- One scoring core,
indx-evaluation, stateless and over wire types only. The repository’s entity labels and the product’s verdicts both reduce to one shape, an expectation: a page, an entity label or a facet, a value, the field it fills, and whether the value is wanted. The benchmark’s entity recall becomes a call to it; routing, text, the baseline and the retrieval and table harnesses stay inindx-benchmark. - A verdict is an expectation. Approved expects the answer, corrected expects the correction, rejected expects the answer’s absence. An entity is right when the merged entities of its page hold the label with the same surface after NFKC folding. A facet is right when its top label is the expected one. Offsets and boxes are not compared, because a candidate’s chunker moves them. A verdict on a chunk facet is not scored, because a chunk has no identity across chunkers.
- A row is a field. A verdict that names a field of the experiment’s type scores under that field; one that names none scores under its entity label or facet, marked unassigned. A row with no example carries no number.
- A candidate is a branch, the router’s own
Branch. It runs under the production router with the type’s branch replaced, as the version it would become, and every evaluation document typed before reading. So the reader pin applies, the branch’s lanes are the only lanes that run, and recognition is not what is measured. The first candidate is always production: the branch the router has for the type now, or its default branch. - An experiment is immutable and freezes what it scores: the type, the router version, the documents, and the labels those documents held when it was created. It refuses a dataset that is not an evaluation set of one type, and a document whose source the installation does not hold (ADR-0081), naming it.
- A result is one cell, a candidate on a document: the rows, the pages, the pages still
needing a person (marked for review, read by a manual-review capability, or not read), and the
cost and time the result reported. Cells are their own rows in the eighth store port,
ExperimentStore(indx.experiment_stores,INDX_EXPERIMENT_STORE), so no two share a write. A candidate that fails on a document is a stored cell with its error. - Progress is derived. The outstanding cells are candidates times documents less the stored results, the way the review queue is answers less labels. Nothing stores a percentage.
- Promotion publishes.
PUT /v1/experiments/{id}/promotionwrites the router’s next version with the type’s branch replaced by the candidate’s, and recordsorigin(the experiment and the candidate) on that version. It is refused unless every cell of the candidate is stored and none failed, and when the router has moved past the version the run measured. Undoing it is restoring the previous version, which carries no origin.
Refused.
- Converting a dataset to the benchmark’s case shape. The manifest’s labels are routing truth by capability ID; a customer’s are verdicts on answers.
- Scoring a lane’s own answer. A verdict judges the merged answer (ADR-0079), and the merged answer is what a route delivers.
- Precision over entities. A reviewer checks some of a page’s entities and never all, so a surface with no verdict is not a false positive. A rejected verdict is the one negative that was stated.
- Adding a candidate to an experiment. A new experiment is created from the old one, so the frozen labels of a run never mix with newer ones.
- A recommendation. The screen states what each candidate scored; which trade to take is the operator’s until Phase 10 can cite a run for its advice.
- A stored status. A run that died with its process would read as running forever.
Consequences. A cost multiplier exists only between two candidates that ran on the same
documents. A document type whose reviewers named no field scores under labels alone. Matching is
exact after the fold, so 2026/06/25 does not match 2026年6月25日; a normalizer per field kind
comes when a run shows the need. Promotion goes through Workspace.publish_router and inherits
its read-then-write of the version number.
Revisit when a field kind needs its own comparison, when experiments must score retrieval or tables (Phase 6), or when a recognizer learned from examples makes typing part of what a candidate is (Phase 5).
ADR-0083 — A run is a background job in the server’s process, and its progress is what the store holds
Section titled “ADR-0083 — A run is a background job in the server’s process, and its progress is what the store holds”Status Accepted · no version change; the wire gains PUT and DELETE on
/v1/experiments/{id}/run; a deployment names its runner with INDX_JOB_RUNNER
Context. An experiment is candidates times documents, and one cell can take over a minute. Every other operation finishes inside its request, under the request’s deadline. The architecture note planned a job runner for Phases 8 and 9; Phase 4 is the first to need it.
Decision.
- A port,
JobRunner:submit(job_id, work),cancel(job_id),active(job_id)andclose().workreceives a function that says whether to stop. The domain is handed a runner and never builds one. - A runner is a plugin, like a store. It is an entry point in
indx.job_runners, named byINDX_JOB_RUNNER, and no first-party package imports one. A deployment that names none keeps its experiments and runs none: starting a run is404 store_disabled. The demo host falls back tomemory. - One runner ships,
indx-jobs-memory: a queue and one worker thread in the server’s process, closed when the app stops. The name says what an operator needs to know, asindx-store-memory’s does: what it holds is gone with the process. One worker, because PDFium holds a process-wide lock (ADR-0046) and a second would wait on the first. - A job is resumable because its steps are idempotent. A run reads the outstanding cells and writes each as it finishes. Starting it again, after a cancel, a failure or a restart, reads only what is still outstanding.
- State is derived.
runningis the runner holding the job; otherwisecompletewhen no cell is outstanding, elsestopped. Each cell runs under the deployment’s request deadline. PUTstarts or resumes,DELETEcancels. Both are idempotent, and everyPOSTin the contract declares arequest_id.
Refused.
- One request per cell driven by the browser. A closed tab would pause the run.
- The server importing its runner. The package would then be named like a plugin and wired like a library, and a second runner would mean editing the transport.
- A queue service or
/v1/jobs. There is one kind of job. - A lease.
activeis per process, so a second instance reportsstoppedwhile the first works, and starting the run there duplicates work without corrupting it. - Cancelling inside a cell. A cancel stops before the next cell; the cell in flight finishes or meets its deadline.
Consequences. Under the memory runner a run does not survive its process, and resumes when
asked. Two instances over
one Postgres can score the same cell twice. The deployed hosts run one instance.
Revisit when a host runs more than one instance, which is a runner with a lease behind the same port, when a second kind of job arrives (Phase 8’s aggregates, Phase 9’s teacher), or when a run must continue across a deploy.
ADR-0084 — A caller takes back what it kept: the original as an attachment, and the result as the API returns it
Section titled “ADR-0084 — A caller takes back what it kept: the original as an attachment, and the result as the API returns it”Status Accepted · no version change; the wire gains GET /v1/documents/{digest}/source and
source_kept on a kept document’s summary, so openapi.json and the generated clients move
Context. A kept document could be opened and forgotten, never taken out. Two things are
worth taking: the file it was read from, and what was generated from it. The second is the
stored record every Use screen already reads. The first was kept nowhere until ADR-0081 put it
behind SourceStore for another reason, so an experiment could read it again. ADR-0081 refused
the source inside StoredDocument because no screen wants the bytes back with every read; a
visitor asking for their own file is a different request.
Decision.
GET /v1/documents/{digest}/sourcereturns the original bytes to the caller who kept them, under the media type they were kept with.- Always an attachment, never sniffed.
Content-Disposition: attachmentandX-Content-Type-Options: nosniff. These are bytes a visitor uploaded, served from the app’s own origin, so a browser saves them and renders nothing: an uploaded HTML or SVG cannot run there. The name rides infilename*(RFC 5987), since a Japanese file name is the usual case. source_kepton the summary says whether to ask. It is written when the document is kept, true only when the deployment stored an inline source. It is false where the deployment keeps no source and for a document kept from a URI, whose bytes were never held.- One refusal for every way there is nothing to give:
404 source_not_keptfor another caller’s document, one never kept, a URI source, and a deployment that retains none. Nothing tells a caller what someone else kept. - What was generated is the stored result, as returned. The Data store saves
GET /v1/documents/{digest}as a file. It is not a second format. - The Data store offers both from one menu, and the original only when
source_kept.
Refused.
- An archive of the two. Each file is what its own tools open, and a zip is a format to maintain.
- An export per part (text, chunks, entities) before someone needs one. They are all in the result.
- Redirecting to a URI source. A
file:ors3:URI means nothing to a browser, and the server would be vouching for a location it only read once. - Keeping sources on a host with no workspace token so every visitor can download. ADR-0081’s retention stands; such a host offers the result alone.
Consequences. source_kept is said once. The memory backend can drop an expiring source
under pressure and leave it standing; the route then answers 404 and the page says so. The
original passes through the page’s memory on its way to disk, bounded by INDX_MAX_INPUT_BYTES.
Revisit when a customer needs a part of the result as its own file, when a kept document can be shared between callers, or when originals outgrow what a page should hold in memory.
ADR-0085 — Demo videos are recorded by a package CI checks and never runs, and nothing they record enters the tree
Section titled “ADR-0085 — Demo videos are recorded by a package CI checks and never runs, and nothing they record enters the tree”Status Accepted · no version change; a frontend package indx-demos, a recipe
just frontend::demo, and a ./messages export on the screen packages it reads
Context. Phases 0 to 4 left eleven screens a person can use over real data, and nothing to
show them with but a live host. A recording goes stale the day a screen changes, so it has to be
something that can be taken again by running a command. ADR-0060 removed the last capture this
repository had, e2e/capture.spec.ts, because it wrote screenshots into the tree for a page
that then told two stories.
Decision.
- A clip is a Playwright script in
frontend/packages/demos, one per screen plus a tour. It drives the built web app on the demo host, asapps/web/e2edoes, at a person’s pace, and records withpage.screencast, which draws the pointer and holds each action while it travels. - A scene is written once. A clip plays what it needs first with the camera off, through the same scenes other clips record, then its own with the camera on. The tour is every scene in order. Any clip can therefore be recorded alone.
- One script, two files. Each clip is recorded in English and in Japanese, over a document
in that language. A control is named by its message key, read from the screen packages’
./messagesexport, the one edgeindx-demosmay have (scripts/check-boundaries.mjs). - CI checks the package and never runs it. Biome and
tsccover it like any other package, and a message key a screen renames or drops fails the type check. Recording isjust frontend::demoon a developer’s machine: it is slow, and its result is watched, not asserted. - Nothing recorded enters the tree. Videos land in
docs/videos/<date>/<time>/, which git ignores: one folder per run, named by when it started in Japan time, so a retake never overwrites an earlier one and two runs can be compared. What a clip shows is what the host answered on that run (ADR-0057, ADR-0073), with the lanes “Auto” picks, so a stock install can record every clip. - A clip empties the workspace before it starts. Types, labels, datasets, routers and experiments belong to the installation, and one host serves the whole run; a key typed on camera must not be one an earlier clip left.
Refused.
- Recording in CI. The run takes many minutes, and a video nobody watched proves nothing the e2e suite does not already prove.
- Committing the videos, or publishing them on the docs site. They are binary, they change with every screen, and a page embedding them would be a second thing to keep true.
- A second config inside
apps/web. The clips share no spec and no helper with the e2e suite, whose names are English and whose pace is a machine’s. - A fresh host per clip. A start takes half a minute; emptying the workspace takes none.
Consequences. A screen that renames a control breaks the clip at the type check when the name is a message, and only at the next recording when it is structure. The Japanese clips read the sample labels and capability IDs in English, as the app shows them. A clip waits for a real experiment run, so its length is the host’s speed.
Revisit when the videos need a home (the docs site, a release), when a clip needs narration or captions beyond the screen’s own title, or when a deployed host takes writes and could be recorded.
ADR-0086 — A type template is an offer the web app carries, and adding one copies it
Section titled “ADR-0086 — A type template is an offer the web app carries, and adding one copies it”Status Accepted · no version change; nothing on the wire moves
Context. A new installation holds no document type, and the Data & labels screen showed an empty list and a form asking for a key and a name. Nothing showed what a type is, and a new one started with no fields. A few usual types, ready to add, answer that. The same screen is under ADR-0073’s rule: it shows no type the installation did not return.
Decision.
- A template is an offer, not a type. The screen draws templates in their own section,
named “Start from a template”, apart from the table of types. The table still lists only what
GET /v1/typesreturned. - Adding one copies it. The screen sends the same
PUT /v1/types/{key}the form sends, with the template’s label, fields and signature. What is kept is a type of the installation’s own, with no link back: editing or deleting it leaves the template as it was. - A key the installation holds is never replaced. A
PUTreplaces, and that type may have been edited, so its template is shown as added and cannot be added again. - The templates are constants in
indx-screen-labels. Seven ship: invoice, contract, purchase order, resume, meeting minutes, inspection report and P&ID. The API carries none. - The label is stored in the visitor’s language at the moment of adding, since a type’s label is one string. Field names are English keys, since a field has a name and a kind and no label.
- A template names a signature only where a shipped parser recognizes the type, and only on a
host that has it. The invoice names
invoice-parser. The P&ID names none, because the process-chart signature recognizes another drawing (ADR-0045). - Only a host that takes writes shows them, and only with the operator’s token pasted.
Refused.
- Templates listed in the types table, or seeded into a new installation. Both show a type nobody added.
GET /v1/type-templates. No client but the web app needs them, and a route is a contract.- A second copy under another key (
invoice_2). The form already creates a type under any key. - A localized field label. It is a new field on
FieldSpec, and worth its own decision.
Consequences. A template’s fields are a guess at what a business reads from such a document, not a measurement, and they change with a release of the web app. A type added in Japanese keeps its Japanese label when read in English. On the deployed hosts, which set no token, the section never shows.
Revisit when a client other than the web app needs the templates, when a field gains a label, or when a customer needs templates of its own.
ADR-0087 — A dataset may hold a document nobody has reviewed yet, and it waits
Section titled “ADR-0087 — A dataset may hold a document nobody has reviewed yet, and it waits”Status Accepted · no version change; nothing on the wire moves
Context. The Data & labels screen offered a dataset only the documents that held a verdict,
one at a time, from a list with no search. The rule lived in the screen alone: PUT /v1/datasets/{key} never asked whether a digest was reviewed, and a dataset’s examples are the
labels its digests hold now (ADR-0077). So gathering came after reviewing, and a person who knew
which documents belonged together could not say so until each had a verdict. The screen had also
grown a side panel per table and a form under each, which left the tables half the page.
Decision.
- A dataset takes a document before it is reviewed. The screen offers the documents this
browser kept beside the ones somebody reviewed, filtered by name, several at a time, in one
PUTof the whole membership. - An unreviewed member waits, and says so. It is shown as waiting for review, the datasets table counts the ones waiting, and one this browser kept links to Extract. It adds no example until it holds a verdict, and then it does without the dataset being written again.
- The contract does not move. The server already accepted such a document and already
counted examples live. The rule that no document both teaches and grades one type still
holds for a waiting document, checked on the same
PUT. - Types and datasets open in dialogs. A type’s fields and signature, a dataset’s members and the documents that could join it, and both “new” forms are dialogs opened from the table row or from the button at the top right of that table. A template (ADR-0086) is offered in the dialog a new type starts from, still apart from the types the installation holds.
- The picker is
DocumentSelectinindx-ux. It takes the documents and the choice as props, so another screen that gathers documents uses the same one.
Refused.
- A query parameter on
GET /v1/documents. The list is one visitor’s and short; the screen filters it, as the Data store does. - Offering every kept document in the installation. Kept documents belong to a visitor (ADR-0071), and a dataset may only copy the source of one its caller kept (ADR-0081).
- Refusing an experiment over a waiting document. It has no expectation to score, and a dataset with unreadable documents is already refused there.
Consequences. A dataset can show documents and no examples. An evaluation dataset whose members all wait scores nothing, and the screen is where a person sees that. A waiting document that expires before anyone reviews it stays a member by name, readable only if its source was kept when it was added.
Revisit when a dataset needs to be reviewed in place, or when the kept list grows past what a client-side filter serves.
ADR-0088 — A deployed host gets its workspace token from a GitHub secret, as a value and not from a secret store
Section titled “ADR-0088 — A deployed host gets its workspace token from a GitHub secret, as a value and not from a secret store”Status Accepted · no version change; nothing on the wire moves
Context. Only the operator’s token writes to the workspace (ADR-0075), and no deployment set
one, so Data & labels was read-only on all three production hosts. Azure already delivered one
secret, the Azure OpenAI key, as a Container App secret. AWS and GCP delivered none: the task
definition had no secrets, the CI deploy policy had no Secrets Manager or SSM statement, and
the GCP stack did not enable Secret Manager.
Decision.
- One optional GitHub secret per cloud, named as ADR-0054 names everything else:
PROD_WORKSPACE_TOKEN,AZURE_PROD_WORKSPACE_TOKEN,GCP_PROD_WORKSPACE_TOKEN. Each reaches its stack as the sensitive variableworkspace_token, on the plan job as well as the deploy job, because the two plans must match. - Empty is read-only. The stack adds
INDX_WORKSPACE_TOKENonly when the variable is not empty, so a host deployed before its secret exists plans no change. - Azure holds it as a Container App secret, the shape the OpenAI key has. AWS and GCP hold it as a plain environment value on the task definition and on the Cloud Run service.
- The deployed web specs present it.
just frontend::e2e-deployedruns withINDX_E2E_TOKENset from the same secret, so the specs that write to the workspace run against the host instead of skipping, and delete what they made.
Refused.
- Secrets Manager or SSM on AWS and Secret Manager on GCP. The AWS one needs an administrator to re-apply the bootstrap stack before CI may create the secret, and both need new grants on the runtime and plan identities, for a token that guards stores held in memory.
- A token Terraform generates. The operator has to know it to type it into the screen, and a generated one would be read back out of state.
Consequences. The token is readable by anyone who may describe the ECS task definition or
the Cloud Run revision, and it is in each stack’s state; ADR-0055’s “GCP’s state carries nothing
secret” no longer holds. On AWS a plan shows container_definitions as one sensitive value once
a token is set. A host with a token is the demo host with a token: it also keeps sources in
memory (ADR-0081), and everything written is lost on a restart or a scale to zero.
Revisit when the deployed hosts get a durable store (docs/cloud-postgres.md), which is
when a secret store earns its administrator step, or when the token must be rotated without a
deploy.