The graph
Leaf crates depend only onobol-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
obol-types — wire contracts, ids, errors, store traits
obol-types — wire contracts, ids, errors, store traits
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.obol-auth — inbound authentication
obol-auth — inbound authentication
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].obol-policy — CEL visibility and Cedar permission
obol-policy — CEL visibility and Cedar permission
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.obol-route — target resolution and the dispatcher
obol-route — target resolution and the dispatcher
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.obol-discover — lexical capability retrieval, off the call path
obol-discover — lexical capability retrieval, off the call path
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.obol-llm — the /v1 proxy layer
obol-llm — the /v1 proxy layer
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.obol-httpx — the in-process OpenAPI executor
obol-httpx — the in-process OpenAPI executor
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.obol-mcp — the MCP data plane
obol-mcp — the MCP data plane
/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.obol-vault — envelope decryption for outbound credentials
obol-vault — envelope decryption for outbound credentials
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.obol-meter — pricing, admission, and asynchronous usage emission
obol-meter — pricing, admission, and asynchronous usage emission
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.obol-store — the only Redis client
obol-store — the only Redis client
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.obol-policy-check — the Rust binary inside the control image
obol-policy-check — the Rust binary inside the control image
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.obol-gateway — the binary and the composition root
obol-gateway — the binary and the composition root
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.obol-testkit — the dev-only harness
obol-testkit — the dev-only harness
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 inpackages/fixtures.