# Approvals & Human Review

> Applies to: ChengOS v0.2.0+ ｜ Last verified: 2026-09-25 ｜ Source: `crates/cheng-core/src/domain/approval.rs`, `crates/cheng-core/src/services/tool_call_approval.rs`, `crates/cheng-core/src/services/approval_gate.rs`, `crates/cheng-nodes/src/nodes/builtin/utils/approver.rs`, `crates/cheng-api/src/services/approval_reaper.rs`

Two different mechanisms put a human in the loop:

| | Approval | Review |
|---|---|---|
| Question | "May I do this?" | "Is this change acceptable?" |
| Asked | **Before** the action | **After** the work is staged |
| State | `waiting_for_review` | `waiting_for_review` |
| Trigger | The approval gate | A tool returning `requires_review: true` |
| Example | Running an SSH command | An agent's document edits |

Both suspend the execution durably. Neither is a UI convention you can bypass by calling the
API differently — the gate blocks inside the node.

## The approval gate

The gate is a **protocol primitive** shared by every high-risk node — SSH, HTTP writes, MCP,
code execution — and by the `utils/approver` node you can place on the canvas yourself.

```text
caller
  │
  ▼
 is_whitelisted()? ──── yes ──► return Approved (whitelist_hit = true)
  │ no
  ▼
 subscribe(approval_bus)          ◀─ subscribe FIRST, never miss an event
  │
  ▼
 persist execution → waiting_for_review + ApprovalContext
  │
  ▼
 publish ApprovalRequested        ◀─ the WS event that renders the approval card
  │
  ▼
 await decision OR durable poll, with timeout
  │
  ▼
 persist execution → running, clear ApprovalContext
  │
  ▼
 publish ApprovalResolved
  │
  ▼
 approved && scope == session ──► on_session_approved hook (add to whitelist)
```

**The order of those steps is the design.** Subscribing before persisting eliminates the race
where a fast click fires its event before the receiver is attached. Persisting before publishing
eliminates the race where the user clicks Approve and the API rejects it because the state is
still `running`. Get either order wrong and you have a rare, unreproducible hang.

Decisions are also recorded on the approval context, not only broadcast: the bus is an
in-memory broadcast channel, so a decision published while no waiter is subscribed would
otherwise be lost forever.

## Risk levels and decisions

**Risk levels** are ordered `low < medium < high < critical` — deliberately, so a classified
risk can be compared against a configured threshold with ordinary comparison operators.
`medium` is the default.

**Decisions**: `approved`, `rejected` (feedback goes back to the LLM for replanning), or `skip`
(skip this action and return a feedback string).

**Scope**: `once` (default) or `session` — session adds the action to the whitelist for the rest
of the conversation.

**Outcomes** recorded on the result: `approved`, `rejected`, `skipped`, `timed_out`, plus a
`whitelist_hit` flag distinguishing "the user said yes" from "a previous rule matched".

## The approver node

`utils/approver` puts a gate anywhere on the canvas.

| Input | Meaning |
|---|---|
| `action_name` | What is being authorised, e.g. `HTTP POST to api.stripe.com` |
| `risk_level` | `low` / `medium` / `high` / `critical` |
| `description` | Shown to the user in the card |
| `param_summary` | JSON summary of the parameters, shown in the card |
| `extra_whitelist` | Extra comma-separated action names to auto-approve |
| `timeout_secs` | Default 300; auto-reject after this |

The output has an `approved` boolean **and** a separate runtime-conditional approved signal port
that is only present when approval was granted. Wire your dangerous action through that port and
it is skipped automatically on rejection, timeout, or policy denial — no explicit condition node
required, because [a null input means "don't take this path"](/docs/editor-variables-and-mapping).

## Deciding

**REST**:

```text
POST /executions/:id/approve
{ "request_id": "…", "decision": "approved|rejected|skip", "scope": "once|session", "reason": "…" }
```

`approve`/`reject` are accepted as aliases of `approved`/`rejected`. An unknown decision is a
400, not a silent default.

**In the UI**: the approval card in chat and the editor.

**From an external channel**: `~~y` to approve, `~~n [reason]` to reject. `~~y xxx` is
deliberately **refused** — the first version will not accept anything that reads as a
conditional approval, because "yes, but…" has no unambiguous meaning at a security gate. See
[Channel Routing](/docs/chat-channel-routing).

## The approval reaper

The handshake has three participants: the gate blocked inside a node, the execution row saying
`waiting_for_review`, and the user's decision. If the gate disappears without releasing the row
— a process restart, a panicked task, a node cancelled out from under it — the row would park
forever, and the user would face an approval card whose buttons return
`NO_ACTIVE_APPROVAL_WAITER`.

The reaper closes that hole. Any approval that has outlived its own `timeout_secs` by more than
a grace period is transitioned to `timeout`.

| Env var | Default | Meaning |
|---|---|---|
| `APPROVAL_REAPER_ENABLED` | `true` | Disable the sweep entirely |
| `APPROVAL_REAPER_INTERVAL_SECS` | `120` | How often to sweep |
| `APPROVAL_REAPER_GRACE_SECS` | `300` | Slack beyond each request's `timeout_secs` |

A live gate always releases its own wait within `timeout_secs`, so the grace period keeps the
reaper off healthy approvals. The transition uses a compare-and-set on **both** the state and
the `request_id`, so a decision landing at the same instant always wins over the reaper.

## Reviews

A review is the other shape: the work is already done, staged somewhere, and a human decides
whether to keep it.

When a tool returns `requires_review: true`, the engine moves the execution to
`waiting_for_review` and suspends it durably. The canonical example is
[document shadow sessions](/docs/data-document-workspace): an agent's edits are staged in a
shadow, the diff is reviewable per block, and the execution resumes on the decision.

Review is **per-item**, which is why a shadow session can end in `partially_accepted` — three
of five edits taken, the rest still pending. An approval, by contrast, is a single yes or no.

Resuming a paused execution uses the resume path, not a new chat message:

```text
POST /executions/:id/pause
POST /executions/:id/resume
```

## Agent file changes: risk-tiered, not all-or-nothing

When an agent edits files or runs processes through its tools, the question "does this need a
card?" is answered by one deterministic policy, not per node:

| Tier | Examples | Card? |
|---|---|---|
| Read-only | viewing, listing, searching, diffing files | Never |
| Small, scoped, undoable | creating a file, a backed-up edit or patch up to 64 KiB, copying to a new path | No — runs automatically and records an undo credential |
| Larger or riskier | overwriting, touching a protected path such as `.env` or `.git`, turning off a safety check | Yes |
| Destructive | delete, move, rollback, any multi-file change set | **Always**, bound to the exact paths |
| Process execution | workspace shell commands, running tests | **Always** |

Before deciding, a **preflight** inspects the planned change where the files live, without
writing. The facts it saw are pinned: if a file changes between the card and the execution, the
approved call fails as stale instead of landing on something you did not review. An automatic
call carries an **undo credential** whose revert refuses if the file has changed since.

The card shows the reason code (`destructive_operation`, `overwrite`, `protected_target`, …).
A remembered "always allow" covers only that operation on those paths. The full rules — the
low-impact envelope, protected paths, trusted automation, and the audit record — are in
[Tool-call Approval Policy](/docs/agent-approval-policy).

## Designing with gates

- **Put the gate as late as possible.** Approving "run a shell command" is meaningless;
  approving the *specific* command with its arguments in `param_summary` is not.
- **Use `session` scope sparingly.** It silences the gate for the rest of the conversation.
- **Set `timeout_secs` to match your operators.** The default 300 seconds assumes someone is
  watching; an overnight batch needs a longer window or no gate at all.
- **Prefer review over approval for content.** Asking "may I edit this document?" up front tells
  the user nothing; showing the diff afterwards tells them everything.

## Next steps

- [Execution Model](/docs/exec-model) — where `waiting_for_review` sits in the state machine.
- [Document Workspace](/docs/data-document-workspace) — shadow sessions, the main review surface.
- [Traces, Logs & Replay](/docs/exec-traces-logs) — auditing what was approved.
- [Tool-call Approval Policy](/docs/agent-approval-policy) — which agent tool calls need a card.
