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.

Version availability

The engine-fulfillable redaction obligations and the redaction_evaluated fail-closed signal on check-input are available on platform v8.6.0+. The same signal on check-output (the response leg) ships in v9.7.0 — see the v9.7.0 release notes. 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.
check-output returns redaction_evaluated: false (or the field is absent) while you are fulfilling a response-phase redact_pii obligation (v9.7.0+)Fail closed. Same disambiguation as the request leg: the response redactor did not run, so the absence of redacted content is not "nothing to mask."

Why redaction_evaluated is load-bearing

An un-redacted response 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 the redaction fields (redacted / redacted_statement on the request leg, redacted_data on the response leg).
  • 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." The signal ships on check-input from platform v8.6.0 and on check-output from v9.7.0; against an older engine the response leg never carries it, which is exactly why the fail-closed rule reads absent as "did not run."

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,
"redaction_evaluated": true,
"redacted_data": "Customer Budi (NIK ****************) requested a refund."
}

Read the engine-masked response from redacted_data — a masked string for message-style responses, or masked rows for tabular responses. Forward only that.

From platform v9.7.0, check-output also carries the redaction_evaluated signal (it is true whenever the response-redaction pipeline actually ran, whether or not anything was masked). When you are fulfilling a response-phase redact_pii obligation and the field is false or absent, fail closed — the redactor did not run, so an unmasked response is not evidence of clean content. On engines older than v9.7.0 the field is never emitted on this endpoint, and the response leg's safety rests on the remaining fail-closed rules: 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.