Skip to main content
What happens inside Policy Gateway on every governed request. Useful for debugging unexpected decisions, interpreting policy events, and reasoning about latency.

Request lifecycle

1. Request hits /policy/*
2. Auth resolves user_id (JWT or API key)
3. Project lookup (key → project, or X-Policy-Project header)
4. Policy lookup (project.policy_id → policy doc)
5. Pre-evaluation
      ├─ allowlist check
      ├─ denylist check
      ├─ category classification (chat/messages only)
      └─ PII redaction (if redact_pii=true)
6. Decision computed → {decision, effective_decision, enforced}
7. Rollout mode applied (shadow/canary/enforced/rollback)
8. If allowed: forward to model
9. Post-evaluation (output classifier runs on response)
10. Policy event emitted to Pub/Sub → connectors
11. Response returned (with policy metadata on /policy/* streams)
There is no global default policy. A project with no policy_id returns 400 Project is not linked to a policy.

Decision fields

FieldMeaning
decisionRaw rule outcome — one of allow, rewrite, summary, escalate, refuse
effective_decisionWhat was actually applied. In shadow/non-enforced canary, this equals allow even when decision != allow
enforcedBoolean — was the decision acted on (true) or only logged (false)
rollout_modeshadow, canary, enforced, or rollback
reason_codeUppercase code mirroring the decision: ALLOW, REWRITE, SUMMARY, ESCALATE, REFUSE. Policies can override with a custom reason_codes list.
triggered_categoriesModeration categories that matched
allowlist_hits / denylist_hitsWhich terms matched
policy_targetchat.completions, messages, responses, mcp_tool
policy_userSubject from X-Policy-User header (if sent)
quota_subjectEffective per-user subject (policy_user, or falls back to user_id)

Policy events

Three event classes are emitted to the Pub/Sub topic policy-events:

EnforcementEvent

Fires on every governed request.
{
  "event_id": "3d14a2b8-...",
  "event_type": "enforcement",
  "source": "policy_gateway",
  "created_at": "2026-04-20T18:30:00Z",
  "user_id": "user_...",
  "org_id": null,
  "policy_id": "support-bot",
  "policy_name": "Support bot policy",
  "data_classification": "internal",
  "history_id": null,
  "decision": "refuse",
  "effective_decision": "allow",
  "enforced": false,
  "rollout_mode": "shadow",
  "reason_code": "REFUSE",
  "triggered_categories": [],
  "allowlist_hits": [],
  "denylist_hits": ["competitor-x"],
  "policy_target": "chat.completions",
  "policy_user": "user_42",
  "quota_subject": "user_42",
  "project_id": "proj_support_bot",
  "project_label": "Support Bot",
  "model": "abliterated-model"
}

SimulationEvent

Fires when the console’s policy simulator is used (/api/policy-gateway/simulate). Adds:
scenario_categories: string[]   // categories forced into the simulated scenario

RevisionEvent

Fires on policy create/update/delete.
edit_type:        "create" | "update" | "delete"
config_snapshot:  <full policy config at time of change>

Delivery semantics

  • Events are emitted fire-and-forget from a background daemon thread.
  • If the Pub/Sub publish fails, the event is dropped (no on-disk retry queue).
  • Connectors downstream of Pub/Sub retry with exponential backoff and honor destination-specific batching.

Caveats

  1. Shadow mode still runs rules. Every allowlist / denylist / category check happens even in shadow — it just doesn’t block. This is what makes dry-run measurement possible.
  2. No per-rule rollout overrides. Rollout mode is a single knob at the policy level. Adding a rule doesn’t let you ramp that rule independently.
  3. Same policy applies to every target. A single policy is used for chat.completions, messages, responses, and mcp_tool. There’s no per-target rule tree.
  4. Allowlist exclusivity. A non-empty allowlist forces refuse on any message without an allowlist hit, regardless of denylist/category.
  5. Canary is probabilistic. Two identical requests at percentage=50 can have different outcomes.