Approval Workflows
For: Platform operators and security teams managing human-in-the-loop controls on AI request execution.
Operations Overview | Approval Workflows | Quarantine | Risk Scoring | CP Connectivity | Telemetry
The require_approval policy decision puts a request on hold and returns HTTP 202 to the caller. An operator reviews and either approves or denies the request via the admin API. Approved requests execute on re-submission. Denied requests return 403.
Table of Contents
- When to Use Approval Workflows
- Approval Lifecycle
- Triggering require_approval in Policy
- Full Lifecycle Walkthrough
- Admin API Reference
- Approval Fields
- Prometheus Metrics
- Environment Variables
- Troubleshooting
- See Also
When to Use Approval Workflows
Use require_approval when a request class needs human review before execution, not just policy enforcement. Common patterns:
| Pattern | Policy trigger |
|---|---|
| High-cost model usage (e.g. GPT-4o above a cost threshold) | budgets layer — cost estimate exceeds threshold |
| New or unrecognized workload making first request | identity layer — workload not in known allowlist |
| Requests with elevated risk score | risk layer — context.risk.score above threshold |
| Sensitive action types requiring sign-off | targets layer — action type matches restricted list |
For automated blocking without human review, use deny or quarantine instead.
Approval Lifecycle
Key behaviors:
- The original request is not executed when
require_approvalfires; only the approval record is created. - The caller must re-submit the same request after approval.
- Pre-approvals are matched on
org_id+workload_id+ action hash. - Expired approvals are treated as denials.
Triggering require_approval in Policy
Add a rule with effect.decision: require_approval in any policy layer:
# In the budgets layer: hold high-cost model requests budgets: - id: budgets.require_approval.high_cost_model priority: 200 when: field: context.action.params.model operator: in value: "gpt-4o" effect: decision: require_approval reason: "high-cost model requires operator approval" # In the risk layer: hold requests with elevated risk score risk: - id: risk.require_approval.high_score priority: 100 when: field: context.risk.score gt: "0.7" effect: decision: require_approval reason: "elevated risk score — manual review required" # In the identity layer: hold unknown workloads identity: - id: identity.require_approval.unknown_workload priority: 50 when: field: context.workload.id operator: not_in value: "wl-prod-app-1,wl-prod-app-2" effect: decision: require_approval reason: "unrecognized workload — operator review required"
The stop_on list controls which decisions halt evaluation. By default, require_approval stops evaluation when matched:
bundle: stop_on: [deny, require_approval]
Full Lifecycle Walkthrough
Step 1 — Submit a request
The caller submits a request that triggers require_approval. The gateway returns HTTP 202:
curl -s -X POST http://localhost:7080/v1/actions:execute \ -H "Content-Type: application/json" \ -H "Authorization: Bearer mvgc_k_<api-key>" \ -d '{ "schema": "mvgc.action_request.v2", "request_id": "01955f3e-aaaa-7abc-8def-000000000001", "org_id": "org-example-001", "workload_id": "wl-prod-app-1", "action": { "type": "ai.infer", "method": "POST", "target": "https://api.openai.com", "path": "/v1/chat/completions", "params": { "model": "gpt-4o" } }, "attribution": { "project_id": "proj-123" } }' | jq .
Response (HTTP 202):
{ "decision": "require_approval", "approval_id": "01955f3e-bbbb-7abc-8def-000000000002", "reason": "high-cost model requires operator approval", "record_id": "01955f3e-cccc-7abc-8def-000000000003" }
The caller stores the approval_id and polls or waits for operator action.
Step 2 — List pending approvals
curl -s "http://localhost:7080/v1/admin/approvals?org_id=org-example-001&status=pending" \ -H "MVGC-Admin-Token: $MVGC_ADMIN_TOKEN" | jq .
Filter options: status=pending|approved|denied|expired, org_id=, workload_id=, limit= (default 20).
Step 3 — Approve or deny
Approve:
APPROVAL_ID="01955f3e-bbbb-7abc-8def-000000000002" curl -s -X POST "http://localhost:7080/v1/admin/approvals/$APPROVAL_ID/approve" \ -H "MVGC-Admin-Token: $MVGC_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"decided_by": "ops-admin@example.com"}' | jq .
Deny:
curl -s -X POST "http://localhost:7080/v1/admin/approvals/$APPROVAL_ID/deny" \ -H "MVGC-Admin-Token: $MVGC_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"decided_by": "ops-admin@example.com"}' | jq .
Both endpoints return the updated approval record.
Step 4 — Re-submit to execute
After approval, the caller re-submits the identical request. The gateway matches the pre-approval and executes normally (HTTP 200):
# Same body as Step 1 curl -s -X POST http://localhost:7080/v1/actions:execute \ -H "Content-Type: application/json" \ -H "Authorization: Bearer mvgc_k_<api-key>" \ -d '{ ... same body as Step 1 ... }' | jq . # → HTTP 200, "decision": "allow"
If the operator denied, the re-submission returns HTTP 403 with "decision": "deny".
Admin API Reference
| Method | Path | Description |
|---|---|---|
GET | /v1/admin/approvals | List approvals. Query params: org_id, workload_id, status, limit |
POST | /v1/admin/approvals/{id}/approve | Approve a pending request. Body: {"decided_by": "string"} |
POST | /v1/admin/approvals/{id}/deny | Deny a pending request. Body: {"decided_by": "string"} |
All admin endpoints require the MVGC-Admin-Token header.
Approval Fields
| Field | Description |
|---|---|
approval_id | UUIDv7 — unique approval request identifier |
org_id | Organisation that owns the request |
workload_id | Workload that triggered require_approval |
status | pending, approved, denied, or expired |
reason | Policy reason code from the DSL rule's effect.reason |
decided_by | Operator identity supplied when approving or denying |
created_at | When the approval request was created |
decided_at | When the operator made a decision (null while pending) |
expires_at | When the approval expires if no decision is made |
Prometheus Metrics
| Metric | Type | Description |
|---|---|---|
mvgc_approvals_pending | Gauge | Current count of open approvals awaiting a decision |
mvgc_requests_total{decision="require_approval"} | Counter | Total require_approval decisions by org |
Alert on mvgc_approvals_pending > N to detect a backlog of unreviewed requests.
Environment Variables
| Variable | Default | Description |
|---|---|---|
MVGC_APPROVAL_TTL | 24h | How long a pending approval waits before expiring |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Re-submitted request still returns 202 | Approval not yet recorded, or request body differs from original | Verify operator approved; confirm body is byte-identical |
| Approval expired before re-submission | TTL too short for your review process | Increase MVGC_APPROVAL_TTL |
| Cannot find approval in list | Wrong org_id filter or approval already expired | Omit status filter; search all statuses |
See Also
- Network Operations Guide — full policy DSL and decision reference
- Quarantine — automated blocking for high-risk requests
- Risk Scoring — risk signals that can trigger
require_approval - Telemetry —
mvgc_approvals_pendingmetric details - Glossary — approval_id