Approval Workflow — End to End
vanzik v2.0updated view on github
What happens from the moment a policy returns REQUIRE_APPROVAL to the moment the agent proceeds (or gives up). Useful when you're deciding approval timeouts, staffing a triage rotation, or wiring notifications.
The 5-step lifecycle#
Agent Vanzik proxy Human (dashboard / CLI / API)
───── ────────────────── ─────────────────────────────
│ │ │
│ 1. /v1/check ─────▶ │
│ │ evaluate policy │
│ │ → REQUIRE_APPROVAL │
│ │ ApprovalQueue.Add │
│ │ Notifier.Send ───────────▶│ (Slack / webhook / console)
│ ◀───── 200 OK ────│ approval_id, approval_url
│ decision=REQUIRE_APPROVAL │
│ │ │ 2. opens dashboard / uses CLI
│ │ │
│ 3. poll /v1/status/{id} (every 2 s) │
│ ──────────────▶ │ │
│ ◀─ status:pending │ │ 4. POST /v1/approve/{id}
│ ──────────────▶ │ ◀─────────────────────────│ (Bearer or session+CSRF)
│ ◀─ status:resolved│ Resolve(ALLOW) │
│ decision:ALLOW broadcast "resolved" │
│ │ │
│ 5. re-run /v1/check (same params) │
│ ──────────────▶ │ → ALLOW (cost reserves, │
│ ◀────────────── │ audit entry written) │
│ │ │
Step numbering matches the sections below.
1. Policy returns REQUIRE_APPROVAL#
Three policy paths lead here:
- Explicit
require_approval:rule match in the relevant scope. - Cost alert threshold: an
est_costabovealert_thresholdbut not abovemax_per_actionreturnsrequire_approval:cost:alert_threshold. Important: no reservation is made againstsessionCostsat this point — the cost is only reserved when the approved action re-runs/v1/check. - (Future) Condition-based approvals (e.g.,
require_priornot satisfied yielding approval instead of deny) — not implemented.
The response body:
{
"decision": "REQUIRE_APPROVAL",
"reason": "Matches approval rule in shell scope",
"matched_rule": "require_approval:shell:<pattern>",
"approval_id": "ap_1a2b3c4d5e6f7890abcdef1234567890",
"approval_url": "http://localhost:8080/v1/approve/ap_1a2b..."
}
The approval_url is built from --base-url (or http://localhost:<port> if unset) and points at the POST-only resolution endpoint /v1/approve/{id} — it is for CLI/scripted resolution, not a browser page (humans approve in the dashboard, see step 4). Behind a reverse proxy, set --base-url https://guardrails.example so the URL in notifications points at the reachable host.
2. Notification fan-out#
Notifier.Send redacts the event and enqueues one job per configured notifier. Each target dispatches asynchronously — a slow Slack webhook never blocks the request thread.
Event payload (redacted before send)#
{
"type": "approval_required",
"timestamp": "2026-04-19T12:03:44Z",
"request": {
"agent_id": "researcher-01",
"scope": "shell",
"command": "sudo apt upgrade"
},
"result": {
"decision": "REQUIRE_APPROVAL",
"reason": "Matches approval rule in shell scope",
"approval_id": "ap_1a2b…",
"approval_url": "https://guardrails.example/v1/approve/ap_1a2b…"
},
"approval_url": "https://guardrails.example/v1/approve/ap_1a2b…"
}
Redactor (in pkg/notify/notify.go — DefaultRedactor) scrubs:
Bearer <token>AKIA[0-9A-Z]{16}(AWS)ghp_[A-Za-z0-9]{36,}(GitHub PAT)xox[baprs]-…(Slack)(secret|token|password|api_key)=<value>sk-…API keys, GoogleAIza…keys, GitHubgho_/ghu_/ghs_/ghr_/github_pat_tokens, JWTs, PEM private keys, andAuthorization:/x-api-key:/api-key:header values
The same redactor also masks the audit trail, GET /v1/audit, the SSE stream and /api/pending (serve --audit-redact) — see OPERATIONS § Audit redaction.
Redaction runs on Command, Path, Domain, Action, URL, Reason, and every Meta value.
Slack payload (what approvers actually see)#
SlackNotifier wraps the event in an attachment (pkg/notify/notify.go):
{
"attachments": [{
"color": "#ecb22e",
"text": ":warning: *REQUIRE_APPROVAL* | scope: `shell` | action: `sudo apt upgrade`\n>Matches approval rule in shell scope\n><https://guardrails.example/v1/approve/ap_1a2b…|Approve this action>",
"footer": "Vanzik",
"ts": 1745064224,
"mrkdwn_in": ["text"]
}]
}
Webhook payload#
POST of the raw event JSON to the configured URL with User-Agent: Vanzik/2.0 and a 10 s timeout.
Dropped events#
If the notifier queue (DefaultQueueSize=256) is full, the event is dropped and counted:
vanzik_notify_events_dropped_total{notifier="slack",reason="queue_full"}
See OPERATIONS.md for tuning.
3. Agent polls /v1/status/{id}#
The SDKs wrap this in wait_for_approval (Python) / waitForApproval (TypeScript). Behavior:
- Poll every 2 s (
pollIntervalMs: 2_000/poll_interval=2). - Send the Bearer token on every poll —
/v1/statusis auth-gated. - Stop on either
status: "resolved"or wall-clock timeout.
Timeouts#
SDK defaults are 5 minutes:
- Python:
guard.wait_for_approval(id, timeout=300, poll_interval=2) - TypeScript:
guard.waitForApproval(id, 300_000, 2_000)
Tune to your human SLA:
- 5 minutes — fine for always-on ops rotations.
- 30–60 minutes — typical for non-urgent approvals.
- 8 hours — operator shift; accepts the restart risk (see below).
On timeout the SDK returns:
CheckResult(decision="DENY", reason="Approval timed out", approval_id="ap_...")
This is a logical deny, not a transport error — the approval_id is preserved so you can resolve it out-of-band.
Restarts and in-flight approvals#
The approval queue is persistent by default (--persist, SQLite-backed — see CLI.md): pending entries are rehydrated on boot, and agents polling /v1/status/{id} across a restart pick up where they left off. Two caveats:
- The store flushes on a ≥1 s background tick — an approval created in the final moments before a hard crash may not have been written and is lost.
- With
--persist=false(in-memory only), a restart loses every pending entry; agents see404on/v1/status/{id}and eventually hit their SDK timeout, receiving the fail-deny response.
Rule of thumb still applies: SDK timeout = expected_human_response_time + restart_budget — even though state survives a restart, nobody can resolve approvals while the process is down.
4. Human resolves#
Three resolution channels, all equivalent:
a) Dashboard (most common)#
Approver opens the dashboard at <base-url>/dashboard, which lists pending approvals in real time (SSE). Each item shows the request (scope, command, agent, reason) with Approve / Deny buttons; clicking posts /v1/approve/{id} or /v1/deny/{id} with the CSRF header. (The approval_url field in the check response is the POST-only API endpoint used by channels b and c below, not a dashboard deep-link.)
b) CLI#
VANZIK_API_KEY=$KEY vanzik approve ap_1a2b…
# Approved ap_1a2b…
vanzik deny ap_1a2b… --url https://guardrails.example
Useful for scripted approvals (e.g., a runbook step) or when the dashboard is unreachable.
c) HTTP API#
curl -X POST -H "Authorization: Bearer $KEY" \
https://guardrails.example/v1/approve/ap_1a2b…
# {"id":"ap_1a2b…","status":"approved"}
See API.md.
What happens server-side#
ApprovalQueue.Resolve(id, decision):
- Locks; if the entry is already resolved, either no-ops (same decision —
idempotent for retried requests) or rejects with a
409conflict (opposite decision — resolutions are write-once; a DENY can never be flipped to ALLOW after the fact, or vice versa). - Otherwise flips
Resolved=true, stampsDecision,ResolvedAt, and the actor (resolved_via:bearer/session/open+ peer host). - Broadcasts a
resolvedSSE event (dashboard updates in real time) — exactly once per approval; retries do not re-broadcast. - Returns.
It does not unblock any waiter — the agent's SDK learns about the resolution on its next poll (within ≤ 2 s).
One-shot consumption and validity#
A resolved ALLOW is a single-use, time-boxed capability:
- The first
/v1/checkretry carrying theapproval_id(and matching the original request shape) is honored withmatched_rule: "allow:approved"and consumes the approval. Any further replay of the same id falls through to fresh policy evaluation and re-enters the approval flow under a new id — one human click authorizes at most one execution. - Honoring is bounded by
--approval-validity(default5m, matching the SDKs'wait_for_approvalwindow;0disables): a resolution older than the window is no longer replayable. - A resolved DENY stays sticky within the validity window (a retrying model gets an immediate deny instead of spawning fresh approval requests) and is never "consumed".
GET /v1/status/{id}is read-only — SDK poll loops and dashboards never spend the capability.- Refused replays are visible as
vanzik_approval_replay_refused_total{reason="consumed"|"expired"}; a risingconsumedcount means something is trying to reuse one approval for multiple executions. - Consumption survives restarts (persisted with the approval), so a restart cannot resurrect a spent ALLOW.
- Multi-node (PostgreSQL store): all of the above holds
cluster-wide, eventually — an approval resolved on one node is visible on
every node within one
--reconcile-interval(default 2s), and a consumed ALLOW is spent everywhere once its stamp reconciles. Inside that staleness window a replay racing to a node that has not yet reconciled can be honored once there; shrink--reconcile-intervalif that window matters to your threat model. Conflicting resolutions across nodes always converge to DENY, and the shared store's merge is monotonic (a resolution is never un-resolved, a resolved DENY is never overwritten by a non-DENY, a consumption stamp is never cleared) — so neither restarts nor lagging replicas can resurrect a spent or denied approval.
5. Agent replays the approval through /v1/check#
wait_for_approval returns the resolution it read from /v1/status/{id}. That read is passive — it spends nothing. The execution is authorized by replaying the approval through /v1/check with approval_id set and the same scope and operationally-meaningful fields as the original request. The replay is what:
- consumes the one-shot ALLOW and enforces
--approval-validity(§4), - reserves cost for cost-scoped actions — the original check returned
REQUIRE_APPROVALbeforecheckCostran, so nothing was reserved againstsessionCosts[session_id], - writes the
allow:approvedaudit entry that ties the execution to the human decision.
Acting on the status poll's result.allowed alone skips all three: the approval is never spent, no cost is reserved, and the audit log shows a resolution with no execution behind it.
final = guard.wait_for_approval(result.approval_id, timeout=300)
if final.allowed:
replay = guard.check("shell", command=cmd, approval_id=result.approval_id)
if replay.allowed: # matched_rule == "allow:approved"
execute(cmd)
const final = await guard.waitForApproval(result.approvalId!, 300_000);
if (final.allowed) {
const replay = await guard.check('shell', { command: cmd, approvalId: result.approvalId });
if (replay.allowed) await execute(cmd);
}
A refused replay (id already consumed, or older than --approval-validity) is not an error on the wire: the request falls through to fresh policy evaluation and comes back as REQUIRE_APPROVAL under a new id. Treat that as a new approval request, never as permission.
SDK convenience: guarded with wait_for_approval=True#
Both SDKs let you opt into blocking-until-resolved:
# Python
@guarded("cost", guard=guard, wait_for_approval=True, approval_timeout=300)
def expensive_call(prompt): ...
// TypeScript
const gated = guarded(guard, 'cost', makeExpensiveCall, {
waitForApproval: true,
approvalTimeoutMs: 60_000,
});
The decorator/HOF internally: check → if REQUIRE_APPROVAL, wait_for_approval → on ALLOW resolution, replay check with approval_id (consuming the approval and reserving cost) → run the wrapped function only if the replay allows. A DENY resolution, a denied replay, or a timeout raises VanzikDenied / VanzikApprovalTimeout; a refused replay that re-entered the approval flow raises VanzikApprovalRequired carrying the new id — the wrapper never waits a second time on its own.
Operational guidance#
Sizing the approval queue#
MaxPendingApprovals = 10000(hardcoded).- Evicts resolved entries first, so a healthy triage process never hits the cap.
- A backlog > 1000 almost always means nobody is triaging — alert on
vanzik_pending_approvals > 100 for 15m.
Choosing notifier targets#
- Slack / Discord / Teams: best for human triage (interactive link, colored attachment).
- Webhook: for piping into your incident platform (PagerDuty, Opsgenie).
- Console / log: dev-only, or as a fallback when other targets are down.
Auditability#
Every resolution is recorded in the audit log when it is made: one entry with decision: ALLOW or DENY, matched_rule: approval:resolved:<via> (bearer, session or open, how the call was authenticated), the approval_id, the original request, and the caller's address in reason. Repeating the same decision writes no second entry. The follow-up re-run check is also recorded (allow:approved / deny:approved). An approval that times out is not recorded. You can reconstruct the full chain by querying /v1/audit?agent_id=X and following approval_id values.
Common failure modes#
| Symptom | Cause | Fix |
|---|---|---|
| Approvers never see Slack/webhook notification | Queue dropped on slow endpoint, or filter mismatch in notifications.approval_required |
Watch vanzik_notify_events_dropped_total; verify the notifier's type block in policy. |
approval_url in Slack is http://localhost:8080 behind a proxy |
--base-url not set |
Start server with --base-url https://guardrails.example. |
Approval works in dashboard but POST /v1/approve/{id} from script returns 401 |
Missing Bearer | Set Authorization: Bearer $KEY. |
| Approvals "stuck" after a restart | Running with --persist=false, or the entry was created inside the final store-sync window before a crash |
Re-issue check() from the agent; resolving stale IDs is a no-op. |
| Agent sees ALLOW but action fails at cost check | Cost only reserves on the check(approval_id=...) replay after approval |
Replay the approval (§5); the guarded wrapper with wait_for_approval=True does it for you. |
Related docs#
docs/API.md— approve/deny/status endpoint shapes.docs/DASHBOARD.md— UI walkthrough for approvers.docs/OPERATIONS.md— queue sizing and restart behavior.docs/POLICY_REFERENCE.md— when REQUIRE_APPROVAL fires.