Decision Types
Governed requests record a decision — the outcome of evaluating the request against your policies. Internal AIControls model calls have a separate evaluation status.
Decision types
| Decision | Meaning |
|---|---|
| Allow | Governance allowed the request to be forwarded; an upstream error can still prevent a usable response |
| Deny | AIControls blocks the request under a policy or budget rule. Depending on the denial path, you receive an error or a synthetic response, such as an inline exception offer. An outbound network call blocked by the network egress filtering proxy — either the always-on SSRF floor or an egress policy — also records a Deny, distinguished from MCP/LLM denials by a request type of egress in the audit API. A Cross-App Access (XAA) token issuance denied by policy at grant time similarly records a Deny, distinguished by a request type of xaa — this is a decision about minting a token, not about a tool call or LLM request made with one. A denial that offered the developer an inline exception records the same Deny, recording the policy's message on the event followed by a note that an exception-requestable block was shown, for example Model is not on the approved list (exception-requestable block shown to user) — offering an exception does not soften the decision, and nothing is allowed unless a grant later exists |
| Audit | Request was allowed but flagged — typically by an audit-mode rule or anomaly detection |
| Warn | Request was allowed with a warning appended to the response |
Ask (warn-ask) | Request was paused and the user was asked to confirm inline; on confirmation the request proceeds and the event is recorded (appears as warn_confirm in the audit API). Produced by enforcement-mode: Ask (validationActions: [Warn] + proxy.nirmata.io/require-confirmation: "true", or the annotation value Ask/warn-ask). The confirmation prompt carries a signed, single-use token bound to the policy, the identity it was issued to, and a short window: a "yes" is only honored against the prompt that produced it, and only once, so a confirmation cannot be replayed on a later turn, in another conversation, or against a different policy. A declined confirmation is the one governance event in this flow that is not authenticated: a "no" is taken at face value with no token check — declining can only ever block the request — so the resulting Deny (policy warn-ask, policy confirmation declined by user) records that the client reported a decline, not proof that a prompt was ever issued. A client can therefore write that event, and 403 its own request, at will; it cannot use it to allow anything. |
Approve (warn-approve) | The policy requires human approval. An in-flight hold is tracked in Security → Approvals; no final call event is written while it waits. Once a policy-level hold is released, an approved MCP, LLM, or outbound egress request records Allow (or Mutated if changed). A later-stage hold from a separate rate-limit or egress check does not replace an independent Audit or Allowed by exception policy decision. For MCP tool calls and LLM requests, a forwarding failure keeps Allow with an Upstream error annotation; this does not prove an upstream side effect. A reviewer rejection records Deny. The separate hitl_approved or hitl_denied event records the reviewer's decision. Pre-call authorization checks can still record require_approval in the audit API as a historical policy requirement; Activity labels those rows Approval required, not Pending. Token-count requests are blocked outright rather than held — see Human-in-the-loop. |
Hold (require-approval, Access Binding display) | An Access Binding requires a human decision before the call can continue. Access Binding forms and lists display the existing wire value as Hold. Guardrail policy authoring keeps the Approve, Ask, and Warn terms above; wire values do not change. |
Approval granted / Approval denied (hitl_approved / hitl_denied) | A reviewer decided a held request. This is a record of the decision, naming the approver — separate from, and additional to, the call's own disposition above, so a held-and-resolved call produces both. Deliberately not recorded as Allow/Deny: an approver working through the queue must not be counted in deny rates, the per-identity risk score, or the admin digest's most-denied-user ranking. A rejected self-approval attempt also records hitl_denied. |
Blocked (response) (response_blocked) | A policy or content scan blocked the reply — after the request itself was already allowed through to the tool or model. Distinct from Deny, which blocks before the call is ever made: here the call ran, and what came back is what got blocked. The event's request-phase fields (Policy/Rule/Message) still describe the request, which was Allow; the matched policy, matched rule, and reason for the block live in the event's response fields instead, alongside what the content scan found and a capped preview of the response body. |
Gateway configuration denials
Some Deny events come from the gateway's own configuration, not from a policy. Their policy name identifies the cause.
| Policy | HTTP status | Meaning |
|---|---|---|
no-route | 503 | No route serves the requested model, the selected route has no member for the caller's credential mode, or the caller's grant names a route the router doesn't have or one that doesn't serve the model. The client gets the code NO_ROUTE; the message names the model and the closest route. See Fail closed. |
no-grant | 400 | Access Bindings grant models, but none grants the requested model to the caller. The client gets the code MODEL_DENIED; the message names the model and the route that serves it. See Fail closed. |
no-upstream-key | 503 | Routing selected a provider with no API key and no organization-managed credential, and the caller sent no provider credential to forward. Fires for PAT, governed-session, and OIDC callers. The message names the provider, the route, the model, and the identity source; the event records the provider and route, and its identity source. Filtering Activity by no-upstream-key or pat-no-upstream-key returns events recorded under either name. See When the selected provider has no API key. |
model-provider-mismatch | 502 | Routing selected a single provider that can't serve the requested model, such as a Claude model on an OpenAI upstream, and the upstream has no Model Aliases entry for it. AIControls refuses the request instead of forwarding it; on /v1/messages the error carries the code MODEL_PROVIDER_MISMATCH. The message names the model, the upstream, and its provider. See When the provider can't serve the model. |
upstream-ambiguous | 503 | Two routes tied at the same model-pattern specificity, the model's family didn't break the tie, and AIControls refused to guess. See Implicit routes. |
passthrough-not-allowed | 403 | A PAT caller sent its own provider key, but passthrough is not enabled for that user or globally. |
Internal AIControls calls
In Security → Activity → Gateway, internal model calls from AIControls Judge and AIControls Analyst display Not evaluated. These calls support AIControls features and do not pass through request policy evaluation. The label is an evaluation status, not an Allow or Deny decision.
Open a call to see its provider outcome and feature outcome separately, along with available token usage and estimated cost. A provider success can still produce an invalid Judge response. Judge verdicts are advisory and do not enforce approval or denial. Aggregate usage and cost appear under Settings → AI Features; see Detection thresholds.
Observed tool calls
When an LLM response asks the client to run a tool, AIControls records that tool call in Security → Activity as its own row. Policy does not evaluate these tool calls as separate calls: the tool runs in the client. Response policies on the related LLM API call can check the tool calls in the response, but only where the route supports it. Streaming responses have limits; see Write a policy. Activity therefore labels the row with a TOOL type tag and an Observed decision, not MCP and Allow. In the call's detail, the type reads Observed Tool Call (or Observed Built-in Tool Call for a Claude Code built-in tool), the Policy Evaluation step reads "Not evaluated as its own call", the Decision step reads Observed, and the destination is Tool (run by the client). A tool call that passes through the MCP proxy is evaluated by policy and keeps the MCP tag and its real decision.
Upstream errors on an allowed call
An Allow decision means governance permitted the call — it does not mean the call actually succeeded. When an allowed MCP tool call or LLM request reaches its target and gets back a failure (a rejected tool result, an expired or invalid upstream API key, a rate limit, a provider outage), the event still records Allow — that was the real governance decision — but carries an Upstream error annotation with the failure detail. It's shown as a warning-colored badge in the Audit Log and Security Events views, separate from the Allow/Deny coloring, so a credential or availability problem downstream doesn't get mistaken for a policy denial (and vice versa).
When the request went through a model route with more than one member, each failed attempt records its own Allow + Upstream error event naming which route and which attempt (e.g. "attempt 1 of 3") failed, and the eventual outcome — success or final exhaustion — carries the same route name and attempt number. A request that fails over is therefore visible as a short run of events for one call, not a single ambiguous row.
The proxy.nirmata.io/enforcement-mode annotation is case-insensitive and accepts either the short user-facing names (Deny, Approve, Ask, Warn, Audit) or their longer-form values (deny, warn-approve, warn-ask, warn, audit).
Policies written with the legacy annotation values enforcement-mode: require-approval or enforcement-mode: warn-confirm are still accepted and behave identically to Approve (warn-approve) and Ask (warn-ask) respectively — no action needed for existing policies.
Evaluation status
Evaluation status is separate from the decision. It explains how AIControls reached an Allow, Deny, Warn, Ask, or Approve result.
| Status | Meaning |
|---|---|
| Empty | At least one access policy or guardrail matched and evaluation completed normally |
unmatched | No enforcing access policy contributed to accessMatched, and a compiler-generated per-plane default policy matched. Ordinary guardrail matches do not relabel unknown access as known. |
skipped | No access policy, guardrail, or per-plane default policy matched; compatibility behavior supplied the result |
error | Policy evaluation failed and the configured fail-open or fail-closed behavior supplied the effective decision |
An unmatched status does not imply Allow. The configured default policy can allow, hold for approval, or deny the request. When a registration in Audit mode has the audit unmatched override on, a Hold or Deny from the default policy becomes Allow, the status stays unmatched, and the would-be decision is recorded as shadowed.
The status appears only when a trusted per-plane default policy is installed. Without one, an unmatched request retains compatibility behavior and reports skipped.
Shadowed workload decisions
A registration in Audit mode records the decisions of its own deny list without enforcing them. The call's decision reflects only the policies that enforce, so a shadowed deny can appear on an Allow event. The would-be decisions are in the event's workloadAccess.shadowed list, each with its policy, mode, and message.
| Shadowed policy | Recorded when |
|---|---|
workloadaccess:<accessPolicy>:deny:mcp or :deny:llm | The workload's own deny list matched the call in Audit mode |
accessdefault:<plane> (with unmatched: true) | The audit unmatched override replaced a Hold or Deny from the default access policy |
accessdefault:<plane>:judge-deny (with unmatched: true) | The override replaced a Judge-driven Hold or Deny |
Guardrails, binding gates, tool defaults, Access Binding decision modes, ceilings, containment, and fail-closed denies are never shadowed.
Exception lifecycle
A second set of values tracks the exception workflow rather than the outcome of a call. They are written alongside call events, so a blocked-then-granted action leaves a trail across both tables.
| Decision | Recorded when |
|---|---|
exception_requested | A developer asked for an exception — from the Builder Portal, the API, or inline in the tool that was blocked (a reply to a denial, an aigov__request_exception tool call, or /request-exception). The event names the requester and the policy or model asked for. Filing a request changes nothing about the call that triggered it, which keeps its own Deny |
exception_granted | A request was approved and the grant became live |
exception_partial_approval | A multi-approver request escalated to co-sign — the grant is active while the remaining approvers review it |
exception_denied | A request was denied, by a single approver or any co-signer |
exception_revoked | An admin ended a still-active grant early |
exception_expired | A grant's expiry time passed |
allowed_by_exception | A call that a policy would otherwise have blocked was allowed because an active grant covered it. This is the payoff event — it is what a granted exception actually looks like in the audit log |
Quiet confidence
The Security Posture dashboard and the weekly admin digest surface Quiet confidence — the percentage of call dispositions resolved with zero human involvement.
Decisions are split into three sets:
| Set | Decisions |
|---|---|
| Quiet (no human involved) | Allow, Mutated, Cache Hit, Audit, Allowed by Exception |
| Human-involved | Deny, Blocked (response) (response_blocked), Warn Approve (require_approval), Warn Ask (warn_confirm). Egress denials are included via Deny — they record a Deny with request type egress, not a separate decision |
| Excluded from both | Workflow rows — the exception lifecycle (exception_requested, exception_granted, exception_partial_approval, exception_denied, exception_revoked, exception_expired) and approval decisions (hitl_approved, hitl_denied) |
Quiet confidence is quiet ÷ (quiet + human-involved), computed over the period window selected on the Security Posture dashboard. Exception-lifecycle rows are workflow bookkeeping, not call dispositions, so they never appear in the denominator — this makes the metric deliberately narrower than total call volume.
Response scan skipped reasons
An audit event for an LLM call can carry a responseScanSkippedReason field (visible via the audit API and event detail) even when the request itself was Allow. It means response-phase content scanning and policy evaluation on the reply — the DLP scanner, Content Safety, and response-phase CEL policies — didn't get to look at all of the response text, for one of the reasons below. This is distinct from Blocked (response): nothing was denied, some or all of the reply text simply wasn't scanned.
| Value | Meaning |
|---|---|
too_large | The response exceeded the buffered-scan size cap (512 KB by default) while streaming. Everything up to the cap was still scanned per-chunk as it streamed to the client; only the assembled, cross-chunk buffered-final pass — and, if configured, the async Content Safety scan and the audit response snippet — stop covering text past the cap. The reply itself is still relayed to the client in full |
unrecognized_response_shape | The response body didn't match a shape the proxy knows how to parse for response-phase scanning — for example, a Bedrock model family other than Claude (Amazon Nova/Titan, Meta Llama, Mistral, Cohere) returning its own native response shape on the cloud proxy (/bedrock/*, /vertex/*) paths. No text was scanned at all for that response |
Both values mean "less coverage than a normal Allow," not "blocked" — the call succeeded and the reply reached the caller either way.
Request size limits
Every request to the MCP/LLM proxy or the admin API is capped by a default maximum body size — a request over the limit is rejected with 413 Request Entity Too Large before governance ever evaluates it, so it does not appear in the audit log at all.
| Surface | Default limit |
|---|---|
MCP tool calls and LLM requests (/mcp, /v1/messages, /v1/messages/count_tokens, /v1/chat/completions, /v1/responses, and the cloud-proxy routes) | 32 MiB |
| Admin/management API | 16 MiB |
The rejection names the limit, for example request body too large: exceeds 32 MiB limit. On the LLM conversation endpoints it also says start a new session or compact the conversation, because agent clients such as Claude Code and Codex CLI resend the whole conversation, including any attached screenshots, on every turn. A session that grows past the cap fails every request, including the client's own compact command, until you start a new session.
These are fixed defaults, not currently configurable. A handful of routes enforce their own smaller, purpose-specific cap on top of this default (for example skill ingest) — the tighter of the two always applies. If a client integration needs a larger request than these defaults allow, contact support.
Synthetic policy names
Every audit event has a Policy field naming the rule that produced the decision. Most values are the name of a CEL policy you (or the built-in library) authored. A few reserved names come from proxy-level checks that aren't backed by an editable policy:
| Policy name | Decision | Fires when |
|---|---|---|
unverified-identity | Deny | identity.requireVerifiedIdentity is enabled and the request's identity was not cryptographically or credential-verified |
mcp-ssrf-guard | Deny | The always-on SSRF floor blocked an MCP tool call whose URL argument resolved to a private, loopback, or internal address |
egress-warn-fallback | Deny | networkFilter.warnFallback: deny is set and an outbound HTTP(S) call matched no egress policy that gave a definitive allow or deny |
upstream-format-mismatch | Deny | The resolved upstream speaks a different wire format than the request, and AIControls can't translate this request. For an Anthropic Messages client, this covers any request to a Gemini-family or ChatGPT OAuth upstream, and requests with content that has no OpenAI Chat Completions equivalent, such as extended thinking, images, documents, or server tools. See Cross-format routing |
subscription-concurrency-cap | Deny | An agent tried to establish an MCP resource subscription (resources/subscribe, or one entry in a subscriptions/listen call) while already holding the maximum of 10 concurrent subscriptions |
subscription_rate_exceeded | Deny | A single MCP resource subscription pushed more than 50 update events per second; the individual event over the limit is dropped (the subscription itself stays open) |
workloadaccess:contain:mcp, workloadaccess:contain:llm | Deny | An administrator contained the calling workload's identity. Containment applies in both access modes and is never shadowed |
workload-registration-binding-incoherent | Deny | The caller's configured Workload Registration access is missing, stale, or ambiguous, so AIControls fails closed instead of using the default access policy |
Access Binding decisionMode values map onto these same decisions — Allow, Audit, and Warn behave exactly as above; RequireApproval produces Held; Deny blocks the request (invalid_grant at token issuance, or denied at the tool call).