Skip to content

API contract

The full HTTP, CLI, and Python contract, including the typed error envelope.

The HTTP API is a transport over the same public Pydantic contracts used by the Python facade. The initial contract is synchronous and stateless.

Operation Purpose
GET /health Process liveness and package version
GET /v1/capabilities Current versioned capability and embedding-space snapshot
POST /v1/plan Inspect processing decisions without executing them
POST /v1/encode Execute an optional supplied plan or create one first
POST /v1/embed Encode a text or image query into a selected embedding space

Planning and encoding accept either:

  • application/json with a URI source or base64 inline bytes; or
  • multipart/form-data with a binary file and a JSON request form part. That part is PlanRequestPart / EncodeRequestPart / EmbedRequestPart in the schema — the JSON request without the field the upload supplies, since the file is the source. It is a named schema rather than an opaque string so a generated client can build it.

Query embedding accepts inline text, a URI/inline image in JSON, or an image in the same multipart form. Both transports validate into the same domain model.

GET /v1/capabilities returns the current snapshot: the capabilities and embedding spaces this installation can route against, plus the routing policy version they belong to.

The snapshot ID is content-addressed, so the same inventory always has the same identity and a caller can detect that its environment moved. An optional snapshot_id query parameter re-requests a specific retained snapshot instead of the current one; an ID that is not retained returns 404 rather than silently answering with a different inventory.

Capability availability follows what is installed. A provider distribution advertises its capabilities through an entry point, so installing one is what makes its capability appear here, and a provider that fails to load is omitted rather than taking the endpoint down. Snapshot IDs are stable for a given install, not across releases.

A successful plan contains:

  • deterministic plan_id and caller/request ID;
  • source content digest;
  • capability snapshot and policy versions;
  • observed public decision reasons and signature matches;
  • document default, page overrides, optional region overrides;
  • ordered fallbacks and estimated cost/latency/quality; and
  • an explicit ready or unsatisfied status.

The response is a PlanResult: that plan under plan, and beside it components naming the distributions that produced it – the loader that fetched the bytes, the sniffer that named the media type and the observer that produced the evidence, in the shape the encode trace uses. Only the plan is hashed and bound; the components are diagnostics, and a plan supplied back to encode is the plan field alone.

Private raw preflight features are not part of the wire contract.

signature_detection asks the installed parsers whether they recognize the document. A match reports the signature, the scope it covers, the evidence it matched on, and the capability it nominates; that capability then leads the route for those scopes while the generic ladder stays behind it as fallbacks, so a wrong signature costs one cheap attempt rather than the document. confidence is the share of independent signals matched, a signal counting in proportion when it is one (the share of a process chart’s symbols that carry a label), not a calibrated probability – nothing in this release has been measured against a labelled corpus. A match whose nominated capability is not installed is still reported, because the caller is entitled to know their document was recognized even when the thing that recognized it is gone; it simply does not change the route.

A media type only a parser declares, planned with signature_detection unset, comes back unsatisfied naming the parser and the flag. Nomination is a parser’s only door, and the silent alternative – routing every page to manual review – would read exactly like an install carrying no parser at all.

POST /v1/encode accepts an optional plan. If present, its source digest, media type and snapshot must match the request. The executor may use the selected route or its declared fallbacks only; it cannot silently replan.

It also accepts metadata, a free-form JSON object of the caller’s own labels – an owner, a tenant, a sensitivity classification. It travels with the source and comes back on the document block, and indx enforces none of it: who may read a vector afterwards is the index’s question rather than the router’s. The field is on EncodeRequest alone. plan_id is the hash of every field of the plan it is decided into, so a label on PlanRequest would give two identical documents two plans for a value that routes nothing. Every key the executor writes is refused rather than accepted: languages from an installed language detector, classification and chunk_classification from the classifiers a request enabled, entities from the entity extractors it enabled, enrichment and chunk_enrichment from the enrichers it enabled, and a label silently replaced is a label the caller believes travelled and did not.

And it accepts classification, extraction and enrichment, all absent by default, all naming installed implementations by ID per unit: classification and enrichment take document_ids, page_ids and chunk_ids, extraction takes page_ids and chunk_ids, and each list is asked in the order given. Nothing bounds how much of the document an implementation is shown – each is handed the full text of its unit and applies its own bound if it needs one, so there is no sample field and no deployment default behind it (ADR-0033).

Three refusals, all 422, all before the source is fetched. An ID no installed provider declares is unknown_classifier, unknown_extractor or unknown_enricher, naming the ones that are. One that would hand the text to an external service is data_residency when the request carries that constraint. And a chunk_ids entry in a request that did not ask for CHUNK granularity is granularity_required, because there would be no chunks to annotate and an empty answer is indistinguishable from no opinion. The IDs a caller may name are on the capability snapshot, outside its content hash; they are one namespace across all eight ports, so no two may share a string (DocumentClassifier, classifiers, entity extractors, enrichers).

The response returns the plan, a flat addressable block tree, embedding-space descriptors, execution trace, and usage. Blocks represent document, page, region, and chunk scopes and link through id / parent_id.

How many chunks a page yields is installation-dependent: boundaries are drawn by installed chunker distributions (Chunker), first answer per page wins, and indx-chunker-page is the floor that keeps one chunk per readable page. An install carrying no chunker returns no chunk blocks and no document vectors, the way a stock install resolves no URI.

A chunk block carries either a bbox or a bbox_reason, never both and never neither. bbox_reason is one of no_geometry (the chunker knows no positions: worksheets, Word sections, text and email; a page read by recognition gets a rectangle per recognized line instead), hidden (the source marks the shape hidden; its text is read, nothing is drawn) or unresolved (the format has positions and this shape’s could not be resolved). PDF pages read by the text layer and PowerPoint slides carry boxes; every other chunk today says no_geometry. Document and page blocks carry neither field.

The document block’s provenance names what the source was read as and where it came from – media_type, plus source_uri for a URI source or filename for an inline one that supplied a name – beside the plan bindings every block carries. Provenance rather than metadata, because the glossary reserves this dict for where a block came from and metadata for what was extracted out of it; an exported row has to say where it came from without the plan beside it, and a digest is not something anyone searches for.

Its metadata carries the caller’s labels and, where a language detector is installed, languages: {"language": "ja", "confidence": 0.98} entries, highest first, on each readable page block and, averaged over the pages by how much text each score was computed over, on the document block. A stock install detects nothing and carries no key at all (LanguageDetector).

Where the request enabled a classifier, the document block’s metadata also carries classification: {"document_type": [{"label": "invoice", "confidence": 0.9}]}, a facet per key, labels highest first, the first enabled classifier with an opinion on a facet winning it. Facet names are the classifier’s own, and a document nothing could read carries no key. A page classifier writes to each page block instead, and a chunk classifier to the document block’s chunk_classification, keyed by chunk block ID, because chunk blocks carry no metadata.

Where the request enabled an entity extractor, the document block’s metadata carries entities: the spans found, keyed by the block each was found in, each a surface text with a [start, end) character range into that block’s own text and a confidence; and beside them a count per (label, NFKC-normalized surface form) with the raw surface forms kept. A count is not an identity – nothing here resolves coreference (entity extractors).

Where the request enabled an enricher, the document block’s metadata carries enrichment: {"summary": "...", "tags": [{"label": "billing", "confidence": 0.9}]}, only the parts answered, the first enabled enricher with an opinion on a part winning it. A page enricher writes to each page block instead, and a chunk enricher to the document block’s chunk_enrichment, keyed by chunk block ID (enrichers).

Fields a specialized capability extracted ride on metadata of the block that stated them – an invoice’s number, dates, and amounts under an invoice key. Money stays a digit string rather than a number, because money in a float is a rounding waiting to happen, and dates stay the strings the document printed, because normalizing 5/8/2026 means deciding whether it is May or August and nothing here knows the issuer’s locale.

A slide the process-chart signature recognized carries process_chart on its page block, the same way: {"title", "nodes": [{"id", "kind", "symbol", "role", "label", "bbox"}], "edges": [{"id", "kind", "from", "to", "arrow", "path"}]}. A node’s kind is symbol (with symbol in triangle, ellipse, diamond, double_triangle and role in material, operation, inspection, assembly), picture, container or text; an edge’s is connection (a connector between two symbols, arrow saying whether the file drew one, path its two ends) or contains (a container and a node whose centre it encloses). IDs are shape: plus the shape’s own cNvPr id, stable within a file; bbox and path use Block.bbox’s normalized convention, so a node is overlaid with the arithmetic a chunk uses. The signature is page-scoped: the match names the slide, and process-chart-parser leads only that page’s route. Fields absent from a node or edge are omitted, not null.

A block whose scope could not be read carries unreadable or failed with a reason rather than being omitted, and the trace records every capability that was attempted, including the ones that failed and what stopped them. That is what makes a fallback visible: a trace showing only the capability that finally worked would hide the reason fallbacks exist at all.

The trace’s components names what took part without a capability ID to be named by: {"role": "chunker", "distribution": "indx-chunker-pdf", "pages": [1, 2]}. Roles are loader, sniffer, chunker, language_detector and classifier; distribution is the installed package that declared the component, pages are the pages a chunker cut or a detector answered, and a classifier repeats the id the request named beside the facets it won. An inline source of a declared type names no loader and no sniffer, because nothing fetched or recognized it. The observer is absent by construction – execution never observes – and belongs to the plan response once that has a wrapper to carry it.

Two outcomes deliberately are not errors. A plan whose constraints nothing installed can satisfy comes back 200 with a failed document block and a failed trace, because nothing conflicts and the request was valid – flattening the caller’s constraints into an error string would lose the part they can act on. A route whose every declared capability failed is a 503: the routing ladder ends in manual review by construction, so exhausting one means an installation missing its terminal fallback, or a caller who set constraints.fallbacks_allowed to false. Together with the constraints.capability_ids allowlist, that flag is how a request names its reader (ADR-0063). An allowlist that leaves a page nothing to read it, or names an ID the snapshot does not hold, is an unsatisfied plan whose line starts with capability_ids:.

Each vector records its embedding_space_id and embedder_id. An embedding space declares vector dimension, distance metric, normalization, and compatible document/query embedders. Text and image embedders share a space ID only when their vectors are directly comparable.

POST /v1/embed requires an embedding-space ID. It chooses that space’s query-role embedder for the input modality and returns vectors plus the exact public embedder configuration used. Provider credentials and private endpoints never appear in the descriptor.

A space that is not in the snapshot, or one with no query-role embedder for the input’s modality – an image into a text-only space – is a 422 with type: invalid_embedding_space and a code naming which. An embedder that is advertised but cannot run, or fails, is a 503: embedders have no fallback ladder, because a caller who named a space asked for exactly that one.

Every install carries default-text, a 256-dimension feature-hashed character trigram space. It is a lexical baseline – two strings that share character grams land near each other and nothing else does – offered so that vectors exist without a model, not as retrieval quality. uv sync --all-packages --extra fastembed adds minilm-multilingual (384-d, text) and clip-vit-b32 (512-d, text and image queries in one comparable space) through fastembed, and --extra hosted a hosted text space, hosted-text, configured with INDX_EMBED_MODEL (a LiteLLM model id) and INDX_EMBED_DIMENSION, optionally INDX_EMBED_API_KEY, INDX_EMBED_API_BASE, INDX_EMBED_TIMEOUT_SECONDS and INDX_EMBED_RETRIES. The dimension is declared rather than discovered because a space has to state it before any vector exists and a hosted provider only answers for a fee. The hosted embedder’s device follows the vision lane’s rule: a model LiteLLM routes to this box is gpu, a hosted one is external, and a plan request naming that space under data_residency is refused the way the hosted vision lane is. What a hosted embedder reports charging joins cost_usd on the trace and the usage.

Document vectors are produced by POST /v1/encode for every space named in embedding_space_ids, one per chunk block; page and document blocks carry none.

A chunk is usually text, and sometimes a rendered page. A page no capability could read as text has no boundary to cut, and used to leave a page block carrying its reason and nothing retrievable; indx-chunker-pdf renders it into one image chunk instead. A space is selected by what a run produces rather than by naming a text embedder, and what a run produces is DOCUMENT_EMBEDDING_MODALITIES(text, image), in that order, so a page that was read never also pays for a render. A space whose only document-role embedder reads images is therefore routable: it answers for exactly the pages that were rendered, and a document may carry text vectors and image vectors in one space. What still comes back unsatisfied is a space declaring no document lane at all, saying it embeds no document input this run produces rather than reporting a missing text embedder it in fact has. Query lanes remain unconstrained by any of it.

All failures use one typed envelope containing type, code, message, optional param, and optional request_id. That includes the transport-level ones the router raises before an operation is reached: an unknown path answers 404 and a wrong method 405, both with type http_error and code not_found or method_not_allowed. They belong to no operation, so they are not listed in the per-operation responses of the OpenAPI document.

Status Meaning
404 Pinned capability snapshot ID is not retained (GET /v1/capabilities)
409 Supplied plan does not match the source or capability snapshot
413 Request exceeds the server-advertised input limit
415 Content type is unsupported, no installed loader resolves the source URI scheme (unsupported_source), or no installed capability reads its media type
422 Request validation failed, the source did not resolve to the expected bytes (source_unreadable), needs a password this server was not given (source_encrypted), names a destination a loader’s guard refuses to fetch (source_forbidden), asks for a block granularity nothing installed produces (unsupported_granularity), or an embedding space cannot serve the request as asked
500 An error the server did not type; the message is generic and the X-Request-ID header locates the log line
501 Contract exists but its application implementation has not landed
503 A declared capability is temporarily unavailable
504 The request exceeded INDX_REQUEST_TIMEOUT_SECONDS, or the caller disconnected before the work finished

Every response carries an X-Request-ID header: the value the caller sent, or one the server generated. It is the correlation ID on every log line the request produced, and it is distinct from the envelope’s request_id, which is the caller’s own identifier from the request body and is echoed whenever the body carried one – including on validation failures. param is the dotted location of the field that failed, as pydantic reports it; a member of a discriminated union carries its tag as a segment (source.uri.uri for an empty uri in a {"type": "uri"} source), and body means the body was not JSON.

INDX_REQUEST_TIMEOUT_SECONDS bounds the time a request may spend in planning, execution, or embedding; unset or 0 means unbounded. The deadline is checked once the source is loaded and then between capability attempts, never inside one, so a source fetch or a capability mid-call finishes before the 504 is answered. Timeouts and disconnects stop the work; they never fall back to another route.

The input limit is advertised as limits.max_input_bytes on the capability snapshot and configured per deployment with INDX_MAX_INPUT_BYTES. It bounds decoded source bytes, not the encoded request body, and is deliberately excluded from the snapshot’s content hash so raising it does not invalidate existing plans.

resolvable is excluded for the same reason and is not the same kind of thing. It reports what this installation can reachresolvable.schemes – and what it can look atresolvable.observable_media_types – and both grow by installing a distribution rather than by changing a setting. Gaining either widens what can be planned next without changing what any outstanding plan selected, so hashing them would answer every plan in flight with a 409 for a reason unrelated to routing.

Planning estimates start from per-capability-kind defaults that a deployment corrects with INDX_ROUTING_ECONOMICS, a path to a JSON object keyed by capability ID or kind, whose values may set quality, cost_usd_per_page, and latency_ms_per_page. An ID wins over its kind. quality is between 0 and 1, the other two are at or above zero, and latency_ms_per_page is a whole number of milliseconds. The numbers are private routing input, not part of the wire contract, but they move estimates and can change which route a constrained request receives, so a malformed file is refused rather than ignored, an unknown field and a fractional latency included.

The variable is operator-side because a capability that could price itself would be buying its way into a route, and the deployment paying the bill is the party with a reason to be honest. What that costs is real and is not hidden: a hosted OCR charging $0.05 per page is admitted against maximum_cost_usd on the OCR default of $0.0005 and publishes that as estimates.cost_usd, until someone writes the real figure into this file. Correct a specific install by capability ID; correct a whole class of work by kind.

Recognition engines ship behind extras, so a default install is a router and a native-extraction lane with no ONNX runtime, no model weights, and no credentials. uv sync --all-packages --extra ocr adds PP-OCR through onnxruntime; --extra vlm adds a vision lane through LiteLLM. What an install does not carry is advertised as unavailable with the reason, and the routing ladder descends past it rather than failing the request.

The vision lane is configured with INDX_VLM_MODEL (a LiteLLM model id), optionally INDX_VLM_API_KEY and INDX_VLM_API_BASE, and INDX_VLM_TIMEOUT_SECONDS / INDX_VLM_RETRIES / INDX_VLM_DPI. The OCR lane takes INDX_OCR_DPI and reports PP-OCR’s own per-line scores as the page’s confidence, weighted by the length of the line each was given for. It scores recognition and not recall: text the detector never boxed is absent from the number rather than counted against it. Both raster lanes also take INDX_OCR_MAX_PIXELS and INDX_VLM_MAX_PIXELS (default 178956970, Pillow’s own decompression-bomb ceiling): a page that would rasterize to more pixels than that at the lane’s DPI is refused before the bitmap exists, and returned as unread so the plan’s next rung gets it.

INDX_TEXT_ENCODING belongs to the text lane and is a comma-separated ordered list, default utf-8-sig. Each name is tried strictly and the first that decodes wins; a source matching none of them fails rather than being mangled into whichever was tried last, and an encoding Python does not know names the variable in its message rather than reporting the source as bad. It exists because Japanese public-sector CSV is overwhelmingly Shift_JIS — 気象庁 and 東京都オープンデータ both serve CP932 — so a deployment reading those sets utf-8-sig,cp932. UTF-8 leads because it is the strong test: arbitrary bytes rarely decode as valid UTF-8, while CP932 decodes almost anything, so ordering is what stops a UTF-8 file being read as mojibake. The default is utf-8-sig rather than utf-8 because it is the same codec for a file with no byte-order mark and strips one for a file that has it.

INDX_CHUNK_DPI (default 120) is the third of these and belongs to indx-chunker-pdf, which renders a page no capability could read as text into a single image chunk so it has a vector at all. It is read per call like the other two. A value that is not a positive integer raises rather than falling back to the default, but the raise does not fail the encode: the executor skips the chunker for that document with a logged warning and the page floor chunks it instead, so the misconfiguration shows in the log and in coarser chunks.

INDX_LANGUAGE_CANDIDATES, INDX_LANGUAGE_MINIMUM_CONFIDENCE (default 0.05) and INDX_LANGUAGE_LOW_ACCURACY belong to indx-language-lingua, behind the lang extra. The candidate list is ISO 639-1 codes, comma separated, and unset means all seventy-five: honest and expensive, since every language loaded costs memory and every language considered is one more way to be confidently wrong about a short page. The floor is not optional decoration – the engine returns a value for every language it was built with, so without one a page would carry seventy-five entries of which seventy-four are noise.

INDX_NER_PATTERNS_GAZETTEER points indx-ner-patterns at a JSON object of {label: [surface, ...]}, matched literally and longest first beside the shipped patterns, so a deployment’s customer list or product names come back as spans under the label it chose. Unset means the patterns alone.

INDX_NER_VIBRATO_DICTIONARY and INDX_NER_VIBRATO_CACHE belong to indx-ner-vibrato, behind the ja extra. The first names a compiled vibrato dictionary, system.dic or system.dic.zst, replacing the IPADIC release the lane otherwise downloads once into the second (default ~/.cache/indx-ner-vibrato) and verifies by digest on every read. Without the extra the distribution advertises no extractor at all.

INDX_NER_ONNX_MODEL (default Xenova/bert-base-multilingual-cased-ner-hrl), INDX_NER_ONNX_MODEL_FILE (default onnx/model_quantized.onnx), INDX_NER_ONNX_MAX_TOKENS (default 512), INDX_NER_ONNX_WINDOW_CHARACTERS (default 400, how much text the model is shown at once, cut at whitespace) and INDX_NER_ONNX_MINIMUM_CONFIDENCE (default 0.5, the mean token score a span needs) belong to indx-ner-onnx, behind the ner extra. The weights download on first use into the Hugging Face cache and never during discovery; without the extra the distribution advertises no extractor at all.

INDX_NER_LLM_MODEL, INDX_NER_LLM_API_KEY, INDX_NER_LLM_API_BASE, INDX_NER_LLM_TIMEOUT_SECONDS (default 60), INDX_NER_LLM_RETRIES (default 1), INDX_NER_LLM_MAX_CHARACTERS (default 6000) and INDX_NER_LLM_LABELS (default person,organization,location,date,money,invoice_number, the labels the model is asked for) belong to indx-ner-llm, behind the llm extra: its own prefix, so a deployment may extract with a different model than it classifies with. Without a model the distribution advertises no extractor at all. The model answers surfaces and the lane locates each in the text; the device rule is the LLM classifier’s.

INDX_ENRICH_EXTRACTIVE_SENTENCES (default 3, how many sentences make the summary) and INDX_ENRICH_EXTRACTIVE_MAX_CHARACTERS (default 600, the ceiling on it, cut at a whitespace boundary) belong to indx-enrich-extractive, the enricher a default install carries. Wrapped lines are rejoined and a sentence ends at a terminator in either language; the sentences chosen are the ones that carry most of the text’s own vocabulary, by term frequency with a redundancy penalty, returned verbatim in document order. A text with nothing to rank, a form of short fields, gets its leading lines. It coins no tags.

INDX_ENRICH_LLM_MODEL, INDX_ENRICH_LLM_API_KEY, INDX_ENRICH_LLM_API_BASE, INDX_ENRICH_LLM_TIMEOUT_SECONDS (default 60), INDX_ENRICH_LLM_RETRIES (default 1), INDX_ENRICH_LLM_MAX_CHARACTERS (default 6000), INDX_ENRICH_LLM_SUMMARY_SENTENCES (default 2, how long a summary the model is asked for) and INDX_ENRICH_LLM_TAGS (default 5, how many tags at most) belong to indx-enrich-llm, behind the llm extra: its own prefix, so a deployment may summarise with a different model than it classifies with. One call answers both parts. Without a model the distribution advertises no enricher at all, and the device rule is the LLM classifier’s.

INDX_CLASSIFIER_WORDS_LABELS points indx-classifier-words at a JSON taxonomy replacing the shipped one: facets of labels, each with names in ja and en, the words that vote for it, and a threshold – an integer count of distinct words or a fraction of the list. Unset means the shipped document-type, business-unit and industry tables for Japanese and English business documents. The confidence is the share of the list that matched, a ratio and not a calibrated probability.

INDX_CLASSIFIER_ZEROSHOT_MODEL (default the Xenova ONNX mirror of MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7), INDX_CLASSIFIER_ZEROSHOT_MODEL_FILE (default onnx/model_quantized.onnx), INDX_CLASSIFIER_ZEROSHOT_HYPOTHESIS (default This document is a {}.), INDX_CLASSIFIER_ZEROSHOT_MINIMUM_CONFIDENCE (default 0.5), INDX_CLASSIFIER_ZEROSHOT_MAX_TOKENS (default 512) and INDX_CLASSIFIER_ZEROSHOT_LABELS belong to indx-classifier-zeroshot, behind the zeroshot extra. Each label becomes one hypothesis against the text, and the entailment probability is the label’s confidence; the token window is the lane’s own bound, so the model reads the head of the document. The weights download on first use into the Hugging Face cache and never during discovery.

INDX_CLASSIFIER_LLM_MODEL, INDX_CLASSIFIER_LLM_API_KEY, INDX_CLASSIFIER_LLM_API_BASE, INDX_CLASSIFIER_LLM_TIMEOUT_SECONDS (default 60), INDX_CLASSIFIER_LLM_RETRIES (default 1), INDX_CLASSIFIER_LLM_MAX_CHARACTERS (default 6000, the head of the document the model is shown, since the contract bounds nothing) and INDX_CLASSIFIER_LLM_LABELS belong to indx-classifier-llm, behind the llm extra. Without a model the distribution advertises no classifier at all rather than a broken one, the hosted-embedding rule. The classifier’s device follows the rule below: a model LiteLLM routes to this box is gpu, a hosted one external, and only the former may be enabled by a request carrying data_residency. What the vendor charged reaches usage.cost_usd.

None of these appear on the wire, but the model id decides one thing that does: a model LiteLLM routes to this box (ollama/…, or an api_base on localhost) is declared as a gpu device and a hosted one as external, so a request carrying data_residency keeps the local vision lane and is correctly refused the hosted one. Changing the configured model therefore changes the capability snapshot ID, which is the point: the residency guarantee a plan was decided under is no longer the same guarantee.

INDX_VALIDATION_MIN_CONFIDENCE is the confidence a deployment refuses output below, and it defaults to 0.0 – off. A page a capability scored under the floor is treated as unread: it descends to the plan’s next capability, its output is discarded, and the trace records the measured value against the floor. Raising it therefore buys accuracy with money and latency, one rung at a time, which is why nothing here picks a number for you: no threshold in this repository has been measured against a labelled corpus.

The floor itself stays off the wire; what it is compared against does not. trace.events[].quality is what a capability claimed about the pages that one event covers – a page-scoped event carries that page’s own score, a document-scoped one the mean over the group it stands for – and actuals.quality is the mean over the document. Both are self-reports on exactly the same footing as actuals.cost_usd, not measurements against ground truth, and null when nothing reported one.

Sources arrive either as inline base64, which always works, or from a URI whose scheme an installed loader resolves. The four indx ships – file, data, http, https – come from indx-loader-file and indx-loader-http and are installed distributions like any other, so the resolvable set is a property of the installation rather than of indx: GET /v1/capabilities reports it as resolvable.schemes, and a stock pip install indx resolves none of them. A source’s media type is then recognized from its bytes before any declared value is trusted — by the same installed observers, which is what makes recognition installation-dependent too: an install carrying none believes the declared Content-Type, then the filename.

There are three refusals here, all installation-dependent, and they are deliberately distinguishable. A scheme no installed loader resolves is a 415 with code unsupported_source, and it comes first, because nothing else can happen until the bytes arrive. A media type no installed capability reads is a 415 without that code. A media type no installed observer can look at is a 422 with code source_unreadable, from preflight. The same source can therefore be refused on one install and planned on another. GET /v1/capabilities lists what is readable under capabilities, and what is reachable and observable under resolvable.

A loader may also refuse a destination it could have fetched: 422 with code source_forbidden. indx-loader-http declines loopback, private, link-local, reserved and multicast addresses unless INDX_LOADER_HTTP_ALLOW_PRIVATE_HOSTS is set, re-checking every redirect hop and refusing a redirect into a scheme it does not speak; INDX_LOADER_HTTP_TIMEOUT_SECONDS bounds the fetch. indx-loader-file reads any path this process can until INDX_LOADER_FILE_ROOTS names the directories it may read, after which anything resolving outside them is refused. In both cases the message names the variable that would permit it and the server log carries what was actually resolved – the response does not, because telling a caller which internal address a hostname resolves to is what an SSRF probe is asking for.

Above both loaders sits INDX_URI_SCHEMES, read by indx-source where the scheme is dispatched. Unset, every scheme an installed loader declares is resolvable; set, only the schemes listed are; and none disables URI sources altogether, which is how a public host without authentication runs. A scheme that is installed but not enabled is the same 415 unsupported_source as one no loader claims, with the message naming the variable, and resolvable.schemes on the snapshot advertises the enabled set rather than the installed one, so a client learns before its first request whether the URI lane exists at all. Inline sources are never affected: bytes handed over were never fetched. The roots keep their meaning under it as a second layer – a deployment that later enables file still reads only the directories it named.

indx-loader-s3 resolves s3://bucket/key, one object per URI, through boto3 behind the s3 extra; without the extra the scheme is still advertised and a fetch is a 422 source_unreadable telling the operator to install indx-loader-s3[s3]. Credentials come from INDX_LOADER_S3_ACCESS_KEY_ID and INDX_LOADER_S3_SECRET_ACCESS_KEY (with INDX_LOADER_S3_SESSION_TOKEN when a role’s temporary keys carry one), and when those are unset boto3’s own chain applies – AWS_* variables, a profile, an instance role. That second layer is deliberate, so a deployment on EC2 or EKS uses its role without copying secrets, and it is why INDX_LOADER_S3_BUCKETS exists: a comma-separated allowlist, unset meaning any bucket the way INDX_LOADER_FILE_ROOTS does, and once set a URI naming any other bucket is 422 source_forbidden with the variable in the message and the configured list in the log. INDX_LOADER_S3_ENDPOINT_URL points the loader at an S3-compatible server and forces path-style addressing; INDX_LOADER_S3_REGION is passed through when set; INDX_LOADER_S3_TIMEOUT_SECONDS bounds the connect and the read (default 30). A ?versionId= query is refused rather than dropped.