Skip to main content

Approvals

A centralized queue of human decisions. When an agent proposes a risky action, the platform files an approval item carrying the frozen proposed action, the supporting evidence, and a hard expiry — then a human approves, edits-then-approves, or rejects it.

Overview

Approvals are a producer-agnostic platform module: anything that can propose a risky action is a thin producer over the same queue. The queue — not any single producer — is the product. One item model, one expiry enforcement path, one decision output shape, so every consumer (activity feed, webhooks, UIs) treats an item the same regardless of where it came from.

Items are created by the platform only — there is no public create endpoint. Three producers file items today:

  • the approval orchestration node — declarative placement in a DAG (origin: node);
  • tool-call interception — a guardrail attached to a project, agent, or tool gates tool calls on every execution surface: chat sessions, direct generations, MCP (origin: tool_call);
  • approval-gated task transitions — a workflow transition declaring requires_approval parks a task move behind an approval (origin: task_transition). The item carries no proposed_action; it gates the transition named by task_transition on task_id.

The origin field records which producer filed an item, for analytics and filtering only — the lifecycle never branches on it.

See the Permissions Reference for the IAM action strings for this module.

Data Model

FieldTypeDescription
idstringPublic identifier (apr_…)
project_idstringID of the owning project
originstringnode | tool_call | task_transition — producer origin (analytics/filtering only)
statusstringpending | approved | rejected | expired
proposed_actionobject | nullFrozen { tool_id, action?, arguments } the decision governs; null for task_transition items
reasoningstring | nullThe proposing agent's rationale
evidenceobject | nullStructured supporting data
predicted_impactstring | nullExpected execution effect
expires_atstringServer-enforced hard gate; the item can never execute after this
dedup_keystring | nullSet on tool-call items to suppress duplicate proposals
run_idstring | nullOriginating orchestration run (node producer)
node_idstring | nullOriginating node id within the run's graph
generation_idstring | nullOriginating generation (tool-call producer)
session_idstring | nullSession the originating generation ran in (tool-call producer)
agent_idstring | nullProposing agent
task_idstring | nullGated task (task_transition producer)
task_transitionstring | nullTransition fired on approval (task_transition producer)
policy_versionstring | nullGuardrail policy version that routed here
previous_item_idstring | nullPrior item's ID when this proposal was re-filed after an earlier matching item (same dedup_key) was rejected
resolved_bystring | nullResolving user's public ID; null on expiry
resolution_reasonstring | nullRequired on rejection
edited_argumentsobject | nullSet on edit-then-approve; the original stays in proposed_action
created_atstringISO 8601 creation timestamp
updated_atstringISO 8601 last-updated timestamp

Key Concepts

Snapshot at emit time

All of an item's evidence (proposed_action, reasoning, evidence, predicted_impact) is resolved against run/call state at emit time and frozen onto the item. Later state changes never alter what the approver sees — a decision is made on exactly the evidence the agent had.

How producers suspend and resume

The two producers share the item lifecycle but suspend differently:

  • approval node — the run parks. Orchestration runs are durable: the node emits the item and parks the run as awaiting_input. Resolution re-enqueues the run with the decision output as the node result, routing approved / rejected / on_expired edges.
  • Task transition — the gate parks. A requires_approval transition files the item and sets pending_transition on the task; the task keeps its state and no other transition may fire until the item resolves. Approval fires the transition as the approval actor (guard re-evaluated then); rejection or expiry clears the gate and appends a note to the task's history. See Workflows.
  • Tool-call interception — return-pending. A synchronous generation cannot be held open for hours. The intercepted call files the item and returns { "status": "pending_approval", "approval_id": "apr_…", "expires_at": "…" } as the tool result; the generation completes its turn normally (the model reads the result and closes with "queued for your approval"). On resolution, the platform starts a continuation generation — linked to the original via initiator_generation_id — feeding the decision output back into the agent's context. On approval the platform first executes the frozen (or edited) arguments and includes the tool's output as the decision's result; on rejection or expiry nothing executes and the continuation carries the decision ({ "decision": "expired" } is the exact counterpart of the node path's on_expired edge). When the original generation ran in a session or conversation, the continuation's messages append there.

Duplicate proposals (dedup)

An agent retrying a proposal must not spam the queue. Tool-call items carry a dedup_key derived from the proposing agent, tool, action, and resolved arguments: while a matching item is pending, a duplicate emit files nothing and returns the existing item — the agent's tool result carries the existing approval_id. Once the item resolves (approved, rejected, or expired), the same proposal files a fresh item. Node-produced items are not deduplicated — each run pauses exactly once per approval node.

When the fresh item follows a rejected one with the same dedup_key, it is admitted rather than suppressed and its previous_item_id links back to that rejected item, so approvers see the recurrence. Re-proposal is deliberately not blocked: a rejection is a feedback signal, and suppressing the recurrence would hide the very pattern that tells a human to encode a guardrail rule that stops it upstream (and silently block legitimate re-proposals whose context has changed).

Recurrence view

GET /api/v1/approvals/recurrences is a read-only rollup answering "what keeps coming back?". It groups items by dedup_key and returns those recurring at least min_count times (default 2), most-recurrent first. Each group carries the agent_id, tool_id, count, the ordered item chain (the previous_item_id thread, oldest → newest), and the reasons in order.

Because dedup already threads a re-proposal onto the item it recurs from, a group is simply the set of items sharing a dedup_key — no new model, no cluster lifecycle. Reading three rejection reasons side by side is the curation step: the prompt to encode a guardrail deny that stops the pattern upstream.

  • status (default rejected) selects the lifecycle state groups are built from — recurring rejections are the primary signal.
  • min_count (default 2) is the floor for a group to be returned.
  • Grouping is exact-key only. Semantic clustering of paraphrased corrections is deliberately out of scope for this deterministic surface.

Expiry is a hard gate

Evidence goes stale, so expiry is enforced server-side in both directions:

  • A background sweeper flips overdue pending items to expired and emits approvals.expired.
  • The resolution path re-checks expires_at at decision time, closing the sweep-vs-approve race. An expired item can never be approved or executed — even a click a millisecond after expiry returns 409 APPROVAL_EXPIRED.

Approve, reject, edit-then-approve

  • Approve resolves the item and resumes its producer with the decision — an approval orchestration node routes down its approved edge (where a downstream tool node acts on the frozen or edited arguments); a tool-call item has its frozen or edited arguments executed by the platform, and the result flows into the continuation generation.
  • Edit-then-approve replaces the arguments via the arguments field on the approve call. Edited arguments must be a JSON object; the original proposal is preserved in proposed_action, and the edit is recorded in edited_arguments.
  • Reject requires a reason, preserved on the item. Rejection reasons and edit diffs are the raw material of the feedback loop: recurring rejections of the same proposal are the signal for graduating the pattern into a guardrail rule.

Decision output

Resolution produces a producer-agnostic decision artifact — the approval orchestration node consumes it as its node result; a tool-call continuation consumes it as the tool result. Identical shape for both:

{
"decision": "approved",
"approval_id": "apr_x1y2z3a4b5c6d7e8",
"resolved_by": "user_a1b2c3d4e5f6g7h8",
"edited_args": { "amount": 450 },
"reason": null,
"result": null
}
  • decisionapproved | rejected | expired
  • resolved_by — resolving user's public ID; null on expiry
  • edited_argsnull unless edit-then-approve
  • reason — required (non-null) on rejection
  • result — the executed tool output on approval. For tool_call items the platform executes the frozen (or edited) arguments at resolution time and populates it; for node items execution belongs to the downstream tool node, so it stays null in the node result

Who may resolve

Any principal with approvals:ResolveApproval in the project may resolve any of the project's items. There is no per-item targeting or assignment — the guardrail policy decides what needs a human, and the project policy layer decides who counts as one. Per-approver routing is a deferred future phase.

Examples

List pending approvals

soat list-approvals --project-id proj_ABC --status pending

List recurring rejections

soat list-approval-recurrences --project-id proj_ABC --min-count 3

Approve (optionally with edited arguments)

soat approve-approval --approval-id apr_01 --arguments '{"amount": 450}'

Reject with a reason

soat reject-approval --approval-id apr_01 --reason "Exceeds monthly budget"