The path of one tool call
1
An agent presents an Obol virtual key
Authorization: Bearer ob_live_… on /v1/* or /mcp. The gateway resolves it
by SHA-256 hash from the Redis key cache, falling back to control. Revoked,
expired, environment-mismatched, and out-of-scope keys are rejected here; a
store failure is NotReady, never a pass. See
/security/virtual-keys.2
Policy decides
CEL decides visibility —
tools/list never shows a tool the key cannot call.
Cedar decides permission, default-deny, with argument conditions. A destructive
tool in prod requires an approval unless policy explicitly grants it. All of
this is compiled data evaluated in-process, with no network hop per decision.
See /policy/overview.3
The egress guard runs
The resolved upstream URL — an
mcp_remote origin or an openapi base URL — is
checked before the outbound hop. A blocked upstream terminates the invocation
upstream_blocked and is never dispatched. See
/security/egress-and-ssrf.4
The vault unwraps
Vault::unwrap_checked decrypts the connection’s EncryptedCredential inside
the gateway process, refusing an expired credential before touching the KEK.
An unwrap failure terminates the invocation credential_unavailable; it never
falls through to an uncredentialed call. See /security/vault.5
The credential is injected outbound
OutboundCredential::apply writes the vendor credential onto the outbound
request — Authorization: Bearer, Authorization: Basic, a named header with an
optional prefix, or a query parameter — with the header value marked
sensitive. This is the only place header or query mutation happens.6
A receipt is written
Every tool call carries an Obol-minted idempotency key (
idt_ UUIDv7) and
produces a receipt naming the policy id, upstream id, vendor status, and the
evidence class of the route. See /receipts/overview.What each party can see
Evidence is tier-dependent; the guarantee is not
Everything above — authentication, scoping, CEL visibility, Cedar permission, approvals, budgets, environment separation, revocation at the Obol hop, idempotency — happens upstream of every connector tier and is therefore tier-independent. What Obol can prove afterwards is not. A native route can begateway_observed; a federated catalog route cannot, because the socket the
gateway observed terminated at the broker rather than at the vendor. The receipt
always names the class. See /receipts/evidence.
The worker hand-off, and its boundaries
apps/connectors ships nothing — one README, no package, no Dockerfile, no
image — and no deployment target defines a connector-worker service or sets
OBOL_CONNECTORS_URL. Every native connector with a reviewed pack compiles to
the in-process OpenAPI executor instead, which is what makes its transport
evidence gateway_observed. The worker:// target and its hand-off are
described here because the code path exists in the gateway, not because
anything reaches it.ToolTarget::Worker call is an MCP tools/call to
{OBOL_CONNECTORS_URL}/mcp/{worker} over a per-call streamable-HTTP session
(apps/gateway/crates/obol-mcp/src/worker/). Two credentials cross that hop and
they do different jobs:
Authorization: Bearer <service JWT>— HS256, audienceobol-connectors, 60-second TTL, minted per call. This authenticates the gateway, not the customer and not the vendor.x-obol-upstream-authorization— the rendered vendor credential, present only when the connection has one. It is produced by runningOutboundCredential::applyinto a scratch header map, markedsensitive, and passed as a custom header. The worker copies it onto its vendor request and never persists it.
WorkerCallRequest contract:
{connection_id, workspace_id, tool, arguments, idempotency_key}. ADR-0014
chose a header over the body deliberately — bodies get logged, buffered, and
snapshotted by frameworks far more often than headers, and MCP handlers see
bodies but not headers.
Hard boundaries on that header:
- It is never logged. The only log line for a worker call carries the worker,
connection, tool, idempotency key, and
last4. A gateway test asserts the string never appears in log output, and another asserts the header name is mentioned nowhere in the tree outsideobol-mcp/src/worker. - It never appears on a remote MCP relay call — a relay test asserts its absence.
- A worker holds no key material at rest and has no KEK or KMS dependency.
- Retries are bounded by transport certainty and the tool’s declared vendor idempotency binding, and reuse the same idempotency key. A post-send transport failure is never retried, because the worker may have executed.
Why workers are meant to be credential-blind
ADR-0024 decided the destination state and is accepted-not-implemented (parked by ADR-0058). It states the rule plainly: The reasoning is that redaction and no-at-rest rules reduce accidental leakage but do not stop a compromised worker dependency from exfiltrating each live credential. Under ADR-0024 a worker would return aRequestPlan — a declared
operation id, method, relative path, selected safe headers, and a bounded body,
never an absolute destination and never a credential — which the gateway would
validate against the pinned connector bundle before injecting the vaulted
credential and performing the HTTP itself. Worker-derived semantic fields would
stay connector_attested; HTTP status, digests, and gateway-selected response
fields would stay gateway_observed. A worker cannot assign trust, evaluate
Cedar, mint a receipt, or choose a verification conclusion.
ADR-0024 supersedes ADR-0014’s credential hand-off and retry mechanics on paper.
The shipped gateway path is still ADR-0014’s
x-obol-upstream-authorization header, so treat that header as legacy surface:
do not extend it, and do not add a worker that depends on it. A vendor whose
request signing Obol cannot express as an auth profile is a reviewed exception
requiring a new ADR, not a secret hand-off.
See /connectors/trusted-workers for the tier
decision and what a worker is for.
What is prohibited outright
- Pooled vendor accounts resold through Obol’s API.
- Storing a customer’s full
sk_live_. Control rejects any sealable secret beginning withsk_live_, and the gateway rejects a Stripe API-key seal beginning withsk_live_again on its own side. - A shared broker OAuth application across tenants. A federated connection uses
the customer’s own OAuth client, and activation fails if one broker account
identifier appears under more than one
workspace_id(ADR-0030). - Exposing a vendor secret to an agent, a worker config, a log, or the web app.
- Pass-through, where the agent holds the vendor key and Obol only logs.