Skip to main content

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.

Available in v8.6.0+

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.
  • denyblock. Do not forward.
  • needs_approvalblock / 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:

FieldMeaning
endpointThe engine endpoint to POST to. phase: "request" obligations name /api/v1/mcp/check-input; the response leg uses /api/v1/mcp/check-output.
methodAlways POST today.
phaserequest 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_typesThe 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_type is 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).

ConditionRequired 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 verdictBlock. 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 callFail 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 callFail closed. Never infer an endpoint or forward unredacted.
Content whose mime-type is not in the obligation's content_typesFail 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_statementFail 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 shapeFail 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. Trust redacted / 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.