Obol is two planes in one monorepo. ADR-0005 named four deployables; three of them ship — apps/gateway, apps/control, and apps/web — alongside apps/landing, which the ADR predates and which only Compose builds. The fourth, apps/connectors, is a reserved design with no source and no service in any deployment target. The split exists because the two halves have opposite profiles: the data plane is a hot proxy where tail latency and memory matter, and the control plane is a product where iteration speed and library ecosystem matter (ADR-0002). ADR references throughout these docs point at the decision records in docs/adr/ in the Obol repository. Accepted ADRs are immutable — a reversal supersedes one rather than editing it.

System shape

System shape
The gateway’s outbound edge fans out to LLM providers under the customer’s own key, an in-process OpenAPI executor for generic REST, and federated remote-MCP or catalog backends. A worker:// arm is compiled in but has nothing to dial: no worker is deployed in any target.

The deployables

Data plane — apps/gateway

The gateway is one binary with two listeners. It is never split into an LLM proxy and an MCP proxy, because sessions, auth, policy, and metering have to share memory. It listens on:
  • POST /v1/chat/completions, /v1/responses, /v1/embeddings, /v1/messages
  • POST | GET | DELETE /mcp (Streamable HTTP, Mcp-Session-Id)
  • POST /v1/route for capability resolution
  • POST /hooks/{workspace}/{connection}/{subscription} for signature-verified vendor webhooks
  • GET /healthz, /readyz (Redis reachable, ≥1 policy snapshot, not draining), and /startupz (Redis reachable and not draining; empty snapshots OK)
  • An internal surface for reload, credential exchange, refresh, seal, catalog session, verification readback and evaluation, and approval resume
It does:
1

Authenticate

Bearer ob_live_... resolves by SHA-256 hash against Redis, falling back to control’s snapshot API; MCP OAuth 2.1 JWTs are validated on iss, aud, and scopes.
2

Admit

Key active, workspace not frozen, model or tool allowed, budget remaining, RPM and TPM within limits.
3

Decide

CEL prefilter for visibility, then Cedar is_authorized as the decision of record. A forbid on a destructive tool in production emits an approval ticket and returns a structured approval_required error.
4

Dispatch

Unwrap the outbound credential in-process, inject it on the outbound hop only, and route to an LLM provider, a remote or catalog MCP server, or the openapi:// executor. A worker:// executor is compiled in but has nothing to dial; it still speaks ADR-0014, not ADR-0024.
5

Receipt

Normalize every executor result into a DispatchObservation, run the deterministic EffectSpec verifier, and mint an InvocationReceipt plus a VerificationEvent — committed together as one InvocationAuditEnvelope before any terminal result or receipt metadata is exposed.
It does not run OAuth consent, render UI, talk to Stripe Billing, embed CPython, or hold connector business logic. The crate layout is deliberately thin and leaf-heavy — leaf crates depend only on obol-types, and composition happens in obol-gateway through store, executor, and hook traits (ADR-0018). See Gateway crates for the full map. One structural detail worth noting here: capability discovery lives in obol-discover, which may depend on obol-types alone. The absence of an edge from obol-route is what keeps retrieval off the dispatch path, and a DAG check script enforces it (ADR-0029).

Control plane — apps/control

Control owns Postgres and everything that is a product rather than a proxy: workspaces (each carrying an org_id column — there is no organizations table), memberships and invitations, virtual keys, the connector catalog and connections, OAuth orchestration, policy validation and publication, the approvals inbox, receipts, usage, and the audit log, all under /api/v1. Background workers (arq) handle OAuth exchange and refresh scheduling, Cedar compile and snapshot publish, key-cache invalidation on revoke, usage and receipt flush to Postgres and Stripe meters, verification scheduling, webhook correlation, and deadline reconciliation. ConnectorBundle validation and publication are not among them. Control reads a reviewed pack straight off disk (services/tools.py::load_bundlepackages/connectors/<slug>/bundle.json); there is no connector_bundles table and no publisher. Bundle validation exists in Rust (obol-types/src/bundle.rs).
Cedar validation runs through a small vendored Rust helper binary, obol-policy-check, invoked as a subprocess. Cedar is never reimplemented in Python, and that binary is the only Rust inside the control image. The subprocess boundary is a versioned contract: the CLI emits one PolicyCompileReport on stdout for both outcomes, and its stderr is never parsed and never reaches an operator response.

Connectors — apps/connectors (reserved, not built)

Reviewed Python workers are designed to run out of process and be called over MCP (ADR-0007). Embedding CPython in the gateway was rejected: the GIL fights Tokio, one bad connector takes the process down, and connectors could not scale independently. Nothing is deployed here. The trusted-worker tier is parked (ADR-0058): apps/connectors/ holds one README — no package, no image — and no deployment target defines a connector-worker service. Every shipped connector runs in the gateway’s in-process OpenAPI executor or federates over remote MCP. ADR-0024 specifies that a worker returns a bounded, bundle-declared request plan, and that the gateway validates it, injects the credential, and performs the HTTP itself — so that a worker never sees a vendor credential and never calls a vendor. That protocol is not implemented. The compiled WorkerExecutor still runs ADR-0014’s hand-off and would pass the vendor credential to a worker in x-obol-upstream-authorization. No worker exists to receive it, so no credential crosses that hop today. A worker never mints a receipt or assigns evidence trust on either path. See Trusted workers.

Dashboard — apps/web

Screens only. The browser talks to control and never to the gateway, and there is deliberately no browser-side playground: the dashboard hands over client configuration and shows the receipt that results. Human authentication is Clerk (ADR-0033); control still owns workspaces, membership, and every workspace_id, and Cedar remains solely the authorization engine for agent traffic.

How state flows

Postgres is the sole source of truth and is written only by control. The gateway holds no Postgres pool on the request path (ADR-0006).
State flow
Three properties matter:
  • Snapshots are the only way state reaches the data plane. Control compiles a per-workspace bundle of keys, connections, tools, and the Cedar policy set with entities, publishes it to Redis, and the gateway hot-reloads. On a cache miss the gateway calls control’s internal snapshot API with a service JWT.
  • Publication is monotonic and fingerprinted. One Redis operation compares the workspace version in the candidate pair, refuses a lower version outright, and writes both snapshot keys, both invalidations, and the pair fingerprint together. A gateway reads the bytes and the fingerprint in one MGET and validates the exact-byte digest; a missing or mismatched fingerprint is not an admissible publication. Each cached pair is revalidated after 30 seconds, and once that window expires, reads fail closed until validation succeeds.
  • Writes off the hot path are async and batched. The gateway emits usage events and invocation-audit envelopes to Redis streams; control workers drain them into Postgres and Stripe meters. A token stream never blocks on a database write.
The accepted cost is eventual consistency: budgets lag by seconds, and revoke propagation is bounded at roughly 60 seconds, which is the product’s stated SLO rather than an implementation accident.

What lives in Redis

Every Redis key carries workspace_id, exactly as every Postgres row does. See Tenancy and identity.
These multi-key scripts target the current single-Redis deployment. Redis Cluster would need a later ADR and a live-key migration plan (ADR-0032).

Repo layout

Repository
packages/proto is the contract pack, and it is contract-first: freeze the schema before writing handlers, and if Rust and Python disagree on a field name, the contract pack is wrong. The JSON schemas are generated from apps/gateway/crates/obol-types with schemars and drift-checked in CI (ADR-0017).

Where the boundaries hold

Two boundaries are load-bearing enough to restate:
  • No Python and no product logic in the gateway. Generic OpenAPI and remote MCP execute in Rust. Messy first-party vendors are designed as out-of-process workers under ADR-0024; that protocol is not implemented, and the trusted-worker tier is parked. A feature that smells like product goes to control by default.
  • The gateway owns invocation identity, evidence classification, verification, and receipts. Executors report observations. A worker, a remote MCP server, or a catalog response is evidence, never a receipt, and no vendor or broker payload can promote its own evidence class (ADR-0019).

Invariants

The nine rules these boundaries encode.

Gateway overview

Request paths, listeners, and dispatch in detail.

Policy publishing

Compile, publish, fingerprint, hot-reload.

Deploy

Compose, Fly, and Helm targets.