Obol routes two different things, on two different surfaces, with two different mechanisms. Both are pure in-process functions of the workspace snapshot, the request, and the clock. Neither performs I/O, reads a counter, or queries a database while deciding. That is invariants 3 and 4: Postgres never sits on the streaming path, and policy is compiled data evaluated in-process.
Routing decides which provider executes. It never decides whether the model chose a sensible tool. Obol’s guarantee is that the wrong tool cannot execute, not that the right one is picked.

Model routing on /v1/*

ModelRouter::resolve (apps/gateway/crates/obol-route/src/lib.rs) takes the client’s model string and the workspace snapshot’s models list and returns one ModelSnapshot plus its price entry and its fallback chain:
  1. An exact name match wins outright.
  2. Otherwise the first pattern containing * that matches, in snapshot order.
The pattern grammar is deliberately small — *, prefix*, *suffix, or an exact string. There is no cost or quality comparison on this path: a model connection declares a pattern and an upstream_model, and the match is the decision. See Model providers for how those connections are created and how the fallback chain behaves. Cost enters this path only through admission control, not selection. If the key carries a monthly budget and any eligible model (the primary or a policy-allowed fallback) has no price in the workspace pricebook, the request is refused with NotReady rather than reserved at a fabricated zero.

Capability routing on /mcp

A tool whose connector segment is the reserved prefix cap is a routed capability (ADR-0015, ADR-0026). Everything else — stripe.create_refund, github.create_issue — takes the direct path unchanged and is never rerouted. The call path in apps/gateway/crates/obol-gateway/src/hooks.rs is:
1

Strip the routing arguments

RouteRequest::take_from removes route, requirements, and intent from the tool arguments. intent is reduced to a digest and is never a scoring input — a sentence a model wrote must not choose a provider. None of the three reaches the vendor.
2

Validate the capability input

The remaining arguments must satisfy the capability’s canonical input schema before any provider projection. Validators use snapshot data and reject external schema references without network or filesystem access. Invalid input cannot dispatch.
3

Authorize every candidate

CapabilityRouter::candidate_targets lists the capability’s non-quarantined bindings. Each is put through the Cedar route action; the refusals are collected. With no compiled policy, every candidate counts as unauthorized, so the call fails closed.
4

Select

CapabilityRouter::select filters, scores, and takes the argmax. It returns the concrete ToolTarget and a RouteDecisionRecord.
5

Claim the invocation

The decision is attached to the invocation guard before authorization, so a call Cedar later denies still records which provider the router picked. A capability call claims its idempotency slot with the connection segment literally unresolved (ADR-0044), so changing a pin cannot mint a second slot for the same idempotency key.
6

Authorize the winner

Cedar’s ordinary call decision runs against the selected concrete tool, with argument conditions over the arguments the vendor will actually receive. That decision gates approval, the vault unwrap, and dispatch.
7

Validate the shaped response

After dispatch, the binding shapes the response and Obol validates the canonical output schema, including identity mappings. A response contract failure retains the dispatch and evidence, returns a nonretryable error, and is replayed from the same invocation. A write that already ran is never retried because its response failed validation.
Cedar authorizes the concrete tool and never the capability (ADR-0027). A capability name is a routing subject, not an authorization subject.

What is deterministic

Selection is a deterministic argmax, not a sampled draw (ADR-0028). Given the same snapshot version, the same request, and the same authorization outcome, the same binding wins every time.
  • Candidates are sorted by binding_id before scoring, so scoring order is fixed.
  • Ties on score break on binding_id.
  • The candidate cap of 8 is applied after scoring, so a ninth provider is dropped for being worst, not for being alphabetically last. Each dropped candidate lands on the record with reason candidate_cap.
  • Rank and propensity are stamped after the sort. The winner’s propensity is exactly 1.0 and every loser’s is 0.0 — written down anyway, so a decision taken under a policy nobody recorded is distinguishable later.
Score normalization is log-ratio-to-cohort-best, computed at decision time and never stored, so adding a provider cannot retroactively rescale a stored estimate.

The score

score_binding in apps/gateway/crates/obol-route/src/select.rs computes:
Composite score
The weights are published in the snapshot’s route_policy.weights and are currently fixed by compile_snapshot: Quality is a hand-authored prior in basis points, not a measurement — there is no latency or success-rate signal in the system yet. It appears squared in the score because its job is to keep a broken provider from winning, not to find a marginally better one. prefer_sources is a preference with a floor of 0.5, not a veto; an operator who means “only this source” uses the only constraint, which is a filter and says so.

Where routing configuration lives

Everything the selector reads is compiled into one workspace snapshot in the control plane and published to Redis. Nothing is fetched per decision.
Snapshot path
compile_snapshot (apps/control/app/services/snapshots.py) assembles connections, tools, models, pricing, tool_pricing, capabilities, bindings, and route_policy, hashes the body canonically, and hands it to publish_workspace_snapshot. Publication increments the workspace’s snapshot_version inside the mutating transaction, then writes the workspace/policy pair to Redis outside it, and reads it back for byte equality. Until the published version covers the workspace’s current version, dependent operations refuse rather than proceed against a stale data plane. Capability specs and bindings are not database rows. They are frozen JSON packs shipped with the image under packages/connectors/catalog/capabilities/, loaded once per process, and republished verbatim into every snapshot. See Catalog.

Operator surfaces

Control never runs the selector. The read surfaces serve frozen pack data plus the decisions the gateway already recorded; nothing in the control plane scores a candidate or predicts a winner. A dashboard that simulated selection would be a second implementation drifting against the first.

Evidence is a filter, never a term

The evidence floor is derived from the tool’s effect class and the workspace environment, and a binding whose declared ceiling cannot meet it is excluded with reason evidence_floor: Evidence trust is deliberately not a scoring input. If it were, a cheap federated route would beat a native one on price, tie elsewhere, and win deterministically on every call forever — cost would silently buy weaker proof. In production, a delete or external_side_effect on a federated binding is additionally excluded outright with reason destructive_federated. See Evidence trust and Connector tiers.

Current state

Every structurally valid native and federated catalog binding is active by default (ADR-0063). An active binding is a candidate; concrete workspace access, authorization, request requirements, and evidence floors determine whether it can serve a call. Explicit quarantine remains enforced for faulty or disabled bindings. See Catalog.

Model providers

BYOK model keys, patterns, and fallbacks.

Capabilities

Specs, bindings, qualifiers, and the browse surface.

LLM routes

The /v1/* surface itself.

MCP surface

How tools/list and tools/call behave.