--- title: "Approvals" description: "Manage approval policies and vote on held tool calls through the Obol control plane." --- A destructive tool call in prod is held at the gateway until a human decides it. Control owns the approval policies that decide *which* calls are held and *who* may review them, and the inbox where reviewers vote. See [Approvals](/policy/approvals) for the model. There is no endpoint that creates an approval request. Requests arrive from the gateway when a call is held; these routes read them and record votes. | Route | Action | Roles | |---|---|---| | `GET /approval-policies`, `GET /approval-policies/{id}` | `workspace.read` | viewer and above | | `POST`, `PUT`, `POST …/disable` on approval policies | `approval.policy.manage` | owner only | | `GET /approvals`, `GET /approvals/{id}` | `workspace.read` | viewer and above | | `POST /approvals/{id}/votes` | `approval.decide` | admin and above | Mutating routes re-read the workspace and the membership under the mutation lock before authorizing, so a role or freeze change that lands mid-request is seen. ## Approval policy object The `operator_approval_policy` contract, frozen in `packages/proto/operator_approval_policy.schema.json`. Currently `1`. Prefixed `apy_`. Prefixed `ws_`. Unique within the workspace. `active` or `disabled`. Monotonic revision number, starting at 1. This is the value you pass as `expected_revision`. Prefixed `ayr_`. The stored, normalized rules. Every list inside a rule is deduplicated and sorted on store, so what you read back may not be ordered as you sent it. RFC 3339 UTC. RFC 3339 UTC. ### Rule fields 1–64 characters matching `^[A-Za-z0-9][A-Za-z0-9_.:-]*$`. Unique within the policy. `allow` or `deny`. Exactly one entry, `"tool.call"`. The action must be named explicitly; there is no wildcard. 1–100 tool patterns. Each is at most 256 characters and supports literal names and `*` only — `?`, `[`, `]`, and whitespace are rejected. 1–3 entries from `dev`, `stage`, `prod`. Up to 2 entries from `admin`, `owner`. May be empty, but a rule must name at least one approver by role or by user id. Up to 100 control user ids, each prefixed `usr_` and at most 64 characters. Every id must be a current member of this workspace, or the request is rejected with `422`. `1` or `2`. The number of approvals required. ## List approval policies `GET /api/v1/workspaces/{workspace_id}/approval-policies` Requires `workspace.read`. Returns every policy in the workspace, disabled ones included, ordered by name then id. Not paginated. Workspace id, prefixed `ws_`. ```bash cURL curl "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/approval-policies" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" ``` ```json Response { "approval_policies": [ { "schema_version": 1, "policy_id": "apy_01hxyz", "workspace_id": "ws_acme_prod", "name": "production writes", "status": "active", "revision": 1, "revision_id": "ayr_01hxyz", "rules": [ { "rule_id": "refunds", "effect": "allow", "actions": ["tool.call"], "tools": ["stripe.create_refund"], "environments": ["prod"], "approver_roles": ["admin", "owner"], "approver_user_ids": [], "quorum": 2 } ], "created_at": "2026-09-04T12:00:00Z", "updated_at": "2026-09-04T12:00:00Z" } ] } ``` ## Get an approval policy `GET /api/v1/workspaces/{workspace_id}/approval-policies/{policy_id}` Requires `workspace.read`. Returns the approval policy object. Workspace id, prefixed `ws_`. Approval policy id, prefixed `apy_`. ### Errors | Status | Condition | |---|---| | `404` | `{"detail": "approval policy not found"}`, or no such workspace / membership. | ## Create an approval policy `POST /api/v1/workspaces/{workspace_id}/approval-policies` Requires `approval.policy.manage` (owner). Returns `200` with the new policy at revision 1. 1–128 characters, and must not be blank after trimming. Unique within the workspace. 1–100 rules. See [Rule fields](#rule-fields). Must be omitted on create. Sending it is a `422`. Additional properties are rejected. ```bash cURL curl -X POST "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/approval-policies" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "production writes", "rules": [ { "rule_id": "refunds", "effect": "allow", "actions": ["tool.call"], "tools": ["stripe.create_refund"], "environments": ["prod"], "approver_roles": ["admin", "owner"], "approver_user_ids": [], "quorum": 2 } ] }' ``` ```json Response { "schema_version": 1, "policy_id": "apy_01hxyz", "workspace_id": "ws_acme_prod", "name": "production writes", "status": "active", "revision": 1, "revision_id": "ayr_01hxyz", "rules": [ { "rule_id": "refunds", "effect": "allow", "actions": ["tool.call"], "tools": ["stripe.create_refund"], "environments": ["prod"], "approver_roles": ["admin", "owner"], "approver_user_ids": [], "quorum": 2 } ], "created_at": "2026-09-04T12:00:00Z", "updated_at": "2026-09-04T12:00:00Z" } ``` ### Errors | Status | Condition | |---|---| | `401` | No session, or the session did not verify. | | `403` | Role does not grant `approval.policy.manage`. | | `404` | No such workspace, or the operator is not a member. | | `409` | Workspace is frozen, or `{"detail": "approval policy name already exists"}`. | | `422` | `{"detail": "expected_revision is only valid when updating"}`, a blank name, a named approver who is not a current workspace member, or any rule-validation failure. | Rule-validation messages include `rules must contain between 1 and 100 entries`, `rule fields are invalid`, `rule_id must be unique`, `effect must be allow or deny`, `actions must explicitly match tool.call`, `tools must not be empty`, `tool patterns support literal names and * only`, `environments are invalid`, `approver principals are invalid`, and `quorum must be 1 or 2`. ## Revise an approval policy `PUT /api/v1/workspaces/{workspace_id}/approval-policies/{policy_id}` Requires `approval.policy.manage` (owner). Replaces the name and the whole rule set, creating revision `current + 1`. Returns `200` with the new revision. Approval policy id, prefixed `apy_`. 1–128 characters, not blank after trimming. 1–100 rules. The full replacement set — this is not a patch. The `revision` you are replacing, at least 1. Required on update. ```bash cURL curl -X PUT "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/approval-policies/apy_01hxyz" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "production writes", "expected_revision": 1, "rules": [ { "rule_id": "refunds", "effect": "allow", "actions": ["tool.call"], "tools": ["stripe.create_refund", "stripe.cancel_subscription"], "environments": ["prod"], "approver_roles": ["owner"], "approver_user_ids": [], "quorum": 1 } ] }' ``` ### Errors | Status | Condition | |---|---| | `403` | Role does not grant `approval.policy.manage`. | | `404` | `{"detail": "approval policy not found"}`, or no such workspace / membership. | | `409` | `{"detail": "approval policy revision changed"}`, a duplicate name, or a frozen workspace. | | `422` | `{"detail": "expected_revision is required"}`, or any rule-validation failure listed above. | ## Disable an approval policy `POST /api/v1/workspaces/{workspace_id}/approval-policies/{policy_id}/disable` Requires `approval.policy.manage` (owner). Sets `status` to `disabled` without deleting the policy or its revisions. Returns `200` with the policy object. The `revision` you observed, at least 1. ```bash cURL curl -X POST "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/approval-policies/apy_01hxyz/disable" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" \ -H "Content-Type: application/json" \ -d '{"expected_revision": 1}' ``` Changing or disabling an approval policy changes the workspace's policy-set hash. Every pending or approved-but-unexecuted approval that was raised under the old set becomes `invalidated` and can no longer be voted on or executed. ### Errors | Status | Condition | |---|---| | `403` | Role does not grant `approval.policy.manage`. | | `404` | `{"detail": "approval policy not found"}`. | | `409` | `{"detail": "approval policy revision changed"}`, or a frozen workspace. | | `422` | `expected_revision` missing or below 1. | ## Approval object The `operator_approval` contract, frozen in `packages/proto/operator_approval.schema.json`. Prefixed `apr_`. Prefixed `ws_`. The original request's environment (`dev`, `stage`, `prod`), not a union of matching rule scopes. The virtual key that made the held call, prefixed `key_`. The inbound request, prefixed `req_`. Also the `X-Request-Id`. The logical tool call, prefixed `inv_`, when one is bound. The namespaced tool name the rules matched. The namespaced tool name the invocation names. Prefixed `conn_`. Numeric context only — currently `amount_usd`. Never arbitrary argument names or values: raw tool arguments are not in this projection. Why the call was held. `pending`, `approved`, `denied`, `expired`, or `invalidated`. This is the **effective** status: a stored `pending` or `approved` request that is unexecuted and past `expires_at` reports `expired`, and one whose policy-set hash no longer matches the workspace's current set reports `invalidated`. `awaiting_quorum`, `quorum_met`, `default_deny`, `reviewer_deny`, `expired`, `policy_changed`, or `execution_failed`. `not_requested`, `pending`, `succeeded`, or `failed`. The approval's own optimistic-concurrency counter. Pass it as `expected_revision` when voting. RFC 3339 UTC. RFC 3339 UTC. After this, the approval can no longer be voted on or executed. RFC 3339 UTC, or `null`. RFC 3339 UTC, or `null`. `policy_set_hash` plus the `rules` that matched, each carrying its `policy_id`, `revision_id`, `rule_id`, `effect`, `actions`, `tools`, `environments`, `approver_roles`, `approver_user_ids`, and `quorum`. This is the rule set as it stood when the call was held. Every vote cast, each with `user_id`, `decision`, `reason`, and `created_at`. `receipt_id`, `error_code`, and `digest` from the resumed execution, each `null` until it runs. Whether the signed-in operator could vote right now. Informational only — control rechecks authorization under its row lock when the vote arrives. `not_pending`, `expired`, `frozen`, `role`, `policy_changed`, `not_eligible`, or `already_voted`. `null` when `can_vote` is true. The signed-in operator's own vote (`decision`, `reason`, `created_at`), or `null`. Approvals needed. Approvals cast so far. ## List approvals `GET /api/v1/workspaces/{workspace_id}/approvals` Requires `workspace.read`. Keyset-paginated, newest first by `(created_at, approval_id)`. 1–50. Opaque cursor from a previous `next_cursor`, at most 1024 characters. An undecodable cursor is a `422` (`{"detail": "invalid cursor"}`). One of `pending`, `approved`, `denied`, `expired`, `invalidated`. Filters on the **effective** status, so `pending` excludes rows that have expired or been invalidated by a policy change, and `expired` includes stored-`pending` rows past their expiry. ```bash cURL curl "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/approvals?decision_status=pending&limit=25" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" ``` ```json Response { "approvals": [ { "schema_version": 1, "approval_id": "apr_test", "workspace_id": "ws_acme_prod", "key_id": "key_approval", "request_id": "req_01hxyz", "invocation_id": "inv_01hxyz", "tool": "stripe.create_refund", "invocation_tool": "stripe.create_refund", "connection_id": "conn_01hxyz", "env": "prod", "safe_summary": { "amount_usd": 12.0 }, "reason": "destructive tool in prod", "decision_status": "pending", "decision_reason": "awaiting_quorum", "execution_status": "not_requested", "revision": 1, "created_at": "2026-09-04T12:00:00Z", "expires_at": "2026-09-04T12:15:00Z", "decided_at": null, "executed_at": null, "policy_snapshot": { "policy_set_hash": "…", "rules": [] }, "votes": [], "result": { "receipt_id": null, "error_code": null, "digest": null }, "can_vote": true, "vote_block_reason": null, "current_user_vote": null, "quorum_required": 2, "approvals_received": 0 } ], "next_cursor": null } ``` Approval objects. Pass back as `cursor`. `null` when the listing is exhausted. ## Get an approval `GET /api/v1/workspaces/{workspace_id}/approvals/{approval_id}` Requires `workspace.read`. Returns one approval object. Approval id, prefixed `apr_`. ### Errors | Status | Condition | |---|---| | `404` | `{"detail": "approval not found"}`, or no such workspace / membership. | ## Vote on an approval `POST /api/v1/workspaces/{workspace_id}/approvals/{approval_id}/votes` Requires `approval.decide` (admin or owner) **and** eligibility under the rules that matched when the call was held. Returns `200` with the updated approval object, including the recomputed quorum. The approval row is locked for the duration, so two reviewers racing to the last slot cannot both succeed. `approve` or `deny`. 1–512 characters. Whitespace-only is rejected — a review that records nothing is not a review. The approval's `revision` as you read it, at least 1. Additional properties are rejected. ```bash cURL curl -X POST "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/approvals/apr_test/votes" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "decision": "approve", "reason": "checked refund against the ticket", "expected_revision": 1 }' ``` ### Errors | Status | Condition | |---|---| | `401` | No session, or the session did not verify. | | `403` | Role does not grant `approval.decide`, or `{"detail": "operator is not an eligible approver"}` — the role is sufficient but no matched rule names this operator. | | `404` | `{"detail": "approval not found"}`, or no such workspace / membership. | | `409` | Workspace is frozen; `{"detail": "approval revision changed"}`; `{"detail": "approval is not pending"}`; `{"detail": "approval has expired"}`; `{"detail": "approval policy changed"}`; `{"detail": "operator already voted"}`. | | `422` | `{"detail": "review reason must not be blank"}`, or body validation failed. | `vote_block_reason` on the approval object tells you in advance which of these a vote would hit. It is a hint for rendering, not an authorization result: control re-evaluates every condition under the row lock.