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.
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:
- An exact name match wins outright.
- Otherwise the first pattern containing
*that matches, in snapshot order.
*, 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.
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_idbefore 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.0and every loser’s is0.0— written down anyway, so a decision taken under a policy nobody recorded is distinguishable later.
The score
score_binding in apps/gateway/crates/obol-route/src/select.rs computes:
Composite score
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 reasonevidence_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.Related pages
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.