Building a Policy Enforcement Point
Decision Mode splits enforcement into a Policy Decision Point (AxonFlow — it decides) and a Policy Enforcement Point (your gateway, proxy, sidecar, or SDK middleware — it enforces). AxonFlow ships three reference PEP adapters and a blessed platform/shared/pep client, but the contract is open: any layer that can make an HTTP call can be a PEP.
This page is the contract for building your own. Get the loop and the fail-closed rules right and your PEP is correct by construction.
The engine-fulfillable redaction obligations and the redaction_evaluated fail-closed signal described here are available on platform v8.6.0+. The decision endpoint itself (POST /api/v1/decide) is available from v8.3.0.
The one rule that makes a PEP safe
Your PEP calls AxonFlow's endpoints. It never reimplements detection, and it never carries a local redactor.
PII detection and redaction run server-side, against the authoritative engine and your active policies. A hand-rolled client-side redactor is a different, unverified path — it drifts from the engine, misses categories the engine catches, and silently becomes a governance gap. The only redaction path a correct PEP has is the engine round-trip. If the engine round-trip can't be completed, the PEP fails closed — it does not fall back to forwarding raw content.
The decide → fulfill → forward loop
A governed exchange is at most two touches of AxonFlow per leg (request, response):
(1) decide (2) fulfill
Client ──▶ PEP ──▶ POST /api/v1/decide ──▶ AxonFlow ──▶ verdict + obligations[]
│
├─ deny / needs_approval ──────────────▶ block (do not forward)
│
└─ allow + redact_pii obligation
│
├─ POST <obligation.fulfillment.endpoint> (content) ──▶ AxonFlow
│ ──▶ engine-redacted content
│
└─ forward ONLY the engine-returned redacted content ──▶ Backend / Client
Step 1 — Decide
Call POST /api/v1/decide with the request you are about to make. You get back a verdict and an obligations[] array:
curl -s -X POST http://localhost:8080/api/v1/decide \
-H "Content-Type: application/json" \
-d '{
"stage": "llm",
"caller_identity": { "gateway_id": "llm-gateway-01", "tenant_id": "acme-prod" },
"target": { "type": "llm", "model": "gpt-4o", "provider": "openai" },
"query": "Summarize this ticket from Budi, NIK 3174011503820001"
}' | jq .
Branch on verdict first (see fail-closed rules):
allow→ continue to step 2.deny→ block. Do not forward.needs_approval→ block / route to your HITL flow. Do not forward.- anything else, or a missing/empty verdict → block. Never default to allow.
Step 2 — Read the obligation
On allow, the engine attaches an obligation when a policy requires redaction. /decide is decision-only — it never returns redacted content. Instead, each redact_pii obligation is self-describing: its fulfillment block tells you exactly which engine endpoint to call.
{
"verdict": "allow",
"decision_id": "f81d4fae-7dec-11d0-a765-00a0c91e6bf6",
"trace_id": "0af7651916cd43dd8448eb211c80319c",
"stage": "llm",
"reasons": [],
"obligations": [
{
"type": "redact_pii",
"detail": "UU PDP Indonesia PII detected: NIK",
"fulfillment": {
"endpoint": "/api/v1/mcp/check-input",
"method": "POST",
"phase": "request",
"content_types": ["text/plain"]
}
}
],
"evaluated_policies": ["sys_pii_indonesia"],
"expires_at": "2026-06-09T10:35:00Z"
}
The fulfillment block:
| Field | Meaning |
|---|---|
endpoint | The engine endpoint to POST to. phase: "request" obligations name /api/v1/mcp/check-input; the response leg uses /api/v1/mcp/check-output. |
method | Always POST today. |
phase | request or response. /decide runs pre-call, so it only ever emits request-phase obligations; you fulfill the response leg yourself after the backend call (see response-leg fulfillment). |
content_types | The mime-types the endpoint can redact today (currently text/plain). If you hold content whose type the endpoint does not advertise, fail closed — do not forward it ungoverned. Media (image/*, application/pdf) routes to AxonFlow's media-governance subsystem, not this endpoint. |
An obligation is an instruction to call the engine — not something you fulfill with your own code. If a verdict has no redact_pii obligation, there is nothing to redact: forward the original content.
Step 3 — Fulfill, then forward
POST the content to the endpoint the obligation named:
curl -s -X POST http://localhost:8080/api/v1/mcp/check-input \
-H "Content-Type: application/json" \
-d '{
"connector_type": "my-gateway",
"tenant_id": "acme-prod",
"operation": "execute",
"statement": "Summarize this ticket from Budi, NIK 3174011503820001"
}' | jq .
{
"allowed": true,
"policies_evaluated": 7,
"redaction_evaluated": true,
"redacted": true,
"redacted_statement": "Summarize this ticket from Budi, NIK ****************"
}
Now forward only redacted_statement to your backend — never the original query. The content your PEP forwards is the content AxonFlow returned, nothing else.
In gateway/PDP mode the
connector_typeis a synthetic tag of your choosing — detection is connector-agnostic and has no "enabled connector" prerequisite. The/api/v1/mcp/path segment is historical; the endpoint governs whatever content you submit.
Fail-closed rules
These are the rules the reference PEP client and its security review hardened. A PEP that honors all of them cannot leak by accident. In every case below, "fail closed" means: do not forward the content — block the exchange (or apply your configured deny posture).
| Condition | Required behavior |
|---|---|
verdict: "deny" | Block. Surface reasons if you show the caller why. |
verdict: "needs_approval" | Block (or route to your HITL approval flow). Do not forward. |
Missing, empty, or unrecognized verdict | Block. Never default to allow. |
/decide returns a non-2xx (4xx rejection, 5xx, or 503 circuit-breaker) | Fail closed. A 503 with a fail-closed deny body is the engine telling you to block; apply your configured posture. |
Engine unreachable / timeout on /decide or the fulfillment call | Fail closed. No verdict and no redacted content means no safe forward. |
A redact_pii obligation names no fulfillment, or names an endpoint your PEP will not call | Fail closed. Never infer an endpoint or forward unredacted. |
Content whose mime-type is not in the obligation's content_types | Fail closed. Do not forward content the endpoint can't redact. |
check-input returns redaction_evaluated: false (or the field is absent) | Fail closed. The redactor did not run — redacted: false here is not "found nothing," it is "didn't look." Forwarding would leak. |
check-input returns redacted: true but an empty redacted_statement | Fail closed. A claimed-but-empty redaction is a self-contradiction; never forward the original. |
check-output returns non-2xx, allowed: false, or a redacted_data of an unexpected shape | Fail closed. Block the already-produced response rather than forwarding it raw. |
Why redaction_evaluated is load-bearing
On the request leg, redacted: false is ambiguous on its own: it could mean "the detector ran and the content was clean" or "the detector never ran." The first is safe to forward; the second leaks. redaction_evaluated disambiguates them:
redaction_evaluated: true⇒ the detector ran. Trustredacted/redacted_statement.redaction_evaluated: false(or absent) ⇒ the detector did not run (no detection config enabled). Fail closed.
The engine sets redaction_evaluated: true on every evaluated allow path, so its absence is itself the signal. This is why a PEP can never treat "no redaction happened" as "safe to forward."
Fulfilling the response leg
/decide runs before the backend call, so it only ever emits request-phase obligations. The response your backend produces is fulfilled by your own call to POST /api/v1/mcp/check-output after you receive it:
curl -s -X POST http://localhost:8080/api/v1/mcp/check-output \
-H "Content-Type: application/json" \
-d '{
"connector_type": "my-gateway",
"tenant_id": "acme-prod",
"message": "Customer Budi (NIK 3174011503820001) requested a refund."
}' | jq .
{
"allowed": true,
"policies_evaluated": 7,
"redacted_data": "Customer Budi (NIK ****************) requested a refund."
}
Read the engine-masked response from redacted_data (a string for message-style responses, or rows for tabular responses; redacted_message is the string counterpart on row-based outputs — at most one is populated). Forward only that. There is no redaction_evaluated field on check-output: the response leg's safety comes entirely from the fail-closed rules above — a non-2xx, allowed: false, an unreachable engine, or an unexpected redacted_data shape all mean block the response, never forward it raw.
Don't build this by hand if you don't have to
AxonFlow ships a blessed PEP client, platform/shared/pep, that implements this entire loop — including every fail-closed rule above — with a single DecideAndFulfill call. Its only redaction path is the engine round-trip, and it refuses (never forwards the original) on any unfulfillable condition. The official SDKs expose the same helper in each language (see the SDK overview; per-language PEP helper docs land when the v8.5.0 SDKs publish). Build your own PEP only when you need a language or runtime the SDKs don't cover — and when you do, this page is the contract to implement.
Related pages
- Decision Mode — the PDP/PEP model, reference adapters, and trace correlation
- Choosing an Integration Mode — where Decision Mode fits among Gateway, Proxy, and Workflow Control Plane modes
- PII Detection → Decision Mode: two-touch redaction — PII specifics of the fulfillment endpoints
- Agent API Endpoints — full field reference for
/decide,check-input, andcheck-output
