The gateway is one binary built from fourteen crates. The split is not cosmetic — it is how invariants get enforced by the compiler rather than by code review. A crate that cannot depend on Redis cannot accidentally make a network call on the authorization path.

The graph

Leaf crates depend only on obol-types plus a small set of declared edges. Composition happens in obol-gateway, through traits defined in obol-types (KeyStore, SnapshotStore, SessionStore, CounterStore, MeterSink, IdempotencyStore, ToolExecutor, McpHooks, Kek).
apps/gateway/scripts/check-dag.sh runs in make lint and CI, so a violating edge is a build failure. Where two leaves need the same helper — glob_matches lives in both obol-policy and obol-route — it is duplicated with an identical grammar test rather than shared, because the shared crate would be a new edge (ADR-0018). Dev-dependencies are exempt. One absence is load-bearing: obol-route, which runs on the call path, is not permitted to depend on obol-discover. That is how “retrieval never runs on the dispatch path” is enforced structurally.

Crates

The crate every other gateway crate depends on, and the one with the least logic. It holds the typed ids (each validating its own prefix on parse and on deserialize, so a ConnectionId can never silently carry a workspace id), the serde structs that mirror packages/proto, the single ErrorEnvelope, money types, the Redis key builders, and the store traits that obol-store implements against Redis and obol-testkit implements in memory. Editing rules are additive-only: new optional fields and new enum variants, never a rename or a removal, and never a strict-unknown-field mode. packages/proto schemas are generated from these types, so the Rust type changes first and Python follows. One type here is deliberately not a wire type: OutboundCredential, the plaintext vendor secret, has no serialization impl at all — enforced by a test that fails to compile if one is added.
Turns an Authorization header into a Principal carrying a KeyContext, across three credential kinds: a virtual key (ob_live_… / ob_test_…, hashed with SHA-256 and looked up in the key store with a control-plane fallback), an MCP OAuth 2.1 access token verified against the authorization server’s JWKS and then resolved through the same key lookup, and an HS256 service JWT for gateway-to-control and gateway-to-connector calls. It never reads Postgres. A store failure fails closed as not_ready, never as unauthorized — a client would treat the latter as final and stop retrying. env_allows is where a test key is prevented from reaching a prod workspace, and RawKey’s Debug prints [REDACTED].
Both policy layers, both evaluated in-process (ADR-0003), depending on nothing but obol-types. The cel module wraps the upstream cel-interpreter in a Prefilter with a fixed, eagerly built context (ADR-0011): three built-in expressions compiled once, a glob function, and a hard rule that any parse error, runtime error, undeclared variable, non-boolean result, or empty allowlist means hidden. The parser is wrapped in catch_unwind because malformed input can panic it. The cedar module compiles a PolicySnapshot into an Authorizer over cedar-policy, with the Cedar schema from packages/cedar compiled in at build time. This crate returns verdicts and nothing else; where verdicts meet auth, vault, routing, and metering is obol-gateway.
Pure resolution against a WorkspaceSnapshot, with no I/O of its own and no Redis. ModelRouter::resolve picks a model target — an exact name match wins regardless of order, otherwise the first matching glob in snapshot order — and carries the price entry and any fallbacks. ToolRouter splits a namespaced connector.tool name on the first dot, resolves it through the snapshot’s tool and connection tables, and produces a ToolTarget naming the executor kind (openapi, mcp_remote, or worker). The ToolExecutor trait is the seam that obol-httpx and obol-mcp implement, and the Dispatcher fronts it with a per-connection circuit breaker: five consecutive trip-worthy failures open the circuit for 30 seconds, then a single half-open probe decides whether it closes. Timeouts, transport failures, and retryable upstream errors trip the breaker; schema violations and non-retryable upstream errors do not.
BM25 over the text a capability owns: its id, display name, description, and feature vocabulary. No embeddings, no model, no network, no async, no clock, no randomness. It exists for discovery surfaces — search_tools and the POST /v1/route resolver — and is deliberately unreachable from dispatch (ADR-0029): by the time tools/call arrives the capability is already named, so retrieval there would put an inference-shaped step on the authorization path and turn a retrieval outage into an execution outage. It does not return a raw BM25 score, because an unbounded corpus-relative number printed next to a routing choice reads as a guarantee Obol has not made; callers get a coarse confidence band and the terms that produced it.
Passthrough request and response types for OpenAI chat completions, responses, and embeddings and for Anthropic messages, plus the translation table between them, the SSE codec, and the usage extractors. parse_request reads the body in the input dialect; render_request renders it for the target’s wire format; Provider::send performs the outbound call with the unwrapped BYOK credential and a UsageGuard. Supported translations are identity OpenAI-to-OpenAI, identity Anthropic-to-Anthropic, and OpenAI chat to Anthropic Messages with the response and stream translated back; anything else is an explicit unsupported error rather than a best-effort approximation. Admission bounds live here too: an output cap is injected when absent, and inputs that cannot be estimated from JSON bytes (media, opaque provider-held context references, provider-executed built-in tools, audio output) fail before reservation. Upstream error bodies are kept only for safe response mapping and are omitted from Display and Debug.
REST connectors are imported specs, never per-vendor crates (ADR-0007). This crate turns an OpenAPI 3 document into the tool catalog control publishes, and executes ToolTarget::OpenApi calls for the dispatcher. tools_from_spec produces one tool per path-and-method pair in document order, requiring an operationId and grouping parameters into path, query, header, and body objects in the input schema. Accept, Content-Type, and Authorization header parameters are ignored and cookie parameters are never exposed. A tool is marked destructive when the spec says so or the method is DELETE. An OutputValidator with cached compiled schemas checks 2xx responses against the declared output schema.
The server side of /mcp and the client side of every MCP upstream. server/ does the HTTP handling — Accept and Content-Type validation, batch rejection, the status matrix, SSE rendering, protocol-version negotiation, and the unknown_tool / approval_required responses. session/ is the SessionManager over the SessionStore trait. relay/ merges responses across upstreams and holds the pooled rmcp clients with a 45-second tools cache. progressive/ holds the synthetic tools. worker/ is the executor that calls a Python worker over a fresh rmcp session with a 60-second service JWT. The crate never depends on obol-policy: policy reaches it through the McpHooks trait, so the MCP transport never decides whether a tool exists. The exact set of rmcp items the crate may use is frozen by a compile-only probe file.
Envelope decryption and nothing else (ADR-0004). An encrypted credential carries base64 of a 12-byte nonce, ciphertext, and 16-byte tag; a KEK-wrapped 32-byte DEK; the KEK provider name; the injection scheme (bearer, basic, a named header, or a query parameter); and the plaintext’s last four characters, which are checked on unwrap and are the only fragment ever logged. The AEAD is AES-256-GCM with a fresh DEK per seal, and the wire format is shared with the Python control plane, pinned by a known-answer test. KEK providers are a local age identity or AWS KMS; a gcp provider name is accepted by configuration but is not implemented and always fails closed. Plaintext exists only in this crate’s return value, between the unwrap call and the outbound socket; nothing here logs, caches, or serializes it.
Everything here runs on ids and counts; no credential passes through it. Pricing::cost computes integer micro-USD from a price entry and a usage record, with cache-exclusive input tokens and half-up rounding in i128. Limiter::admit does the RPM increment on an hourly window and then checks monthly spend against the budget, failing closed on any store error. Meter is a bounded channel of usage and approval events whose record is a synchronous non-blocking send: when the queue is full the event is dropped and a counter increments, because a response is never blocked on metering. A flusher batches up to 64 events or 50 ms onto the Redis streams, retries a failed emit once, then drops and counts. UsageGuard accumulates tokens per chunk and emits exactly one event on drop — normal, panic, or cancellation — so a client disconnect still bills what was consumed, marked partial. Invocation receipts never enter this lossy queue.
All Redis I/O plus the control-plane fallback client (ADR-0012). It is the only crate that depends on the redis crate; everyone else programs against the traits in obol-types and receives a RedisStore or SnapshotCache from the binary. It implements the key store, session store, counter store, meter sink, and idempotency store, and every key is built through a KeySpace that carries a workspace id, key id, key hash, or session id — that is how invariant 5 is checked. ControlClient fetches a workspace snapshot or a key context from control with a per-request service JWT. SnapshotCache holds one immutable workspace-and-policy pair per workspace with a 30-second freshness bound, runs the pub/sub invalidation loop, and preloads at startup. Redis failure of any kind maps to Unavailable and never panics, so callers fail closed. The idempotency and audit paths use Lua scripts that validate every fallible condition before writing.
The one piece of Rust that ships in the Python control image. Control shells out to it so a workspace’s Cedar bundle is validated, tested, and compiled exactly the way obol-policy will evaluate it — a bundle this tool accepts is a bundle the gateway will load, which is why Cedar is never reimplemented in Python (ADR-0002). Subcommands: validate, test against fixture cases, compile to a PolicySnapshot or to a machine-readable compile report, and openapi-tools to produce the tool catalog from a spec. Under compile --format json the exit codes are a contract: 0 and 1 both put exactly one report on stdout, 2 means usage or I/O failure and prints no JSON at all. stderr is never parsed, because stderr is where error chains and filesystem paths live.
The axum service and the only crate allowed to depend on everything else. config is env-only with a redacting Debug; state::AppState::build wires authentication, the prefilter, the Cedar authorizer cache, the vault, the meter and its flusher, the limiter, the LLM provider, the dispatcher and its three executors, the session manager, the upstream pool, and metrics. app builds the router and its layers: request id, one access-log line per request, an 8 MiB body limit, a timeout, and panic capture. hooks::GatewayHooks is the McpHooks implementation — the single ordered boundary where route resolution, idempotency claim, visibility, admission, Cedar, approval binding, credential unwrap, dispatch, verification, and receipt commit happen. invocation owns the durable receipt lifecycle, netguard the private-upstream guard, approval the approval-token contract, drain the bounded shutdown, and telemetry the JSON tracing, optional OTLP export, and Prometheus registry. Everything except main.rs is in the library so integration tests run the production router in-process.
Not shipped. Fixture loaders for packages/fixtures, in-memory implementations of every store trait (with a switch to make them unavailable for fail-closed tests), a per-test prefixed slice of a real Redis, and a fake for every upstream the gateway talks to: a wiremock control plane, a wiremock LLM with SSE and a mid-body-disconnect server, a real rmcp MCP server, a real rmcp worker that records idempotency headers, a wiremock OpenAPI surface, and a sandbox that starts several of them together for cross-executor conformance. It also provides a global JSON tracing capture used to assert that secrets never appear in logs, and an in-memory duplex HTTP connector so tests drive the real router without opening a socket. Network from this crate only ever reaches loopback or the test Redis; nothing here talks to a live vendor.

Testing conventions

Tests run against a real Redis on a dedicated port, with a per-test key prefix rather than a database flush, so they are safe to run concurrently. They never touch Postgres and never call a live vendor — vendor behavior comes from recorded fixtures in packages/fixtures.