Skip to content

Planner

The planning port: cheap observation into a serializable RoutePlan.

Protocol: Planner.plan(request: PlanRequest, loaded: PreloadedSource | None = None) -> PlanResult

Planner is the boundary for inspecting a source and selecting how indx should process it. It accepts the source, routing constraints, optional pinned capability snapshot, requested embedding spaces, and signature-detection preference. It returns a serializable route plan.

The plan identifies routes at document, page, or region scope. Each assignment contains a selected capability and ordered fallbacks, together with public decision reasons. The plan also records its source digest, media type, capability snapshot, policy version, estimates, and whether the request is ready or has unsatisfied constraints.

Separating planning from execution makes routing cheap to inspect before any expensive parser, OCR system, model, or embedder runs. A caller can understand, store, compare, or approve the proposed work independently of execution.

Binding the result to normalized input and versioned decision context also makes identical planning requests reproducible and prevents later execution from quietly choosing a different route.

The input is PlanRequest, which carries a request ID, source, constraints, optional capability snapshot ID, embedding-space IDs, and signature-detection flag.

loaded is that source already in hand, and it is optional. A caller with nothing loaded omits it and the planner fetches; a caller that already holds the bytes hands them over. The executor is the one such caller: a planless encode loads and observes the source, and before this it then paid for a second transfer inside plan() and a second SSRF verdict that could disagree with the one it already had. PreloadedSource is a structural protocol in indx-interfaces, not a model: the working copy stays private to indx-source, which indx-interfaces sits below and may not import. Nothing on the wire carries it, and PlanRequest is unchanged.

The output is RoutePlan, which carries:

  • A deterministic plan ID and the caller’s request ID.
  • The source digest, media type, capability snapshot ID, and policy version.
  • Scoped selected routes and ordered fallbacks.
  • Decision reasons and optional signature matches.
  • Requested embedding spaces and estimated cost, latency, and quality.
  • A ready or unsatisfied status with explicit unsatisfied constraints.
  • Perform only cheap source observation during planning. When the caller sets signature_detection, that extends to creating the installed SignatureDetector implementations and asking them what they recognize – the one plan-time exception to “planning invokes nothing”, opted into by the caller and bound to the same budget as preflight.
  • Route over a fixed ladder of kinds – native extraction where a text layer exists, then OCR, then a vision model, then manual review – never over capability IDs. parser is deliberately not a rung: a parser is a specialist that is only correct about a document something recognized first, so signature nomination is its only door, and a capability that generically reads a format declares native_extraction however specialized its machinery. A media type only a parser declares, planned without signature detection, is an unsatisfied plan naming the parser and the flag rather than a silent manual-review route (POLICY_VERSION 0.5.0; the decision for every previously routable input is unchanged).
  • Treat the ladder and the set of kinds as closed and first-party. Six kinds, three escalation rungs, one device preference and two preflight signals the policy reads: none of them grows by installing a distribution, because each is a decision about what indx routes to rather than a thing to route to. A distribution whose work is none of the six declares the nearest kind and the deployment corrects the numbers through INDX_ROUTING_ECONOMICS; a signal the policy does not read is inert rather than wrong. Adding a kind, a rung, or a meaning is a first-party change with a POLICY_VERSION bump.
  • Nominate from a document-scoped or page-scoped signature only. A region-scoped match is carried on the plan and routes nothing: it is a claim about part of a page, there is no region assignment for it to become, and promoting it to its page read “there is an invoice table in this region” as “this page is an invoice” (POLICY_VERSION 0.7.0; no first-party parser emits one, so no previously reachable input changes route).
  • Refuse a requested embedding space against what a run produces, not against a named lane: a space is unsatisfied when it embeds none of the input execution hands a document embedder, which DOCUMENT_EMBEDDING_MODALITIES states once (POLICY_VERSION 0.6.0). A space declaring a document-role image embedder was previously reported as missing a text embedder it had. That list gained image once a run began producing rendered pages, so such a space is now routable rather than unsatisfied, and because one space may then run two embedders, the device rules are applied to each of them rather than the first (POLICY_VERSION 0.8.0).
  • Place a nominated capability ahead of the generic ladder, never in place of it. A signature is a guess, and a guess that displaced the fallbacks would turn a wrong match into a failed document.
  • Ask detectors in a fixed order and order the matches by their content, so discovery order cannot reach plan_id.
  • Never let a detector fail a plan. One that will not load, raises, or answers with something that is not a signature contributes nothing and is logged; no signature is already a complete answer.
  • Evaluate the request against the selected capability snapshot and routing policy.
  • Select only capability and embedding-space IDs present in that snapshot.
  • Apply quality, latency, cost, hardware, residency, embedding-space, and snapshot constraints.
  • Produce deterministic output for identical normalized inputs, policy, and snapshot.
  • Explain why each route was selected and preserve eligible fallbacks.
  • Return an explicit unsatisfied plan when constraints cannot be met.

The protocol describes planning behavior, not preflight internals. The evidence vocabulary is public – PageEvidence, RegionEvidence, TextLayerState and the signal constants live in indx-interfaces, because an observer ships as its own distribution and cannot emit evidence it cannot name. The whole preflight context and the working registry models remain private to the router implementation: the context carries the source digest a plan is bound to, and minting it there is what stops an installed observer from naming the identity of a source it did not fetch. See SourceObserver.

The public indx.plan() facade and POST /v1/plan operation delegate to a planner. The returned PlanResult carries the plan under plan, which can be inspected directly or supplied to the execution workflow, and beside it the components that produced it: the loader, the sniffer and the observer, by distribution, outside the hashed artifact. Encoding without a supplied plan first obtains a plan through the composed indx service, handing over the bytes it already loaded, and its trace then names the observer that looked inside the planner.