---
title: "Policy decision for an infrastructure gateway (PEP)"
method: POST
path: "/api/v1/decide"
tags: ["Decision Mode"]
---

# Policy decision for an infrastructure gateway (PEP)

`POST /api/v1/decide`

## Request

- Base URL: `https://agent.getaxonflow.com`
- URL: `https://agent.getaxonflow.com/api/v1/decide`
- Auth: none declared
- TypeScript: <https://skmtc.dev/getaxonflow/apis/axonflow-agent-api/docs/api/v1/decide/post.ts> (a function with typed inputs; its response is checked against the schema)

Decision Mode endpoint (ADR-056 / epic #2426). The customer's
infrastructure gateway (Policy Enforcement Point) calls this
endpoint per request to get a verdict (`allow` or `deny`) and
enforces the result. AxonFlow is consulted,
never on the traffic path.

The shared-policy engine behind this endpoint is the same engine
that backs Gateway Mode's `POST /api/policy/pre-check`. The
difference is the caller: Gateway Mode is called by application
code; Decision Mode is called by an infrastructure gateway.

**M1 scope: static policies only** (PII detection, SQL injection,
dangerous patterns, RBI India PII, compliance categories) to keep
the inline RPC budget in single-digit milliseconds. Dynamic/custom
policy support is M2 scope per the epic.

**OTel trace correlation:** the response carries a W3C-compatible
32-hex `trace_id`. When the caller passes a `traceparent` header,
its trace-id is reused so multi-gateway-layer decisions stitch
into one end-to-end trace. Each decision also emits an OpenTelemetry
span on the `axonflow.agent.decision` tracer.

**Available at all tiers** (Community through Enterprise) — the
policy engine is the same one Gateway Mode uses.

## Headers

- `X-Axonflow-Approval-Id` string, uuid
- `X-Axonflow-Client` string
- `X-Axonflow-PEP-Handshake` string: base64url (padding optional) of the PEPHandshake document.
- `traceparent` string

## Request body

- DecideRequest
  - `approval_id` string, uuid: The approval a retry spends (#4370), for a client that cannot set the `X-Axonflow-Approval-Id` header (see that parameter). Never part of what the approval binds.
  - `expected_policy_bundle` string: Optional pin for the exact composed policy bundle the caller reviewed. If the active bundle differs, the Agent returns a deny with reason `policy_bundle_mismatch` before queueing or spending an approval. The pin is part of the approval binding, so an approved retry must send the same value. Use the `policy_bundle` returned by `/api/v1/decide`, rather than a typed policy artifact digest.
  - `stage` 'llm' | 'tool' | 'agent', required: Which gateway layer is calling. Maps to ADR-056's three-layer reference architecture (agent / MCP / LLM).
  - `caller_identity` DecisionCallerIdentity: Gateway-asserted caller identity. `org_id` and `tenant_id` are OPTIONAL in the body -- the auth-derived identity from `apiAuthMiddleware` is authoritative. In non-community mode, body-supplied values MUST match the authenticated identity or the request is rejected with HTTP 403.
    - `gateway_id` string: Identifier of the calling gateway (PEP), for audit trail.
    - `org_id` string: Org scope for the decision. In non-community mode, must match the authenticated identity if supplied.
    - `tenant_id` string: Tenant scope for the decision. In non-community mode, must match the authenticated identity if supplied.
  - `target` DecisionTarget: What the gateway is about to call.
    - `type` string: What kind of thing is being called: `llm`, `tool`, `agent`, or `http` (an LLM-shaped target under the transport name the ext_proc and ext_authz seams send). `tool` IS LOAD-BEARING, not descriptive. It is the only value for which the platform records `server` and `tool` as the decision's tool attribution -- onto the audit row (`policy_details.tool_server` / `.tool_name`) and into the descriptor a human approver sees on a HITL queue entry. A tool call sent under any other value is decided and enforced exactly the same way, and its audit row carries neither field: a complete-looking record that does not say which tool it was about. Matching is case-insensitive, so `TOOL` and `Tool` are the same value; there is no second accepted WORD and no alias (#3717). Capability-scoped policy evaluation is a separate question and is NOT offered for every tool target. A target that also names a `server` describes a call the caller routes to a backend it does not itself execute, so its `tool` is not used to relax evaluation -- those requests always get full evaluation. SO SETTING `server` IS A TRADE, AND IT IS THE SAFE DIRECTION OF ONE: it is what puts `tool_server` on the audit row, and it also opts the request out of any evaluation relaxation. It cannot weaken enforcement. What it costs is false positives on prose that looks like a statement, and the ADR-065 shadow comparison's tool label, which follows the scoping key and is empty for these requests. Audit attribution, the HITL descriptor and the FinCrime scoring context all still carry the tool name.
    - `model` string: Model identifier when type is llm.
    - `provider` string: Provider identifier when type is llm.
    - `server` string: Server/connector identifier when type is tool (#2904).
    - `tool` string: Tool identifier when type is tool.
  - `query` string, required: The request body / prompt / statement being decided on.
  - `user_token` string: Optional end-user JWT for audit identity. PEP gateways are typically services and may omit this field -- in enterprise mode the platform synthesizes a service identity for the audit row when no token is supplied. Supplying a token gets the validated-user record on the audit row instead.
  - `context` object: Part of what an approval binds (#4370): a retry naming an approval must send the same `context`, or it is refused `bound_input_changed` - keep per-request values (request ids, timestamps) in headers. Optional caller-supplied context (string values) that AxonFlow propagates end-to-end into the decision audit record + the OTel decision span, so a SIEM can correlate the decision with upstream logs (e.g. by session_id). Intended for infrastructure-gateway audit headers such as `X-AI-Agent`, `X-Session-ID`, `X-Leader-Identity`, and a tenant-scoped header family. Only keys matching the server's allowlist (`AXONFLOW_DECISION_CONTEXT_ALLOWLIST`; the default covers common agent / session / leader identity headers plus a tenant-scoped header family, where a trailing `*` is a prefix match) are persisted; all other keys are silently dropped. Surviving keys are canonicalized to lower_snake_case (`X-AI-Agent` → `x_ai_agent`) so joins are deterministic regardless of header casing. Non-string values are dropped; values are capped at 256 bytes and the map at 10 keys (surplus dropped, flagged `context_truncated`). The persisted map is returned (full) by `GET /api/v1/decisions/{id}/explain` and (truncated to 5 keys) by `GET /api/v1/decisions`.
  - `fulfillment_capabilities` string[]: What this PEP's **seam** can mechanically do to a request before forwarding it. A different axis from the capability handshake, which declares which OBLIGATIONS the enforcement point can discharge: `request_header_mutation` has no obligation type at all, and `immutable_audit` has no seam mechanic, so neither list is derivable from the other. Three wire states, three meanings: * **member omitted** - a legacy (pre-9.11.0) caller. Obligations are emitted exactly as before. * **`[]`** - still a **legacy caller**, deliberately. These bytes have always been acceptable to the server and any non-Go client could send them, so giving them a new meaning would move an unchanged caller from "obligation emitted, the PEP fails closed" to "obligation suppressed, organization fallback posture" - default `log`, i.e. allowed without the redaction. Since v10.4.0 the state is representable in the Go client and distinguishable in the type; its reading is unchanged. * **non-empty** - only the obligations these capabilities can discharge are emitted; the organization's obligation-fallback posture decides what happens to any the platform suppresses. Unknown values are ignored, never an error and never a block, so an older platform meeting a newer PEP's vocabulary degrades instead of failing.

## Response `200`

Decision verdict (allow / deny)

- DecideResponse
  - `pending_approval` PendingApproval: A call held for a person's approval (#4370, PRD v11 §1.13). Pending is NOT allow: nothing ran, and the enforcement point must not forward. An approver approves the queue entry in the portal (Approvals), and the caller retries the same call naming `approval_id` (see the `X-Axonflow-Approval-Id` parameter). The approval expires at `expires_at`: the approval requirement's own deadline, which the engine stamps 15 minutes after the decision on v11. It is never extended; a retry after it is refused `approval_expired`.
    - `approval_id` string, uuid, required: The queue entry's id; the retry names it.
    - `status` 'pending' | 'approved', required: `pending`: nobody has decided it yet. `approved`: a person approved it and it is waiting for this caller's retry, which must name the id.
    - `plane` 'mcp:request' | 'decide', required
    - `expires_at` string, date-time: When the approval lapses. Omitted on a retry of a still-pending approval.
    - `retry` object, required
      - `header` 'X-Axonflow-Approval-Id', required
      - `argument` 'approval_id': The MCP tool argument / MCP route body field that carries the id.
      - `body_field` 'approval_id': The decide request field that carries the id.
  - `approval_id` string, uuid: On an allow, the approval that admitted this call (#4370).
  - `verdict` 'allow' | 'deny' | 'needs_approval', required: The PEP MUST enforce this verdict. `allow` = forward; `deny` = block; `needs_approval` = block, and the call is held for a person's approval: `pending_approval` names it and the retry spends it (#4370, PRD v11 §1.13). A PEP forwards only on `allow`. On an Enterprise deployment with the approval queue wired, an anchored CHALLENGE answers `needs_approval`; on the Community build, or when no approval could be queued, it is a `deny` whose first reason is `approval_required`.
  - `decision_id` string, uuid, required: Fresh UUID per decision. Stable handle for audit-log correlation, follow-up explain calls, and PEP-side logging.
  - `trace_id` string, required: W3C trace-context trace-id (32 lowercase hex). When the request carried a `traceparent` header, the trace-id is reused so multi-gateway-layer decisions stitch into one end-to-end trace. Otherwise a fresh trace-id is minted.
  - `stage` 'llm' | 'tool' | 'agent': Echo of the request stage, for audit dashboards.
  - `reasons` string[]: Human-readable reason strings backing the verdict. Empty on verdict=allow with no obligations. A deny with reason `unknown_constraint` keeps that code as its first entry and adds one entry per constraint that could not be evaluated, the binding one first: `<policy id> (<source>[, document version N]) could not be evaluated: <why>`, where the reason names the attributes it could not establish. The version is `version N` for an installed pack's policy, and a shipped control carries none. An id the activation did not activate carries no parentheses, and a constraint whose unknown attribute was not recorded says `an attribute it reads` in place of the attribute.
  - `obligations` DecisionObligation[], required: PEP-side requirements that accompany an `allow` verdict (e.g. redact PII before forwarding). Always a non-nil array so PEP code can iterate without a nil-check.
    - `type` string, required: Obligation kind. Currently emitted: `redact_pii`. Future obligations will be added here as needed by PEP adapters.
    - `detail` string: Human-readable detail for audit logs.
    - `fulfillment` ObligationFulfillment: Names the engine call a PEP makes to discharge an obligation (since 8.6.0). Fulfillment is a property of the contract, not of PEP-author discipline: a conforming PEP POSTs the obligation's source content to `endpoint` and forwards the engine-redacted content the endpoint returns. There is no other blessed way to satisfy a `redact_pii` obligation. A PEP holding content of a type NOT in `content_types` (e.g. an image awaiting OCR-PII redaction) MUST fail closed rather than forward it unredacted.
      - `endpoint` string, required: Engine path the PEP POSTs to in order to discharge the obligation. For a request-phase `redact_pii` obligation this is `/api/v1/mcp/check-input`; the response-phase counterpart is `/api/v1/mcp/check-output`. `/decide` runs pre-call, so it only ever emits request-phase obligations.
      - `method` string, required: HTTP method to use against `endpoint`.
      - `phase` 'request' | 'response', required: Which content the PEP submits. `request` = the PEP redacts the request it is about to forward (the `query` it asked `/decide` about); `response` = the PEP redacts a backend response before returning it. `/decide` emits only `request` obligations; the `response` value is part of the contract for PEP helpers that fan out to both phases.
      - `content_types` string[]: The mime-types `endpoint`'s redaction detectors can handle today (e.g. `text/plain`). Deliberately content-type-agnostic: adding a modality is a server-side detector registration plus a new entry here, not a redesign of this shape.
  - `evaluated_policies` string[], required: Policy IDs that MATCHED during evaluation (not the total number of policies considered). Empty when no policy matched, except on an indeterminate deny (below). On `deny`, the first entry is the blocking policy; the rest (if any) are non-blocking matches recorded for audit. On an indeterminate deny (reason `unknown_constraint`), the constraints that could not be evaluated are the policies that decided it: they come first, the binding one first, then what matched. On `allow` with obligations, the entries are the policies that produced the obligation. The full evaluation count will be surfaced separately when the explain endpoint (`/api/v1/decisions/{id}/explain`) lands.
  - `expires_at` string, date-time, required: When the decision expires. PEPs that cache decisions MUST re-call by this timestamp.
  - `engine` 'anchored': Which policy engine authored this verdict: the ADR-065 decision plane, the only author on this route (PRD v11 §1.1). Omitted on a refusal no engine decided - an authentication failure, or a request refused before the policy pass ran.
  - `subject_type` string: The type of principal the verdict was decided for (PRD v11 §1.6): `User` for a verified user token, `Client` when the request presented no user identity and its client credential is the principal. Omitted wherever `engine` is.
  - `policy_bundle` string: The digest of the policy set that decided: the system corpus's restriction for this route and the organization root - the organization's active typed document composed with the deployment's baseline permission pack, or, while it has published nothing, the implicit bundle of that pack and the organization template. A rollback reinstates an earlier digest. Omitted wherever `engine` is.
  - `policy_packs` string[]: The add-on policy packs (PRD v11 §1.9) whose controls composed into `policy_bundle` on this route, each as `<pack id>@<digest of the pack document the deployment instantiated>`, sorted. Omitted when the deployment installs no pack or none binds on this route.
  - `policy_identities` PolicyIdentity[]: Each entry of `evaluated_policies`, in the same order, named (PRD v11 §1.14): the policy's own display name where it has one, whose it is, and for an organization's own policy or an installed pack's the version it was published at. A shipped control carries no version: `policy_bundle` identifies it. Additive: `evaluated_policies` stays a list of ids. Omitted when `evaluated_policies` is empty.
    - `id` string, required: The policy id, as `evaluated_policies` carries it.
    - `name` string: The policy's own display name. Omitted when it declares none.
    - `source` 'shipped' | 'organization' | 'pack': Whose the policy is: a control the release ships (the system corpus, the organization template, the deployment's baseline permission pack, or a recorded override's replacement of a shipped control), the organization's own published document, or an installed policy pack. Omitted for an id the engine did not activate - a checksum validator's (`legacy_validators`).
    - `version` integer: The version an organization's own policy (its document's) or a pack's control (the pack's) was published at. Omitted for a shipped control.
  - `document_version` integer: The published version of the organization's active typed document (PRD v11 §1.14). Omitted while the organization has published nothing: `policy_bundle` names that implicit bundle by digest.
  - `legacy_validators` object[]: A checksum validator that acted BEFORE the anchored engine decided (#4122): under an organization's recorded `pii=block` or `pii=redact` detection override, the Indonesia or India validator blocked the request or masked the response ahead of the decision plane. Omitted when none did, which is every request without such an override.
    - `validator` 'indonesia_pii' | 'india_pii', required
    - `action` 'blocked' | 'masked', required

## Other responses

- `400`: Request malformed (invalid JSON or missing required fields).
- `401`: Authentication failed. THREE distinct causes, and the audit row distinguishes them even though the status code does not. 1. **Credentials.** In non-community mode the `Authorization` header is required and credentials must resolve to a valid client. 2. **`user_token_rejected`** -- a `user_token` WAS supplied in the request body and failed to validate (malformed, expired, wrong algorithm, bad signature, or revoked by `jti`). The attempt is audited as a blocked decision rather than being refused invisibly. Independent of any org posture. 3. **`user_token_required`** (#3476) -- NO `user_token` was supplied and the caller's organisation requires one. Off by default: this cause cannot occur unless an operator has set `organizations.require_user_token` for that organisation, or the deployment-wide `AXONFLOW_REQUIRE_USER_TOKEN` default. With the posture off, a token-less enterprise caller is served under a synthetic service identity exactly as before, which is the correct answer for an infrastructure gateway acting as a PEP with no end-user token to forward. Causes 2 and 3 are recorded as reserved identifiers in the audit row's policy ids and MUST NOT be collapsed into one: they have opposite operator remedies (repair the caller's token, versus provision one at all). Query them with JSONB containment against `policy_details->'policy_ids'`, never with `LIKE` -- `_` is a single-character wildcard, so a `%user_token_required%` pattern also matches unrelated prose in the same row. A per-user token is read from the `user_token` field of the request body on this endpoint. It is not read from the `X-User-Token` header here; that header belongs to a different envelope.
- `403`: **Tenant assertion mismatch** -- `caller_identity.tenant_id` or `caller_identity.org_id` in the body did not match the authenticated identity (non-community mode only). **Token tenant mismatch** -- the tenant of the resolved per-user token differs from the client's tenant. The canonical row records it with `security_event` `tenant_mismatch`. **`segment_resolution_failed` is no longer a cause** (it was one from #3456). No segment gate stands here any more. It resolved the caller's governance segments and refused the request when that failed, on behalf of an organization's segment-scoped static rows, and those rows no longer decide: the anchored engine authors this verdict and reads no segments (PRD v11 §1.1, §1.2).
- `429`: Community SaaS tenants past the daily request cap. Only emitted on the SaaS surface; self-hosted Community and Enterprise deployments do not return 429. The 429 is written by the auth middleware **before** the decide handler runs, so the body is the shared rate-limit envelope — NOT the DecideErrorResponse shape.
- `503`: Circuit breaker is open. The body carries `verdict: "deny"` as the fail-closed default; a fail-open PEP adapter should treat 5xx as "PDP degraded" and apply its configured posture per ADR-056 §Failure posture. A `Retry-After` header is included when the breaker has a scheduled expiration.

---

[API](https://skmtc.dev/getaxonflow/apis/axonflow-agent-api.md) - [All operations](https://skmtc.dev/getaxonflow/apis/axonflow-agent-api/llms.txt) - [OpenAPI document](https://skmtc.dev/getaxonflow/apis/axonflow-agent-api/revisions/34d30fcb7fa1?raw)
