--- title: "Crate reference" description: "Every crate in apps/gateway/crates: what it owns, what it depends on, and why the dependency graph is enforced." --- 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`). ```text obol-types ← nothing obol-auth, obol-policy, obol-vault, obol-meter, obol-route, obol-discover ← obol-types obol-llm ← obol-types, obol-meter obol-httpx ← obol-types, obol-route obol-store ← obol-types, obol-auth obol-mcp ← obol-types, obol-route, obol-auth obol-policy-check ← obol-types, obol-policy, obol-httpx, obol-route obol-gateway ← anything ``` `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`. ```bash OBOL_TEST_REDIS_URL=redis://127.0.0.1:6380 cargo nextest run -p obol-gateway ```