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
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/messagesPOST | GET | DELETE /mcp(Streamable HTTP,Mcp-Session-Id)POST /v1/routefor capability resolutionPOST /hooks/{workspace}/{connection}/{subscription}for signature-verified vendor webhooksGET /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
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.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_bundle →
packages/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
- 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
MGETand 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.
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.