Decision Mode

Policy decision for an infrastructure gateway (PEP)

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.

post/api/v1/decide

Request

Headers

X-Axonflow-Approval-Idstring uuid

The approval a retry spends (#4370). A call held for a person's approval is answered a pending_approval naming an id; once a person approves it in the portal, the caller retries the SAME call naming that id here (or in the body's approval_id field, for a client that cannot set headers - a header and a field naming different ids are refused approval_not_found).

The retry is decided exactly as the first call was. Only when it is again held for approval, and the approval is approved by a person who is not the caller, before its expiry, unspent, and granted for this very call (the same input, tool, route, requester and requirement), does it pass - and the approval is spent: it admits exactly one call. A call that is allowed anyway spends nothing; no approval lifts a deny. Every other outcome is refused with one of the ApprovalHoldReason codes. Enterprise; the Community build has no approval queue and ignores the header.

WHAT "THE SAME CALL" MEANS. On the MCP routes: the connector, the tool, the operation, the statement and its parameters (and the row limit on /mcp/resources/query). On /api/v1/decide: the WHOLE request the engine decided - stage, target, query AND context. A retry that changes any of it is refused bound_input_changed: resend the same context, and carry per-request values (request ids, timestamps) in headers, never in context - the correlation id is a header.

X-Axonflow-Clientstring
Example:mcp-proxy/0.3.1

Optional client-version telemetry header (<client>/<version>, e.g. mcp-proxy/0.3.1 or claude-code/1.9.1). Enterprise deployments with the client_version_telemetry capability count validated values in the axonflow_client_version_requests_total metric on the decide and MCP check-output planes. Telemetry only — never used for authentication or authorization; invalid values are ignored.

X-Axonflow-PEP-Handshakestring

base64url (padding optional) of the PEPHandshake document.

The ADR-065 PEP capability handshake: base64url of a compact JSON document in which an external enforcement point declares what it is and which obligations it can discharge. See PEPHandshake for the document.

Absent is the default and changes nothing. A caller that omits the header takes byte-for-byte the path it took before this header existed.

What an absent header means for a redaction depends on the plane, by design (PRD v11 section 1 item 16, #4257). The MCP passes discharge a redaction of the content they hand back: the request pass (check-input, check_policy) masks the statement, and a redaction that masks nothing in the statement is refused unsupported_obligation to every caller, and one that masks a request parameter to every caller that has not declared field_redact at version 2 (on Community, to every caller) (#4264); the response passes (check-output, the MCP server's check_output) mask the rows or the message. /api/v1/decide and the gateway pre-check return a decision rather than content, so a required redaction is a field_redact obligation for the enforcement point, and a caller that has not declared field_redact is refused unsupported_obligation. A caller that declares NO redaction (capabilities: []) is refused on each of these planes; on Community the MCP passes still return a checksum validator's masked content to it (reachable only through a directly inserted detection_action_overrides row) until #4122.

A header that is PRESENT and cannot be read is refused, never treated as absent: degrading a malformed declaration to "legacy caller" would go on handing an enforcement point obligations it had just said it cannot discharge. The refusal is 400 and its message names this header, which matters on /api/v1/access/evaluation where the refusal is rendered through that surface's existing incomplete_evaluation code and the message is the only thing distinguishing a malformed HEADER from a malformed body ENVELOPE.

Present more than once is refused: RFC 7230 permits an intermediary to join repeated field lines with a comma, and a comma is outside the base64 alphabet, so a joined pair can only decode to malformed. That is why the document is base64 rather than raw JSON, which would join into something a lenient parser might accept.

When a decision carries a mandatory obligation the declared set does not cover, the request is answered 200 with verdict: deny and the reason unsupported_obligation (ADR-065 invariant 8) - a decision about the request, not a transport error. That holds on every edition. An Enterprise deployment adds a second reason beginning pep_capability_unsupported naming the gap, and refuses a checksum validator's mask on the MCP passes; a Community deployment hands that masked content over (#4257 split 2, #4122).

traceparentstring
Example:00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01

W3C trace-context header. When present and valid, the trace-id is reused in the response so multi-layer decisions correlate into one end-to-end trace.

Request body

approval_idstring 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_bundlestring

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).

querystring required

The request body / prompt / statement being decided on.

user_tokenstring

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.

contextobject

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_capabilitiesstring[]

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.

Example request

{
  "target": {
    "model": "gpt-4o",
    "provider": "openai",
    "server": "postgres",
    "tool": "query"
  }
}

Response

Decision verdict (allow / deny)

approval_idstring 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_idstring uuid required

Fresh UUID per decision. Stable handle for audit-log correlation, follow-up explain calls, and PEP-side logging.

trace_idstring 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.

reasonsstring[]

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.

evaluated_policiesstring[] 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_atstring 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_typestring

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_bundlestring

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_packsstring[]

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.

document_versioninteger

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.

Example response

{
  "obligations": [
    {
      "type": "redact_pii",
      "fulfillment": {
        "endpoint": "/api/v1/mcp/check-input",
        "method": "POST",
        "content_types": [
          "text/plain"
        ]
      }
    }
  ]
}

Changes

No recorded changes to this operation across all 1 revision of this API.