---
title: "Approvals"
description: "How a destructive production tool call is held, reviewed by a human, and resumed — from the Cedar gate through the control-plane inbox to the gateway resume endpoint."
---
Destructive tools in production require an approval unless policy explicitly grants the call. The gate is a Cedar `forbid`; the review is a control-plane workflow; the execution is still the gateway's, under the same authorization it would have applied to any other call.
## The gate
The approval gate is a Cedar `forbid` whose `@id` ends in `needs-approval`:
```cedar
@id("prod-destructive-needs-approval")
forbid(principal, action == Obol::Action::"call", resource)
when { resource.destructive && context.env == "prod" && !context.approved };
```
The gateway turns a `Deny` into `ApprovalRequired` only when **all** of these hold: the tool is destructive, the environment is prod, the call is not already approved, at least one policy matched, and every matched policy id ends in `needs-approval`. A deny caused by any other forbid stays a deny. A deny with no matched policy at all stays a deny too — there is no permit for an approval to unblock.
This is a naming convention. A workspace that authors its own approval forbid must name it with the `needs-approval` suffix, or its denies will never become tickets.
## What the agent sees
The call is held, not executed. The gateway records the invocation in state `AwaitingApproval` and returns an `approval_required` error — HTTP 423, JSON-RPC code `-32003` — carrying `approval_id`, `tool`, `expires_at`, and an inbox URL when one is configured. Tickets expire 24 hours after creation.
Before the held state is committed, the gateway writes the approval ticket to a dedicated Redis stream, `stream:approvals`, and **awaits** its acknowledgement. The lossy usage meter is never used for this. If that write fails, nothing is held and the call stops as not-ready.
The stream write and the held-state commit are not one transaction. A crash between them can leave an orphan ticket in the inbox. Resuming an orphan returns a retryable `not_ready`; it can never create execution authority, because resume requires the exact committed held record.
Raw arguments travel on the private approval stream and into the request snapshot. They never appear in the public receipt or audit envelope.
## The inbox
A control worker drains `stream:approvals` through a consumer group every ten seconds. Each entry is validated, bound to a live workspace, virtual key, and active connection, and inserted as an `ApprovalRequest`. Acknowledgement happens only after the database commit, or immediately for deterministically invalid input that could never become valid on retry. Duplicate valid deliveries are idempotent; a repeated `approval_id` with a different payload is rejected as a collision.
At ingest the request is matched against the workspace's active approval policies. If no `allow` rule matches the tool and environment, the request is closed immediately with `default_deny`.
## Approval policies
Approval policies say **which humans may review which tools in which environments**. They never authorize vendor execution themselves — that stays with Cedar and the gateway.
A rule has this shape:
```json
{
"rule_id": "refunds-prod",
"effect": "allow",
"actions": ["tool.call"],
"tools": ["stripe.create_refund", "stripe.*"],
"environments": ["prod"],
"approver_roles": ["admin", "owner"],
"approver_user_ids": ["usr_01h..."],
"quorum": 2
}
```
| Field | Rule |
|---|---|
| `actions` | Must be exactly `tool.call`. |
| `tools` | Literal names and `*` only. No `?`, no character classes, no whitespace. |
| `environments` | Any of `dev`, `stage`, `prod`. |
| `approver_roles` | Any of `admin`, `owner`. At least one role or one named user is required. |
| `approver_user_ids` | Obol user ids. Named users must belong to the workspace and must remain admins or owners. |
| `quorum` | `1` or `2` distinct eligible people. |
| `effect` | `allow` or `deny`. |
Matching rules:
- A principal matches a rule when their role is selected **or** their user id is named.
- An explicit matching reviewer `deny` defeats every allow. It excludes that person from reviewing; it is not a decision to reject the request.
- Quorum is satisfied when **any one** matching allow rule reaches its own count. Votes are not pooled across rules, so a broad one-person rule also permits a tool covered by a narrower two-person rule. Avoid overlapping weaker grants.
- A permitted reviewer's `deny` vote closes the request.
Managing approval policies is the owner-only operator action `approval.policy.manage`. Casting a vote is `approval.decide`, which admins and owners hold (ADR-0037's role matrix, extended by ADR-0039).
## Voting
Each request stores the policy revisions, an immutable snapshot of the rules that matched it, the reviewer votes, and a hash of the complete active policy set. Votes use optimistic revisions plus workspace and request locking.
Before a vote is accepted the service rechecks:
- the request revision matches the caller's `expected_revision` (otherwise 409)
- the request is still pending, and has not expired
- the workspace's current policy-set hash still equals the one stored on the request
- the caller has at least one eligible allow rule and no matching deny rule
- the caller has not already voted
Changing or disabling any active approval policy invalidates every unexecuted request under the old policy set. The request closes as `invalidated` with reason `policy_changed`, and a new request with a new idempotency key is required. This coarse invalidation is deliberate and fail-closed.
When quorum is met the request becomes `approved`, its execution status becomes `pending`, and a delivery job is enqueued.
## Resume
A control worker runs due delivery jobs every ten seconds. It takes a lease with an attempt fence, then re-verifies everything under the workspace lock before it mints any authority:
The request must still be approved and pending execution, unexpired, in an unfrozen workspace, under the same policy-set hash, with quorum still satisfied by current memberships, and the virtual key must still exist. Any failure marks the job failed with a specific error code — `approval_not_executable`, `approval_expired`, `workspace_frozen`, `policy_changed`, `reviewer_authority_changed`, `key_unavailable`.
Control mints a short-lived HS256 approval token bound to the original approval id, workspace, key, concrete tool, argument hash, and idempotency key.
The worker commits, releasing the transaction, and calls the gateway's service-authenticated `POST /internal/v1/approvals/resume` with the held record's identity, the key hash, the original arguments, the idempotency key, and the token. Only the key *hash* is sent; no plaintext virtual key is ever reconstructed.
The gateway verifies service auth and the token, enforces expiry with no clock-skew leeway, requires the exact durable held record and a matching approval, and then re-applies its current key, workspace, connection, scope, and Cedar state before executing.
The resume endpoint cannot create an invocation, widen the key, or change the destination. A capability resumption pins the original concrete tool and connection while preserving the original logical tool, invocation, and idempotency identity — changing route allowlists cannot move an approved operation to another provider.
Human authority is checked at token mint. A revocation after dispatch cannot retract an operation already in flight.
## Resuming from the agent side
The same held call can also be re-issued by the agent. It repeats the identical `tools/call` with its virtual key in `Authorization`, plus two headers:
```http
x-obol-approval-id: apr_01h...
x-obol-approval-token:
```
The call counts as approved only when the token verifies — signature, issuer `obol-control`, audience `obol-gateway`, expiry — **and** every bound claim matches the live request: the header id, workspace, key id, tool name, canonical hash of the arguments, and effective idempotency key. Any mismatch falls back to `approved = false`, so Cedar's gate re-issues a ticket instead of executing.
## Boundaries
- **Approval is not verification.** An approved request may still be queued, blocked, or failed. Only the gateway receipt states the vendor outcome and the evidence class, and a federated-route receipt does not become gateway-observed evidence because a human approved it. See [Receipts](/receipts/overview).
- **Reviewer views are closed and credential-free.** They show metadata, decision history, and a numeric `amount_usd` summary when the ticket carried one. They do not expose arguments, idempotency keys, request payloads, or approval tokens. Reviewers must confirm the underlying request in their own system of record.
- **This is workspace-scoped approval permission management, not general IAM.** Custom roles, user groups, self-approval exclusion, MFA step-up, notifications, escalation, monetary rule conditions, and policy simulation are out of scope today.
Related: [Cedar authorization](/policy/cedar), [Policy overview](/policy/overview), [Approvals API](/api-reference/approvals), [Receipts](/receipts/overview), [Invariants](/concepts/invariants).