Skip to content

Approval gates & circles

An approval gate asks for permission to perform one precisely described action. The gate freezes its approver pools, records one revisioned decision history, and, if approved, issues one action-bound execution capability.

Creating a gate commits the gate, frozen approver snapshots, request signal, initial audit events, and any matching circle decision together. A partial approval graph is never externally visible.

Pass an idempotency_key when a create request may be retried:

  • the same requester, key, and canonical request return the existing gate;
  • the same requester and key with a different action, payload, or policy returns APPROVAL_IDEMPOTENCY_CONFLICT;
  • a retry never reproduces a plaintext execution token.

The canonical request includes resolved policy references. Role names and identity names are resolved at creation and stored as concrete snapshot IDs, so later membership or fill changes cannot silently widen an existing approval.

Every pending gate has exactly one active_tier: primary, fallback, or escalation. Only unique eligible identities in that tier count.

Mode Approval threshold in the active tier
any one approval
quorum min_approvals unique approvals
all every identity in that tier approves

An exact duplicate vote is an idempotent acknowledgement-loss retry. Reusing the same identity with a different vote or note returns APPROVAL_DUPLICATE_VOTE. Late votes cannot alter a terminal gate.

A primary denial either terminates the gate or atomically activates the escalation tier, according to on_deny. Idle processing can activate fallback or escalation when the configured pool exists. Tier changes, votes, threshold evaluation, terminal state, audit events, and token hash issuance share one transaction and one revision fence.

The database clock is authoritative. If the stored deadline has passed, an approval, denial, cancellation, or reaper request all resolve through the same pending-revision check, and timeout wins. Caller clocks cannot force or postpone a deadline or idle-tier transition.

Concurrent approve, deny, cancel, and timeout requests therefore produce one terminal state and one matching deterministic event set.

Execution authorization and durable attempt

Section titled “Execution authorization and durable attempt”

MCP and CLI creation use requester-bound execution by default for an agent requesting approval for its own exact operation. SDK and HTTP callers can select it explicitly:

{
"execution_binding": {
"mode": "requester_bound",
"attempt_id": "stable-id-for-this-action"
}
}

Chorus commits the authenticated requester and attempt ID inside the fingerprinted action payload. The decisive approval returns no secret. That requester begins through beginRequesterBoundExecutionAttempt (or the bound MCP/CLI command), and storage atomically checks the gate, identity, attempt, fingerprint, expiry, and one-time transition. Finish uses the same identity binding and revision fence. Approval, begin, and finish emit personal approval invalidations so agents and Console can converge on Approval → Ready → Running → Done without polling a token handoff.

Bearer mode remains the explicit delegation adapter for a different executor or a pre-binding gate. Existing gates without an execution mode are bearer gates.

For bearer mode, the transaction that first commits approval receives a random plaintext token. Chorus stores only its SHA-256 hash, action fingerprint, database expiry, and consumption state. Reads and idempotent retries never return plaintext.

To call a route protected by requireApproval, send:

X-Approval-Gate-Id: approval_gate:...
X-Approval-Token: <plaintext from the winning response>
X-Approval-Attempt-Id: <stable id for this exact action request>

The TypeScript SDK exports buildApprovalExecutionHeaders to construct these headers. Choose a new attempt ID for new work; reuse it only when retrying the exact same action and body.

Token acceptance atomically checks the approved state, token hash, action fingerprint, and database expiry, consumes the token, and creates a durable execution attempt:

  • one of competing consumers is accepted;
  • a different attempt after consumption gets APPROVAL_TOKEN_CONSUMED;
  • the same active attempt gets APPROVAL_EXECUTION_IN_FLIGHT until its lease expires;
  • a failed or stale attempt can resume with the same ID, token, and fingerprint only before the original token expiry;
  • a succeeded attempt gets APPROVAL_EXECUTION_COMPLETED and never runs again;
  • reusing an attempt ID for different work gets APPROVAL_EXECUTION_ATTEMPT_CONFLICT.

Accepted, resumed, succeeded, and failed transitions are audit events. If a worker crashes after acceptance, the unfinished attempt remains visible and becomes resumable after its lease, but the original token expiry is the absolute resumption deadline. Terminal results and exact finish replays remain observable after that deadline.

At gate creation, Chorus copies the requested execution lease into action_payload.approval_execution_lease_seconds before computing the action fingerprint. If the request omits it, Chorus samples APPROVAL_EXECUTION_LEASE_SECONDS (default five minutes) once and commits that value. Begin and resume operations use only the committed value; later environment changes and caller-provided lease values cannot alter it. Legacy approved gates without this field fail closed and require a fresh gate.

Chorus likewise copies the execution-token lifetime into action_payload.approval_execution_token_ttl_seconds before fingerprinting. The value is sampled once from APPROVAL_EXECUTION_TOKEN_TTL_SECONDS (default one hour), must be a whole 60–604800 seconds, and must cover the committed execution lease. Storage derives the absolute token/resumption deadline only from that committed value; it never accepts a caller-supplied TTL or resamples the environment when an approval later crosses threshold. Legacy gates without the committed lifetime fail closed and require a fresh gate.

External workers must use the public begin/finish protocol rather than treating an approved gate as permission by itself:

  1. Independently derive the expected action fingerprint from the exact local action or candidate manifest.
  2. Begin one stable attempt ID with the gate ID, capability, and expected fingerprint.
  3. Perform the action only when the response is accepted or resumed and execution_authorized is true.
  4. Finish that same attempt with its returned revision and an outcome of succeeded, retryable_failure, or terminal_failure.

observed_in_flight and observed_completed are reconciliation observations. They always have execution_authorized: false and must never start or repeat work. Finish responses also have execution_authorized: false; finalization records an outcome but does not grant a second execution.

The CLI enforces this distinction: approvals execution begin-bound needs the gate, committed attempt, and action fingerprint but no capability file. Bearer approvals execution begin requires both an owner-only approval-response file and --expected-action-fingerprint, and exits nonzero for a non-authorizing observation. For high-risk operations, prefer the REST header or this file-based CLI path. Bearer RPC and MCP calls carry plaintext in their request body, so use them only where request bodies and transcripts are not recorded. Requester-bound MCP calls carry no approval secret.

When requireApproval commits the protected action but cannot record its final state, it preserves the action response and adds X-Chorus-Approval-Execution: reconciliation-required plus the attempt ID. Do not retry the action from that response. Reconcile the durable attempt and finish its existing revision instead.

A circle is a bounded standing authorization. During atomic gate creation, Chorus matches active circles by requester membership in the circle ring, allowed_actions, and the circle validity window. A matching auto_approve_enabled circle creates the gate already approved, with its token hash and audit events in the same commit. Only the winning create response receives plaintext.

Circle auto-approval still creates a gate and complete audit history; it bypasses votes, not accountability. Use circles only for repetitive, tightly bounded actions. Fresh approval gates remain appropriate for spend, customer-facing actions, production changes, and other decisions whose context must be reviewed each time.

GET /circles and client.circles.list() accept limit (default 20, maximum 100). Ring membership and active-window filters run in storage before that limit, so a caller cannot receive hidden circles or an under-filled page merely because inaccessible rows sort first. Matching a circle during gate creation is a single database-clock query with requester membership in the predicate; it does not scan rings in application code.

Approval reads deliberately return 404 to callers who are neither the requester, an eligible snapshot identity, nor an admin. This avoids revealing that a hidden gate exists.

Migration 045 adds gate revisions, the explicit active tier, unique idempotency scopes, deterministic event keys, and durable execution attempts. Migration 069 inventories the retained action binding, quarantines legacy gates whose payload no longer matches their fingerprint, and separately quarantines pending or approved legacy gates that do not already commit a valid lease and token-lifetime pair. It never invents those authority-defining values or recomputes a fingerprint. Terminal denied or cancelled history can remain when its action binding is valid. Migration 069 also records executor/finalizer provenance and makes the action binding read-only. Duplicate scopes, orphaned rows, invalid retained bindings, and dependents of a quarantined executable gate move to approval_lifecycle_quarantine instead of being silently accepted into the state machine.