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

Use require_approval when a request class needs human review before execution, not just policy enforcement. Common patterns:

PatternPolicy 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 requestidentity layer — workload not in known allowlist
Requests with elevated risk scorerisk layer — context.risk.score above threshold
Sensitive action types requiring sign-offtargets layer — action type matches restricted list

For automated blocking without human review, use deny or quarantine instead.


Approval Lifecycle

policy returns require_approval, HTTP 202 to caller

POST /approve (operator action)

POST /deny (operator action)

MVGC_APPROVAL_TTL elapsed, no operator action

caller re-submits, gateway finds pre-approval

caller re-submits, gateway returns HTTP 403

caller re-submits, gateway returns HTTP 403

Pending

Approved

Denied

Expired

Executed

Rejected

Key behaviors:

  • The original request is not executed when require_approval fires; 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

MethodPathDescription
GET/v1/admin/approvalsList approvals. Query params: org_id, workload_id, status, limit
POST/v1/admin/approvals/{id}/approveApprove a pending request. Body: {"decided_by": "string"}
POST/v1/admin/approvals/{id}/denyDeny a pending request. Body: {"decided_by": "string"}

All admin endpoints require the MVGC-Admin-Token header.


Approval Fields

FieldDescription
approval_idUUIDv7 — unique approval request identifier
org_idOrganisation that owns the request
workload_idWorkload that triggered require_approval
statuspending, approved, denied, or expired
reasonPolicy reason code from the DSL rule's effect.reason
decided_byOperator identity supplied when approving or denying
created_atWhen the approval request was created
decided_atWhen the operator made a decision (null while pending)
expires_atWhen the approval expires if no decision is made

Prometheus Metrics

MetricTypeDescription
mvgc_approvals_pendingGaugeCurrent count of open approvals awaiting a decision
mvgc_requests_total{decision="require_approval"}CounterTotal require_approval decisions by org

Alert on mvgc_approvals_pending > N to detect a backlog of unreviewed requests.


Environment Variables

VariableDefaultDescription
MVGC_APPROVAL_TTL24hHow long a pending approval waits before expiring

Troubleshooting

SymptomLikely causeFix
Re-submitted request still returns 202Approval not yet recorded, or request body differs from originalVerify operator approved; confirm body is byte-identical
Approval expired before re-submissionTTL too short for your review processIncrease MVGC_APPROVAL_TTL
Cannot find approval in listWrong org_id filter or approval already expiredOmit status filter; search all statuses

See Also