From e82739591d63f39449171f52189bab04ddd8eb31 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Tue, 9 Jun 2026 08:11:47 +0200 Subject: [PATCH 1/2] feat(pep): decide -> fulfill -> forward Decision Mode PEP (#2571) Add the SDK analog of platform/shared/pep (ADR-056, epic #2563): a decide client that surfaces engine-fulfillable redact_pii obligations, plus a fulfill helper that discharges them by round-tripping content through the named engine endpoint (check-input) -- never by redacting locally. - decide() / fulfillRequest() / decideAndFulfill() (+ async mirrors) and the Pep.hasRequestRedaction() helper + constants on the main client - DecideRequest (fluent builder) / DecideResponse / Obligation / ObligationFulfillment / DecisionCallerIdentity / DecisionTarget types - redacted / redacted_statement / redaction_evaluated on MCPCheckInputResponse; redaction_evaluated on MCPCheckOutputResponse; content_type on check-input - ObligationNotFulfillableException fail-closed signal (no local redaction) - 34 unit tests (every fail-closed branch + passthrough + decide parse + decide_and_fulfill allow/deny/unfulfillable; 99% line cover on new code) + runtime-e2e (real enterprise agent: decide -> fulfill -> masked, demo creds refused); wire-shape baseline annotated (pinned spec SHA unchanged) Minor bump 8.4.0 -> 8.5.0 (additive, SDK semver decoupled from platform). Refs #2563 Signed-off-by: Saurabh Jain --- CHANGELOG.md | 60 +++ examples/explain-decision/pom.xml | 2 +- examples/list-decisions/pom.xml | 2 +- pom.xml | 2 +- .../DecideFulfillObligationTest.java | 135 +++++ .../decide_fulfill_obligation/README.md | 31 ++ .../java/com/getaxonflow/sdk/AxonFlow.java | 421 +++++++++++---- src/main/java/com/getaxonflow/sdk/Pep.java | 155 ++++++ .../ObligationNotFulfillableException.java | 53 ++ .../getaxonflow/sdk/types/DecideRequest.java | 211 ++++++++ .../getaxonflow/sdk/types/DecideResponse.java | 210 ++++++++ .../sdk/types/DecisionCallerIdentity.java | 105 ++++ .../getaxonflow/sdk/types/DecisionTarget.java | 117 ++++ .../sdk/types/MCPCheckInputRequest.java | 43 +- .../sdk/types/MCPCheckInputResponse.java | 141 ++++- .../sdk/types/MCPCheckOutputResponse.java | 79 ++- .../com/getaxonflow/sdk/types/Obligation.java | 106 ++++ .../sdk/types/ObligationFulfillment.java | 124 +++++ .../java/com/getaxonflow/sdk/PepTest.java | 499 ++++++++++++++++++ .../getaxonflow/sdk/types/PepTypesTest.java | 232 ++++++++ tests/fixtures/wire-shape-baseline.json | 18 +- 21 files changed, 2608 insertions(+), 138 deletions(-) create mode 100644 runtime-e2e/decide_fulfill_obligation/DecideFulfillObligationTest.java create mode 100644 runtime-e2e/decide_fulfill_obligation/README.md create mode 100644 src/main/java/com/getaxonflow/sdk/Pep.java create mode 100644 src/main/java/com/getaxonflow/sdk/exceptions/ObligationNotFulfillableException.java create mode 100644 src/main/java/com/getaxonflow/sdk/types/DecideRequest.java create mode 100644 src/main/java/com/getaxonflow/sdk/types/DecideResponse.java create mode 100644 src/main/java/com/getaxonflow/sdk/types/DecisionCallerIdentity.java create mode 100644 src/main/java/com/getaxonflow/sdk/types/DecisionTarget.java create mode 100644 src/main/java/com/getaxonflow/sdk/types/Obligation.java create mode 100644 src/main/java/com/getaxonflow/sdk/types/ObligationFulfillment.java create mode 100644 src/test/java/com/getaxonflow/sdk/PepTest.java create mode 100644 src/test/java/com/getaxonflow/sdk/types/PepTypesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b5fe3..fe5dc01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,66 @@ All notable changes to the AxonFlow Java SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [8.5.0] - 2026-06-09 — Decision Mode PEP: decide → fulfill → forward + +Adds the SDK analog of the platform PEP client (`platform/shared/pep`, ADR-056, +epic #2563). A Policy Enforcement Point now follows one path — +**decide → fulfill → forward** — and the SDK makes the engine-fulfillable +obligation contract impossible to misuse: there is **no local redaction path**, +so a `redact_pii` obligation can only be discharged by round-tripping content +through the engine endpoint the obligation names. + +This is a minor, additive release (the SDK's semver is decoupled from the +platform's). + +### Added + +- **`AxonFlow.decide(DecideRequest)`** — the PDP step. `POST /api/v1/decide` + returns a `DecideResponse` whose `getObligations()` is always a (possibly + empty) list of self-describing `Obligation`s. Decision Mode auth is HTTP Basic + (org:license), which the client already sends; wrong/demo credentials are + refused with `AuthenticationException`. A `deny` verdict is returned in the + body (HTTP 200), not as an error. `decideAsync(...)` mirror provided. +- **`AxonFlow.fulfillRequest(DecideResponse, String)`** — discharges every + request-phase `redact_pii` obligation by POSTing the statement to the engine's + `check-input` endpoint and returning the **engine-redacted** statement + (`FulfillResult`: content + `didRedact()`). Fails closed with + `ObligationNotFulfillableException` when an obligation names no request-phase + fulfillment, advertises a content-type the PEP is not holding, names an + endpoint the client will not call, the engine call fails, or the engine reports + `redaction_evaluated=false`. Never redacts locally. +- **`AxonFlow.decideAndFulfill(DecideRequest)`** — the blessed one-call path + (decide, then fulfill any request-phase obligation; `DecideAndFulfillResult` + carries verdict, content, and decision); fail-closed by construction. + `decideAndFulfillAsync(...)` mirror provided. +- **New types**: `DecideRequest` (fluent builder), `DecideResponse`, + `Obligation`, `ObligationFulfillment`, `DecisionCallerIdentity`, + `DecisionTarget`. +- **New exception**: `ObligationNotFulfillableException` (a fail-closed signal, + extends `AxonFlowException`). +- **PEP constants + `Pep.hasRequestRedaction(List)` helper** + (`OBLIGATION_REDACT_PII`, `PHASE_REQUEST`/`PHASE_RESPONSE`, + `CONTENT_TYPE_TEXT`, `VERDICT_ALLOW`/`VERDICT_DENY`/`VERDICT_NEEDS_APPROVAL`, + endpoint-path constants). +- **`redacted` / `redactedStatement` / `redactionEvaluated` on + `MCPCheckInputResponse`** and **`redactionEvaluated` on + `MCPCheckOutputResponse`** — the request-redaction contract fields the agent + emits (ADR-056). A PEP fulfilling an obligation fails closed when + `redactionEvaluated` is false. +- **`contentType` on `MCPCheckInputRequest`** (new 5-arg constructor) and a + `content_type` option on `mcpCheckInput(connectorType, statement, options)` — + selects the request-redaction detector (defaults to `text/plain` + server-side). + +### Notes + +- Wire field names are byte-identical across the Go / Python / TypeScript / Java + SDKs (snake_case on the wire). The new MCP response fields are an acknowledged + SDK superset of the pinned community OpenAPI spec; the wire-shape baseline is + annotated without bumping the pinned spec SHA. +- Existing source-compatible `MCPCheckInputResponse` / `MCPCheckOutputResponse` + constructors are preserved; the new fields default to `false` / `null`. + ## [8.4.0] - 2026-05-30 — Decision request context + Pasal 56(b) transfer basis Targets AxonFlow platform **v8.5.0**. diff --git a/examples/explain-decision/pom.xml b/examples/explain-decision/pom.xml index 7b5ee0b..5f68122 100644 --- a/examples/explain-decision/pom.xml +++ b/examples/explain-decision/pom.xml @@ -25,7 +25,7 @@ com.getaxonflow axonflow-sdk - 8.4.0 + 8.5.0 diff --git a/examples/list-decisions/pom.xml b/examples/list-decisions/pom.xml index 5f7f594..fd1aab1 100644 --- a/examples/list-decisions/pom.xml +++ b/examples/list-decisions/pom.xml @@ -24,7 +24,7 @@ com.getaxonflow axonflow-sdk - 8.4.0 + 8.5.0 diff --git a/pom.xml b/pom.xml index b46fe70..ad368b6 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.getaxonflow axonflow-sdk - 8.4.0 + 8.5.0 jar AxonFlow Java SDK diff --git a/runtime-e2e/decide_fulfill_obligation/DecideFulfillObligationTest.java b/runtime-e2e/decide_fulfill_obligation/DecideFulfillObligationTest.java new file mode 100644 index 0000000..00bd292 --- /dev/null +++ b/runtime-e2e/decide_fulfill_obligation/DecideFulfillObligationTest.java @@ -0,0 +1,135 @@ +/* + * runtime-e2e/decide_fulfill_obligation/DecideFulfillObligationTest.java + * + * Real-wire test of the Decision Mode PEP surface (ADR-056, epic #2563, + * tracking #2571) against a running AxonFlow enterprise agent. + * + * Proves, with NO mocks, that the SDK can run the decide -> fulfill -> forward + * path end-to-end: + * + * 1. decide() on a PII-bearing query returns an allow verdict carrying a + * request-phase redact_pii obligation whose fulfillment names the + * request-redaction engine endpoint. + * 2. fulfillRequest() discharges that obligation through the engine and + * returns engine-masked content in which neither the email + * (john.doe@example.com) nor the card (4111111111111111) survives, and + * the content differs from the original. (No local redaction exists in + * the SDK — only the engine can produce this.) + * 3. decideAndFulfill() yields the same masked content in one call. + * 4. Demo credentials (demo-org / demo-license-not-real) are refused with an + * AuthenticationException (HTTP 401). + * + * Run: + * source /tmp/axonflow-e2e-env.sh + * mvn -q -DskipTests package + * mvn -q -DskipTests dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt + * SDK_JAR=$(ls target/axonflow-sdk-*.jar | grep -v sources | grep -v javadoc | head -1) + * java -cp "$SDK_JAR:$(cat /tmp/cp.txt)" \ + * runtime-e2e/decide_fulfill_obligation/DecideFulfillObligationTest.java + */ +import com.getaxonflow.sdk.AxonFlow; +import com.getaxonflow.sdk.AxonFlowConfig; +import com.getaxonflow.sdk.Pep; +import com.getaxonflow.sdk.exceptions.AuthenticationException; +import com.getaxonflow.sdk.types.DecideRequest; +import com.getaxonflow.sdk.types.DecideResponse; +import com.getaxonflow.sdk.types.DecisionTarget; + +public class DecideFulfillObligationTest { + + static final String EMAIL = "john.doe@example.com"; + static final String CARD = "4111111111111111"; + static final String QUERY = "Send the receipt to " + EMAIL + " and charge card " + CARD; + + static void fail(String msg) { + System.err.println("FAIL: " + msg); + System.exit(1); + } + + static void check(boolean cond, String msg) { + if (!cond) { + fail(msg); + } + } + + public static void main(String[] args) { + String endpoint = System.getenv().getOrDefault("AXONFLOW_ENDPOINT", "http://localhost:8080"); + String clientId = System.getenv("AXONFLOW_CLIENT_ID"); + String clientSecret = System.getenv("AXONFLOW_CLIENT_SECRET"); + String tenantId = System.getenv("AXONFLOW_TENANT_ID"); + String userToken = System.getenv("AXONFLOW_USER_TOKEN"); + if (clientId == null || clientSecret == null) { + fail("AXONFLOW_CLIENT_ID / AXONFLOW_CLIENT_SECRET unset — source /tmp/axonflow-e2e-env.sh"); + } + + AxonFlow client = + AxonFlow.create( + AxonFlowConfig.builder() + .endpoint(endpoint) + .clientId(clientId) + .clientSecret(clientSecret) + .build()); + + DecideRequest req = + DecideRequest.builder("tool", QUERY) + .target(new DecisionTarget("tool", null, null, "send_receipt")) + .userToken(userToken) + .build(); + + // 1. decide -> allow + request-phase redact_pii obligation. + DecideResponse decision = client.decide(req); + System.out.println( + "decide -> verdict=" + + decision.getVerdict() + + " decision_id=" + + decision.getDecisionId() + + " obligations=" + + decision.getObligations().size() + + " evaluated_policies=" + + decision.getEvaluatedPolicies()); + check(Pep.VERDICT_ALLOW.equals(decision.getVerdict()), "expected allow, got " + decision.getVerdict()); + check( + Pep.hasRequestRedaction(decision.getObligations()), + "expected a request-phase redact_pii obligation, got " + decision.getObligations()); + System.out.println("PASS step 1: decide returned allow + redact_pii request-phase obligation"); + + // 2. fulfillRequest -> engine-masked content; PII must NOT survive. + AxonFlow.FulfillResult fr = client.fulfillRequest(decision, QUERY); + System.out.println("fulfillRequest -> didRedact=" + fr.didRedact() + " content=" + fr.getContent()); + assertMasked(fr.getContent()); + check(fr.didRedact(), "expected the engine to have changed the content (didRedact=true)"); + System.out.println("PASS step 2: fulfillRequest masked email + card via the engine (no local redaction)"); + + // 3. decideAndFulfill -> same masked content in one call. + AxonFlow.DecideAndFulfillResult daf = client.decideAndFulfill(req); + System.out.println( + "decideAndFulfill -> verdict=" + daf.getVerdict() + " content=" + daf.getContent()); + check(Pep.VERDICT_ALLOW.equals(daf.getVerdict()), "decideAndFulfill verdict=" + daf.getVerdict()); + assertMasked(daf.getContent()); + System.out.println("PASS step 3: decideAndFulfill returned engine-masked content in one call"); + + // 4. Demo credentials are refused with 401. + AxonFlow demo = + AxonFlow.create( + AxonFlowConfig.builder() + .endpoint(endpoint) + .clientId("demo-org") + .clientSecret("demo-license-not-real") + .build()); + try { + demo.decide(DecideRequest.builder("tool", "ping").build()); + fail("expected demo credentials to be refused with AuthenticationException"); + } catch (AuthenticationException e) { + System.out.println("PASS step 4: demo credentials refused -> AuthenticationException: " + e.getMessage()); + } + + System.out.println("ALL PASS: decide -> fulfill -> forward verified through the SDK against the live agent"); + } + + static void assertMasked(String content) { + check(content != null, "content is null"); + check(!content.contains(EMAIL), "email '" + EMAIL + "' survived in: " + content); + check(!content.contains(CARD), "card '" + CARD + "' survived in: " + content); + check(!content.equals(QUERY), "content equals the original (no redaction happened): " + content); + } +} diff --git a/runtime-e2e/decide_fulfill_obligation/README.md b/runtime-e2e/decide_fulfill_obligation/README.md new file mode 100644 index 0000000..4e5f1e6 --- /dev/null +++ b/runtime-e2e/decide_fulfill_obligation/README.md @@ -0,0 +1,31 @@ +# decide_fulfill_obligation (v8.5.0 — Decision Mode PEP, #2563 / #2571) + +Real-stack proof that the SDK runs the Decision Mode PEP path +**decide → fulfill → forward** against a live AxonFlow enterprise agent, with +NO mocks and NO local redaction: + +1. **`decide()`** on the PII-bearing query + `"Send the receipt to john.doe@example.com and charge card 4111111111111111"` + returns an `allow` verdict carrying a request-phase `redact_pii` obligation + whose fulfillment names the request-redaction engine endpoint. +2. **`fulfillRequest()`** discharges that obligation through the engine and + returns engine-masked content in which neither `john.doe@example.com` nor + `4111111111111111` survives, and the content differs from the original. The + SDK has no local redaction path — only the engine can produce this. +3. **`decideAndFulfill()`** yields the same masked content in one call. +4. **Demo credentials** (`demo-org` / `demo-license-not-real`) are refused with + an `AuthenticationException` (HTTP 401). + +## Run + +``` +source /tmp/axonflow-e2e-env.sh # AXONFLOW_CLIENT_ID / _SECRET / _TENANT_ID / _USER_TOKEN +mvn -q -DskipTests package +mvn -q -DskipTests dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt +SDK_JAR=$(ls target/axonflow-sdk-*.jar | grep -v sources | grep -v javadoc | head -1) +java -cp "$SDK_JAR:$(cat /tmp/cp.txt)" \ + runtime-e2e/decide_fulfill_obligation/DecideFulfillObligationTest.java +``` + +Exits non-zero (and prints `FAIL: ...`) if any step fails — e.g. if the PII +survives fulfillment or demo credentials are not refused. diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index 53770ea..459f839 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -53,7 +53,6 @@ import java.util.concurrent.Executors; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ThreadFactory; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import okhttp3.*; import org.slf4j.Logger; @@ -158,8 +157,8 @@ public Thread newThread(Runnable r) { * config.getMapTimeout()}. Used for every plan-lifecycle call (generate, execute, get, update, * cancel, resume, rollback) where a single call may outlive the default request timeout. MAP * plans chain multiple LLM calls end-to-end and commonly take 60-120s; the global timeout - * (default 60s) would cut them off. Shares the connection pool, interceptors, and dispatcher - * with {@link #httpClient} — only the call-timeout attribute differs. + * (default 60s) would cut them off. Shares the connection pool, interceptors, and dispatcher with + * {@link #httpClient} — only the call-timeout attribute differs. */ private final OkHttpClient planHttpClient; @@ -187,9 +186,12 @@ private AxonFlow(AxonFlowConfig config) { this.planHttpClient = this.httpClient .newBuilder() - .callTimeout(config.getMapTimeout().toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS) - .readTimeout(config.getMapTimeout().toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS) - .writeTimeout(config.getMapTimeout().toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS) + .callTimeout( + config.getMapTimeout().toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS) + .readTimeout( + config.getMapTimeout().toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS) + .writeTimeout( + config.getMapTimeout().toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS) .build(); this.objectMapper = createObjectMapper(); this.retryExecutor = new RetryExecutor(config.getRetryConfig()); @@ -211,20 +213,16 @@ private AxonFlow(AxonFlowConfig config) { } /** - * Run the heartbeat gate against the process-global singleton. Constructs - * the gating decision from this client's mode/config, then asks - * {@link HeartbeatState#shared()} to decide whether to send (and to - * write the stamp on success). + * Run the heartbeat gate against the process-global singleton. Constructs the gating decision + * from this client's mode/config, then asks {@link HeartbeatState#shared()} to decide whether to + * send (and to write the stamp on success). * - *

This call is synchronous and bounded by the per-call HTTP timeout - * (3s) WHEN the gate decides to fire. When the gate decides not to fire - * (typical hot-path case after the first cold-start ping), the cost is - * a single mutex acquire and a {@code System.currentTimeMillis()} - * comparison. + *

This call is synchronous and bounded by the per-call HTTP timeout (3s) WHEN the gate decides + * to fire. When the gate decides not to fire (typical hot-path case after the first cold-start + * ping), the cost is a single mutex acquire and a {@code System.currentTimeMillis()} comparison. * - *

For the request hot path, see {@link #invokeHeartbeatAsync()}, - * which delegates to a daemon thread so a 3-second firing-block never - * delays a user API call. + *

For the request hot path, see {@link #invokeHeartbeatAsync()}, which delegates to a daemon + * thread so a 3-second firing-block never delays a user API call. */ private void invokeHeartbeat() { String modeStr = config.getMode() != null ? config.getMode().getValue() : "production"; @@ -245,22 +243,20 @@ private void invokeHeartbeat() { } /** - * Async variant of {@link #invokeHeartbeat()} — dispatches the gate - * onto {@link #HEARTBEAT_EXECUTOR} so a user-facing API call is never - * delayed by the 3-second telemetry POST when the gate decides to fire. + * Async variant of {@link #invokeHeartbeat()} — dispatches the gate onto {@link + * #HEARTBEAT_EXECUTOR} so a user-facing API call is never delayed by the 3-second telemetry POST + * when the gate decides to fire. * - *

The executor is a single-threaded daemon — concurrent dispatches - * queue rather than spawning threads (10k req/s would otherwise create - * 10k threads/s pre-fix). The gate's in-flight + 1-hour cache means - * queued runs immediately fast-path past the work, so queue depth is - * bounded in practice. + *

The executor is a single-threaded daemon — concurrent dispatches queue rather than spawning + * threads (10k req/s would otherwise create 10k threads/s pre-fix). The gate's in-flight + 1-hour + * cache means queued runs immediately fast-path past the work, so queue depth is bounded in + * practice. * - *

Daemon thread choice: long-running services have stable JVMs so - * the executor completes the POST normally. Short-lived processes - * (Lambda cold start, CLI binaries) deliver the boot ping via the - * synchronous {@link #invokeHeartbeat} call from the constructor, so - * the async request-path heartbeat is "extra" — its loss to JVM exit - * is acceptable and only matters across the 7-day boundary. + *

Daemon thread choice: long-running services have stable JVMs so the executor completes the + * POST normally. Short-lived processes (Lambda cold start, CLI binaries) deliver the boot ping + * via the synchronous {@link #invokeHeartbeat} call from the constructor, so the async + * request-path heartbeat is "extra" — its loss to JVM exit is acceptable and only matters across + * the 7-day boundary. */ private void invokeHeartbeatAsync() { try { @@ -273,14 +269,12 @@ private void invokeHeartbeatAsync() { } /** - * Single HTTP wrapper used by every public-API request path. Invokes - * the heartbeat gate as a side effect, ASYNCHRONOUSLY so the user's - * API call is never delayed by telemetry. + * Single HTTP wrapper used by every public-API request path. Invokes the heartbeat gate as a side + * effect, ASYNCHRONOUSLY so the user's API call is never delayed by telemetry. * - *

IMPORTANT: This wrapper must NOT be called from telemetry code - * itself ({@link TelemetryReporter#sendPingNow} or its private helpers). - * Those build their own throw-away {@code OkHttpClient} instances to - * avoid any recursive heartbeat triggering. + *

IMPORTANT: This wrapper must NOT be called from telemetry code itself ({@link + * TelemetryReporter#sendPingNow} or its private helpers). Those build their own throw-away {@code + * OkHttpClient} instances to avoid any recursive heartbeat triggering. */ private Response executeHttp(OkHttpClient client, Request request) throws java.io.IOException { invokeHeartbeatAsync(); @@ -687,12 +681,12 @@ public CompletableFuture searchAuditLogsAsync(AuditSearchRe * Fetches the full explanation for a previously-made policy decision. * *

Implements ADR-043 (Explainability Data Contract). Calls {@code GET - * /api/v1/decisions/:id/explain} and returns a {@link DecisionExplanation} including - * matched policies, risk level, reason, override availability, existing override ID (if - * any), and a rolling-24h session hit count for the matched rule. + * /api/v1/decisions/:id/explain} and returns a {@link DecisionExplanation} including matched + * policies, risk level, reason, override availability, existing override ID (if any), and a + * rolling-24h session hit count for the matched rule. * - *

The caller must either own the decision (user_email match) or belong to the same - * tenant as the decision's originator. + *

The caller must either own the decision (user_email match) or belong to the same tenant as + * the decision's originator. * *

Example usage: * @@ -703,8 +697,8 @@ public CompletableFuture searchAuditLogsAsync(AuditSearchRe * } * } * - * @param decisionId the global decision identifier returned in the original step gate or - * policy evaluation response + * @param decisionId the global decision identifier returned in the original step gate or policy + * evaluation response * @return the decision explanation (frozen shape per ADR-043) * @throws IllegalArgumentException if decisionId is null or empty * @throws AxonFlowException if the request fails or the decision is past retention @@ -750,14 +744,12 @@ public CompletableFuture explainDecisionAsync(String decisi /** * Lists recent policy decisions for the caller's tenant (Session γ / #1982). * - *

Returns the slim 5-field {@link DecisionSummary} page; the platform applies - * a tier-gated cap (5/24h Free + Community, 100/30d Pro + Evaluation, 1000/full - * retention Enterprise). Over-cap requests yield a 429 with the V1 upgrade - * envelope, surfaced as {@link RateLimitException} carrying - * {@code limitType}, {@code tier}, and {@code upgrade.{tier,compareUrl,buyUrl}}. + *

Returns the slim 5-field {@link DecisionSummary} page; the platform applies a tier-gated cap + * (5/24h Free + Community, 100/30d Pro + Evaluation, 1000/full retention Enterprise). Over-cap + * requests yield a 429 with the V1 upgrade envelope, surfaced as {@link RateLimitException} + * carrying {@code limitType}, {@code tier}, and {@code upgrade.{tier,compareUrl,buyUrl}}. * - *

Filters compose; null fields are omitted from the URL so the platform applies - * tier defaults. + *

Filters compose; null fields are omitted from the URL so the platform applies tier defaults. * *

Example: * @@ -851,9 +843,8 @@ private static String optString(JsonNode node, String field) { } /** - * Serialize {@link ListDecisionsOptions} into a "?k=v&k=v" query string. - * Empty when opts or all fields are null. Stable field order so test - * mocks can match the URL exactly. + * Serialize {@link ListDecisionsOptions} into a "?k=v&k=v" query string. Empty when opts or all + * fields are null. Stable field order so test mocks can match the URL exactly. */ static String buildListDecisionsQuery(ListDecisionsOptions opts) { if (opts == null) { @@ -1991,10 +1982,10 @@ public CompletableFuture rollbackPlanAsync( /** * Lists configured LLM providers from a SINGLE page of results. * - *

Calls {@code GET /api/v1/llm-providers}. Mirrors the Python SDK's {@code - * list_providers()}, the Go SDK's {@code ListProviders()}, and the TypeScript - * SDK's {@code listProviders()}. For multi-page traversal use {@link - * #listAllLLMProviders}; for pagination metadata use {@link #listLLMProvidersPaged}. + *

Calls {@code GET /api/v1/llm-providers}. Mirrors the Python SDK's {@code list_providers()}, + * the Go SDK's {@code ListProviders()}, and the TypeScript SDK's {@code listProviders()}. For + * multi-page traversal use {@link #listAllLLMProviders}; for pagination metadata use {@link + * #listLLMProvidersPaged}. * * @return list of configured providers */ @@ -2029,8 +2020,7 @@ public List listLLMProviders( } /** - * Lists one page of providers along with pagination metadata so callers can - * paginate manually. + * Lists one page of providers along with pagination metadata so callers can paginate manually. */ public LLMProviderListResponse listLLMProvidersPaged( String type, Boolean enabled, Integer page, Integer pageSize) { @@ -2096,9 +2086,7 @@ public List listAllLLMProviders(String type, Boolean enabled, int p LLMProviderListResponse resp = listLLMProvidersPaged(type, enabled, page, pageSize); all.addAll(resp.getProviders()); PaginationMeta meta = resp.getPagination(); - if (meta == null - || meta.getTotalPages() <= page - || resp.getProviders().isEmpty()) { + if (meta == null || meta.getTotalPages() <= page || resp.getProviders().isEmpty()) { break; } page += 1; @@ -2496,7 +2484,12 @@ public MCPCheckInputResponse mcpCheckInput( String operation = (String) options.getOrDefault("operation", "execute"); @SuppressWarnings("unchecked") Map parameters = (Map) options.get("parameters"); - request = new MCPCheckInputRequest(connectorType, statement, parameters, operation); + // content_type selects the request-redaction detector (ADR-056 / #2563); null + // defaults to text/plain server-side. + String contentType = (String) options.get("content_type"); + request = + new MCPCheckInputRequest( + connectorType, statement, parameters, operation, contentType); } else { request = new MCPCheckInputRequest(connectorType, statement); } @@ -2560,6 +2553,238 @@ public CompletableFuture mcpCheckInputAsync( () -> mcpCheckInput(connectorType, statement, options), asyncExecutor); } + // ======================================================================== + // Decision Mode PEP — decide -> fulfill -> forward (ADR-056, epic #2563) + // ======================================================================== + + /** + * Asks the PDP for a verdict on a request ({@code POST /api/v1/decide}). + * + *

This is the PDP step of a PEP. {@code /decide} is a pure decision point: it NEVER mutates + * content. When an allow verdict carries a {@code redact_pii} obligation, discharge it with + * {@link #fulfillRequest(DecideResponse, String)} (or use the one-call {@link + * #decideAndFulfill(DecideRequest)}) — never by redacting locally. + * + *

Decision Mode auth is HTTP Basic (org:license), which this client already sends; demo / + * wrong credentials are refused with HTTP 401 → {@link + * com.getaxonflow.sdk.exceptions.AuthenticationException}. A deny verdict is returned in the body + * with HTTP 200, not as an error. + * + * @param request the {@link DecideRequest} ({@code stage} ∈ {@code {"llm","tool","agent"}} and + * {@code query} are required) + * @return the {@link DecideResponse} verdict, with {@code obligations} always a (possibly empty) + * list + * @throws com.getaxonflow.sdk.exceptions.AuthenticationException on HTTP 401 (bad / demo + * credentials) + * @throws AxonFlowException on other non-200 responses + */ + public DecideResponse decide(DecideRequest request) { + Objects.requireNonNull(request, "request cannot be null"); + return retryExecutor.execute( + () -> { + Request httpRequest = buildRequest("POST", Pep.DECIDE_PATH, request); + try (Response response = executeHttp(httpClient, httpRequest)) { + return parseResponse(response, DecideResponse.class); + } + }, + "decide"); + } + + /** + * Asynchronously asks the PDP for a verdict on a request. + * + * @param request the {@link DecideRequest} + * @return a future containing the {@link DecideResponse} + */ + public CompletableFuture decideAsync(DecideRequest request) { + return CompletableFuture.supplyAsync(() -> decide(request), asyncExecutor); + } + + /** + * Discharges every request-phase {@code redact_pii} obligation on {@code decision} by calling the + * engine endpoint each obligation names, returning the engine-redacted statement to forward. + * + *

There is NO code path in which this method redacts locally — fulfillment is always the + * engine round-trip (ADR-056 / #2563). It NEVER returns the original statement under an + * unfulfillable condition; it throws {@link + * com.getaxonflow.sdk.exceptions.ObligationNotFulfillableException} so the caller fails closed. + * + * @param decision the verdict whose obligations to discharge (null is treated as no obligations) + * @param statement the request content to redact + * @return a {@link FulfillResult} with the (possibly engine-redacted) content and whether the + * engine actually changed it + * @throws com.getaxonflow.sdk.exceptions.ObligationNotFulfillableException when a {@code + * redact_pii} obligation named no request-phase fulfillment, advertised a content-type the + * PEP is not holding, named an endpoint this client will not call, the engine call failed, or + * the engine reported the redactor did not run ({@code redaction_evaluated=false}). The + * caller MUST fail closed (block) — never forward the original {@code statement}. + */ + public FulfillResult fulfillRequest(DecideResponse decision, String statement) { + if (decision == null) { + return new FulfillResult(statement, false); + } + String redacted = statement; + boolean didRedact = false; + for (Obligation ob : decision.getObligations()) { + if (ob == null || !Pep.OBLIGATION_REDACT_PII.equals(ob.getType())) { + // redact_pii is the only content-mutating obligation today; other types are + // pass-through by contract. + continue; + } + ObligationFulfillment f = ob.getFulfillment(); + if (f == null || !Pep.PHASE_REQUEST.equals(f.getPhase())) { + throw new ObligationNotFulfillableException( + "redact_pii obligation missing request-phase fulfillment"); + } + List contentTypes = f.getContentTypes(); + if (contentTypes != null + && !contentTypes.isEmpty() + && !contentTypes.contains(Pep.CONTENT_TYPE_TEXT)) { + throw new ObligationNotFulfillableException( + "fulfillment endpoint does not advertise a " + Pep.CONTENT_TYPE_TEXT + " detector"); + } + if (!Pep.endpointPathMatches(f.getEndpoint(), Pep.REQUEST_REDACTION_PATH)) { + throw new ObligationNotFulfillableException( + "fulfillment endpoint '" + f.getEndpoint() + "' is not the request-redaction endpoint"); + } + redacted = fulfillViaCheckInput(redacted); + // didRedact reflects whether the ENGINE changed the content, not merely that an + // obligation was present. + if (!redacted.equals(statement)) { + didRedact = true; + } + } + return new FulfillResult(redacted, didRedact); + } + + /** + * POSTs {@code statement} to the request-redaction engine endpoint and returns the engine-masked + * statement. Fails closed (throws {@link + * com.getaxonflow.sdk.exceptions.ObligationNotFulfillableException}) when the engine call errors, + * the engine returns a non-200, or {@code redaction_evaluated} is false — never returns + * unredacted content under an unfulfillable condition. + */ + private String fulfillViaCheckInput(String statement) { + MCPCheckInputResponse result; + try { + Map options = new HashMap<>(); + options.put("operation", "execute"); + options.put("content_type", Pep.CONTENT_TYPE_TEXT); + result = mcpCheckInput("gateway", statement, options); + } catch (AxonFlowException e) { + throw new ObligationNotFulfillableException( + "request-redaction engine call failed: " + e.getMessage(), e); + } + // FAIL CLOSED if the redactor did not actually run (#2563 B1). Without this the PEP cannot + // distinguish "engine looked, found nothing" (safe to forward) from "engine wasn't looking" + // (would leak PII). + if (!result.isRedactionEvaluated()) { + throw new ObligationNotFulfillableException( + "engine reported the redactor did not run (redaction disabled)"); + } + if (result.isRedacted() + && result.getRedactedStatement() != null + && !result.getRedactedStatement().isEmpty()) { + return result.getRedactedStatement(); + } + // Redactor ran and found nothing to mask — forward unchanged. + return statement; + } + + /** + * One-call PEP path: decide, then fulfill any request-phase obligation (ADR-056 / #2563). + * + *

Returns a {@link DecideAndFulfillResult} carrying the verdict, the content to forward + * (engine-redacted when an obligation applied), and the raw decision. Branch on the verdict: + * forward {@link DecideAndFulfillResult#getContent()} on {@code allow}; block on {@code deny} / + * {@code needs_approval}. + * + *

On the not-fulfillable path this throws {@link + * com.getaxonflow.sdk.exceptions.ObligationNotFulfillableException} — a caller that catches it + * has NO content to accidentally forward, so the path is fail-closed by construction. + * + * @param request the {@link DecideRequest} + * @return the verdict, content, and decision + * @throws com.getaxonflow.sdk.exceptions.AuthenticationException on HTTP 401 + * @throws com.getaxonflow.sdk.exceptions.ObligationNotFulfillableException when an allow verdict + * carries an unfulfillable {@code redact_pii} obligation + */ + public DecideAndFulfillResult decideAndFulfill(DecideRequest request) { + DecideResponse decision = decide(request); + if (!Pep.VERDICT_ALLOW.equals(decision.getVerdict())) { + return new DecideAndFulfillResult(decision.getVerdict(), request.getQuery(), decision); + } + FulfillResult fulfilled = fulfillRequest(decision, request.getQuery()); + return new DecideAndFulfillResult(decision.getVerdict(), fulfilled.getContent(), decision); + } + + /** + * Asynchronously runs the one-call PEP path. + * + * @param request the {@link DecideRequest} + * @return a future containing the {@link DecideAndFulfillResult} + */ + public CompletableFuture decideAndFulfillAsync(DecideRequest request) { + return CompletableFuture.supplyAsync(() -> decideAndFulfill(request), asyncExecutor); + } + + /** + * Result of {@link #fulfillRequest(DecideResponse, String)}: the content to forward and whether + * the engine actually changed it. + */ + public static final class FulfillResult { + private final String content; + private final boolean didRedact; + + FulfillResult(String content, boolean didRedact) { + this.content = content; + this.didRedact = didRedact; + } + + /** Returns the content to forward (engine-redacted when an obligation mutated the request). */ + public String getContent() { + return content; + } + + /** Returns whether the engine actually changed the content. */ + public boolean didRedact() { + return didRedact; + } + } + + /** + * Result of {@link #decideAndFulfill(DecideRequest)}: verdict, content to forward, and decision. + */ + public static final class DecideAndFulfillResult { + private final String verdict; + private final String content; + private final DecideResponse decision; + + DecideAndFulfillResult(String verdict, String content, DecideResponse decision) { + this.verdict = verdict; + this.content = content; + this.decision = decision; + } + + /** Returns the verdict: {@code allow}, {@code deny}, or {@code needs_approval}. */ + public String getVerdict() { + return verdict; + } + + /** + * Returns the content to forward on {@code allow} (engine-redacted when an obligation applied), + * or the original query on a non-allow verdict. + */ + public String getContent() { + return content; + } + + /** Returns the raw PDP decision. */ + public DecideResponse getDecision() { + return decision; + } + } + /** * Validates MCP response data against configured policies. * @@ -5489,9 +5714,9 @@ public void markStepCompleted( } /** - * Inspects a 409 response on a step gate/complete call. If the body carries {@code - * error.code == "IDEMPOTENCY_KEY_MISMATCH"}, returns a typed {@link - * IdempotencyKeyMismatchException}; otherwise falls back to a generic {@link AxonFlowException}. + * Inspects a 409 response on a step gate/complete call. If the body carries {@code error.code == + * "IDEMPOTENCY_KEY_MISMATCH"}, returns a typed {@link IdempotencyKeyMismatchException}; otherwise + * falls back to a generic {@link AxonFlowException}. * *

Must only be called on responses with {@code response.code() == 409}. Consumes the response * body. @@ -5682,7 +5907,8 @@ public com.getaxonflow.sdk.types.workflow.WorkflowTypes.CheckpointListResponse g return retryExecutor.execute( () -> { Request httpRequest = - buildOrchestratorRequest("GET", "/api/v1/workflows/" + workflowId + "/checkpoints", null); + buildOrchestratorRequest( + "GET", "/api/v1/workflows/" + workflowId + "/checkpoints", null); try (Response response = executeHttp(httpClient, httpRequest)) { return parseResponse( response, @@ -5699,18 +5925,20 @@ public com.getaxonflow.sdk.types.workflow.WorkflowTypes.CheckpointListResponse g * @param workflowId workflow ID * @return resume result with fresh decision */ - public com.getaxonflow.sdk.types.workflow.WorkflowTypes.ResumeFromCheckpointResponse resumeFromLastCheckpoint( - String workflowId) { + public com.getaxonflow.sdk.types.workflow.WorkflowTypes.ResumeFromCheckpointResponse + resumeFromLastCheckpoint(String workflowId) { Objects.requireNonNull(workflowId, "workflowId cannot be null"); return retryExecutor.execute( () -> { Request httpRequest = - buildOrchestratorRequest("POST", "/api/v1/workflows/" + workflowId + "/checkpoints/resume", "{}"); + buildOrchestratorRequest( + "POST", "/api/v1/workflows/" + workflowId + "/checkpoints/resume", "{}"); try (Response response = executeHttp(httpClient, httpRequest)) { return parseResponse( response, new TypeReference< - com.getaxonflow.sdk.types.workflow.WorkflowTypes.ResumeFromCheckpointResponse>() {}); + com.getaxonflow.sdk.types.workflow.WorkflowTypes + .ResumeFromCheckpointResponse>() {}); } }, "resumeFromLastCheckpoint"); @@ -5723,8 +5951,8 @@ public com.getaxonflow.sdk.types.workflow.WorkflowTypes.ResumeFromCheckpointResp * @param checkpointId checkpoint database ID * @return resume result with fresh decision */ - public com.getaxonflow.sdk.types.workflow.WorkflowTypes.ResumeFromCheckpointResponse resumeFromCheckpoint( - String workflowId, long checkpointId) { + public com.getaxonflow.sdk.types.workflow.WorkflowTypes.ResumeFromCheckpointResponse + resumeFromCheckpoint(String workflowId, long checkpointId) { Objects.requireNonNull(workflowId, "workflowId cannot be null"); return retryExecutor.execute( () -> { @@ -5737,7 +5965,8 @@ public com.getaxonflow.sdk.types.workflow.WorkflowTypes.ResumeFromCheckpointResp return parseResponse( response, new TypeReference< - com.getaxonflow.sdk.types.workflow.WorkflowTypes.ResumeFromCheckpointResponse>() {}); + com.getaxonflow.sdk.types.workflow.WorkflowTypes + .ResumeFromCheckpointResponse>() {}); } }, "resumeFromCheckpoint"); @@ -5835,8 +6064,8 @@ public com.getaxonflow.sdk.types.workflow.WorkflowTypes.ListWorkflowsResponse li * Approves a workflow step that requires human approval. * *

Call this when a step gate returns {@code require_approval} to approve the step and allow - * the workflow to proceed. Prefer the two-arg overload when you can pass a comment — the - * server requires a comment (min 10 chars) as an audit justification. + * the workflow to proceed. Prefer the two-arg overload when you can pass a comment — the server + * requires a comment (min 10 chars) as an audit justification. * * @param workflowId workflow ID * @param stepId step ID @@ -5851,8 +6080,8 @@ public com.getaxonflow.sdk.types.workflow.WorkflowTypes.ApproveStepResponse appr /** * Approves a workflow step that requires human approval, with an audit comment. * - *

The server requires {@code comment} with a minimum of 10 characters — it's the - * audit-trail justification that every approval carries into the workflow history. + *

The server requires {@code comment} with a minimum of 10 characters — it's the audit-trail + * justification that every approval carries into the workflow history. * * @param workflowId workflow ID * @param stepId step ID @@ -5953,9 +6182,7 @@ public com.getaxonflow.sdk.types.workflow.WorkflowTypes.RejectStepResponse rejec } Request httpRequest = buildOrchestratorRequest( - "POST", - "/api/v1/workflows/" + workflowId + "/steps/" + stepId + "/reject", - body); + "POST", "/api/v1/workflows/" + workflowId + "/steps/" + stepId + "/reject", body); try (Response response = executeHttp(httpClient, httpRequest)) { return parseResponse( response, @@ -6023,8 +6250,9 @@ public com.getaxonflow.sdk.types.workflow.WorkflowTypes.RejectStepResponse rejec /** * Gets MAP-plane pending approvals — the counterpart of {@link #getPendingApprovals(int)}. * - *

Lists pending approvals for MAP-backed workflows ({@code GET /api/v1/plans/approvals/pending}). - * Every returned entry has {@code planId} populated; WCP-only approvals are not returned. + *

Lists pending approvals for MAP-backed workflows ({@code GET + * /api/v1/plans/approvals/pending}). Every returned entry has {@code planId} populated; WCP-only + * approvals are not returned. * *

Requires an Evaluation or Enterprise license (same tier gate as the MAP step approve/reject * endpoints). @@ -6048,8 +6276,7 @@ public com.getaxonflow.sdk.types.workflow.WorkflowTypes.RejectStepResponse rejec path.append(hasQuery ? '&' : '?') .append("plan_id=") .append( - java.net.URLEncoder.encode( - planId, java.nio.charset.StandardCharsets.UTF_8)); + java.net.URLEncoder.encode(planId, java.nio.charset.StandardCharsets.UTF_8)); } Request httpRequest = buildOrchestratorRequest("GET", path.toString(), null); @@ -6423,17 +6650,17 @@ public CompletableFuture getHITLRequestAsync(String request * with {@code ErrHITLApprovalDisabledByTier} when called against a community tier that hasn't * enabled HITL, and 401 when credentials are invalid. * - *

This is the explicit row-creation step for callers that detect {@code require_approval} - * from a separate gate ({@code pre_check}, {@code check_tool_input}, MAP plan approvals) and - * want the row enqueued so a reviewer can act on it. After creating, either poll - * {@link #getHITLRequest(String)} until terminal state, or supply - * {@link HITLCreateInput#setNotifyUrl(String) notifyUrl} so the platform fires a signed webhook - * on the transition (n8n Wait-node "On Webhook Call" pattern, ADK plugin polling-free mode). + *

This is the explicit row-creation step for callers that detect {@code require_approval} from + * a separate gate ({@code pre_check}, {@code check_tool_input}, MAP plan approvals) and want the + * row enqueued so a reviewer can act on it. After creating, either poll {@link + * #getHITLRequest(String)} until terminal state, or supply {@link + * HITLCreateInput#setNotifyUrl(String) notifyUrl} so the platform fires a signed webhook on the + * transition (n8n Wait-node "On Webhook Call" pattern, ADK plugin polling-free mode). * *

{@code clientId}, {@code originalQuery}, and {@code requestType} are required; all other - * fields are optional. Bad {@code notifyUrl} schemes are rejected by the platform with HTTP - * 400 (surfaced here via {@link AxonFlowException}); only {@code https://} (and {@code http://} - * for self-hosted local-dev) are accepted. + * fields are optional. Bad {@code notifyUrl} schemes are rejected by the platform with HTTP 400 + * (surfaced here via {@link AxonFlowException}); only {@code https://} (and {@code http://} for + * self-hosted local-dev) are accepted. * * @param input the create-request input * @return the created approval request with {@code requestId} populated diff --git a/src/main/java/com/getaxonflow/sdk/Pep.java b/src/main/java/com/getaxonflow/sdk/Pep.java new file mode 100644 index 0000000..c3756ab --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/Pep.java @@ -0,0 +1,155 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk; + +import com.getaxonflow.sdk.types.Obligation; +import com.getaxonflow.sdk.types.ObligationFulfillment; +import java.util.List; + +/** + * Decision Mode PEP (Policy Enforcement Point) contract constants and helpers (ADR-056, epic + * #2563). + * + *

A PEP follows one path: decide → fulfill → forward. + * + *

    + *
  • decide: ask the PDP ({@code POST /api/v1/decide}) for a verdict on a request. + *
  • fulfill: for every obligation the verdict carries, call the ENGINE endpoint named in the + * obligation's {@code fulfillment} block to obtain engine-redacted content. + *
  • forward: forward the (possibly redacted) content, or block, per verdict. + *
+ * + *

The structural guarantee #2563 demands: a PEP built on this SDK contains NO redaction logic of + * its own. The ONLY way it discharges a {@code redact_pii} obligation is by POSTing the source + * content to the engine endpoint the obligation names ({@code AxonFlow.fulfillRequest} / {@code + * AxonFlow.decideAndFulfill}) and forwarding what the engine returns. If an obligation arrives + * without a fulfillable engine endpoint — or the engine reports the redactor did not run — the + * helper throws {@code ObligationNotFulfillableException} and the caller MUST fail closed (block), + * never forward unredacted. Mirrors {@code platform/shared/pep} (the Go reference PEP). + */ +public final class Pep { + + private Pep() {} + + // --- Obligation contract constants (mirror platform/agent/decision_handler.go) --- + + /** + * The obligation a PEP discharges by replacing request content with engine-redacted content + * before forwarding. + */ + public static final String OBLIGATION_REDACT_PII = "redact_pii"; + + /** + * Request-phase fulfillment. {@code /decide} runs pre-call so it only emits request-phase + * obligations. + */ + public static final String PHASE_REQUEST = "request"; + + /** + * Response-phase fulfillment. Part of the contract for PEP helpers that fan out to the + * response-redaction endpoint after the backend call. + */ + public static final String PHASE_RESPONSE = "response"; + + /** + * The only redaction content-type wired today. The contract is content-type agnostic — a PEP + * holding content of a type not advertised by an obligation's {@code contentTypes} must fail + * closed rather than forward it unredacted. + */ + public static final String CONTENT_TYPE_TEXT = "text/plain"; + + // --- Verdict values returned by the PDP --- + + /** The PDP allows the request (possibly carrying obligations). */ + public static final String VERDICT_ALLOW = "allow"; + + /** The PDP denies the request. */ + public static final String VERDICT_DENY = "deny"; + + /** The PDP requires human approval before the request may proceed. */ + public static final String VERDICT_NEEDS_APPROVAL = "needs_approval"; + + // --- Engine endpoints a PEP will POST content to for fulfillment --- + // An obligation whose fulfillment endpoint is not one of these is rejected — a + // PEP must not be steered into calling an arbitrary URL by a malformed verdict. + + /** The PDP verdict endpoint. */ + public static final String DECIDE_PATH = "/api/v1/decide"; + + /** The request-phase redaction engine endpoint. */ + public static final String REQUEST_REDACTION_PATH = "/api/v1/mcp/check-input"; + + /** The response-phase redaction engine endpoint. */ + public static final String RESPONSE_REDACTION_PATH = "/api/v1/mcp/check-output"; + + /** + * Reports whether any obligation requires request-phase PII redaction. + * + *

Exposed so a PEP can branch ("does this verdict carry work for me?") before calling {@code + * AxonFlow.fulfillRequest}. + * + * @param obligations the obligations to scan (may be null) + * @return {@code true} if any {@code redact_pii} obligation has a request-phase fulfillment + */ + public static boolean hasRequestRedaction(List obligations) { + if (obligations == null) { + return false; + } + for (Obligation o : obligations) { + if (o == null) { + continue; + } + ObligationFulfillment f = o.getFulfillment(); + if (OBLIGATION_REDACT_PII.equals(o.getType()) + && f != null + && PHASE_REQUEST.equals(f.getPhase())) { + return true; + } + } + return false; + } + + /** + * Reports whether {@code endpoint} is the expected engine path. + * + *

Tolerates an absolute URL whose path component matches (some PDPs return a fully-qualified + * obligation endpoint); a blank endpoint never matches. + * + * @param endpoint the obligation's fulfillment endpoint + * @param expected the expected engine path, e.g. {@link #REQUEST_REDACTION_PATH} + * @return {@code true} when the endpoint's path equals {@code expected} + */ + public static boolean endpointPathMatches(String endpoint, String expected) { + String e = endpoint == null ? "" : endpoint.trim(); + if (e.equals(expected)) { + return true; + } + int idx = e.indexOf("://"); + if (idx >= 0) { + String rest = e.substring(idx + 3); + int slash = rest.indexOf('/'); + if (slash >= 0) { + String path = rest.substring(slash); + int q = path.indexOf('?'); + if (q >= 0) { + path = path.substring(0, q); + } + return path.equals(expected); + } + } + return false; + } +} diff --git a/src/main/java/com/getaxonflow/sdk/exceptions/ObligationNotFulfillableException.java b/src/main/java/com/getaxonflow/sdk/exceptions/ObligationNotFulfillableException.java new file mode 100644 index 0000000..907927f --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/exceptions/ObligationNotFulfillableException.java @@ -0,0 +1,53 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.exceptions; + +/** + * Fail-closed signal: a {@code redact_pii} obligation could not be discharged through the engine + * (ADR-056, epic #2563). + * + *

Thrown by {@code AxonFlow.fulfillRequest} / {@code AxonFlow.decideAndFulfill} when an + * obligation named no request-phase fulfillment, advertised a content-type the PEP is not holding, + * named an endpoint this client will not call, the engine call failed, or the engine reported the + * redactor did not run ({@code redaction_evaluated=false}). + * + *

A caller catching this MUST fail closed (block) — it must NEVER forward the original, + * unredacted statement. The SDK never returns the original content under any unfulfillable + * condition; this exception is the only outcome. + */ +public class ObligationNotFulfillableException extends AxonFlowException { + + private static final long serialVersionUID = 1L; + + /** + * Creates a new ObligationNotFulfillableException. + * + * @param message the error message + */ + public ObligationNotFulfillableException(String message) { + super(message, 0, "OBLIGATION_NOT_FULFILLABLE"); + } + + /** + * Creates a new ObligationNotFulfillableException with a cause. + * + * @param message the error message + * @param cause the underlying cause + */ + public ObligationNotFulfillableException(String message, Throwable cause) { + super(message, 0, "OBLIGATION_NOT_FULFILLABLE", cause); + } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/DecideRequest.java b/src/main/java/com/getaxonflow/sdk/types/DecideRequest.java new file mode 100644 index 0000000..cceb586 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/DecideRequest.java @@ -0,0 +1,211 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; +import java.util.Objects; + +/** + * Inbound contract for {@code POST /api/v1/decide} (ADR-056, epic #2563). Mirrors the platform + * {@code DecideRequest}. + * + *

Required: {@code stage} (one of {@code "llm"} | {@code "tool"} | {@code "agent"}) and {@code + * query}. {@code userToken} is optional — a PEP that supplies one gets the validated-user record on + * the audit row; one that doesn't gets a synthesized service user. Build instances with the fluent + * {@link Builder}. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class DecideRequest { + + @JsonProperty("stage") + private final String stage; + + @JsonProperty("query") + private final String query; + + @JsonProperty("caller_identity") + private final DecisionCallerIdentity callerIdentity; + + @JsonProperty("target") + private final DecisionTarget target; + + @JsonProperty("user_token") + private final String userToken; + + @JsonProperty("context") + private final Map context; + + private DecideRequest(Builder b) { + this.stage = b.stage; + this.query = b.query; + this.callerIdentity = b.callerIdentity; + this.target = b.target; + // Omit blank user_token per the wire contract (omit if empty). + this.userToken = (b.userToken == null || b.userToken.isEmpty()) ? null : b.userToken; + // Omit an empty context map per the wire contract (omit if empty). + this.context = (b.context == null || b.context.isEmpty()) ? null : b.context; + } + + /** + * Creates a builder for a decide request. + * + * @param stage the decision stage: {@code "llm"}, {@code "tool"}, or {@code "agent"} + * @param query the request content to be decided on + * @return a new builder + */ + public static Builder builder(String stage, String query) { + return new Builder(stage, query); + } + + /** Returns the decision stage: {@code "llm"}, {@code "tool"}, or {@code "agent"}. */ + public String getStage() { + return stage; + } + + /** Returns the request content to be decided on. */ + public String getQuery() { + return query; + } + + /** Returns the gateway-asserted caller identity, or null. */ + public DecisionCallerIdentity getCallerIdentity() { + return callerIdentity; + } + + /** Returns the target descriptor, or null. */ + public DecisionTarget getTarget() { + return target; + } + + /** Returns the user token, or null when omitted. */ + public String getUserToken() { + return userToken; + } + + /** Returns the additional context map, or null when omitted. */ + public Map getContext() { + return context; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DecideRequest that = (DecideRequest) o; + return Objects.equals(stage, that.stage) + && Objects.equals(query, that.query) + && Objects.equals(callerIdentity, that.callerIdentity) + && Objects.equals(target, that.target) + && Objects.equals(userToken, that.userToken) + && Objects.equals(context, that.context); + } + + @Override + public int hashCode() { + return Objects.hash(stage, query, callerIdentity, target, userToken, context); + } + + @Override + public String toString() { + return "DecideRequest{" + + "stage='" + + stage + + '\'' + + ", query='" + + query + + '\'' + + ", callerIdentity=" + + callerIdentity + + ", target=" + + target + + ", userToken='" + + (userToken != null ? "" : null) + + '\'' + + ", context=" + + context + + '}'; + } + + /** Fluent builder for {@link DecideRequest}. */ + public static final class Builder { + private final String stage; + private final String query; + private DecisionCallerIdentity callerIdentity; + private DecisionTarget target; + private String userToken; + private Map context; + + private Builder(String stage, String query) { + this.stage = stage; + this.query = query; + } + + /** + * Sets the gateway-asserted caller identity. + * + * @param callerIdentity the caller identity + * @return this builder + */ + public Builder callerIdentity(DecisionCallerIdentity callerIdentity) { + this.callerIdentity = callerIdentity; + return this; + } + + /** + * Sets the target descriptor. + * + * @param target the target + * @return this builder + */ + public Builder target(DecisionTarget target) { + this.target = target; + return this; + } + + /** + * Sets the user token (omitted from the wire when null or empty). + * + * @param userToken the user token + * @return this builder + */ + public Builder userToken(String userToken) { + this.userToken = userToken; + return this; + } + + /** + * Sets the additional context map (omitted from the wire when null or empty). + * + * @param context the context map + * @return this builder + */ + public Builder context(Map context) { + this.context = context; + return this; + } + + /** + * Builds the {@link DecideRequest}. + * + * @return the request + */ + public DecideRequest build() { + return new DecideRequest(this); + } + } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/DecideResponse.java b/src/main/java/com/getaxonflow/sdk/types/DecideResponse.java new file mode 100644 index 0000000..7c0feb6 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/DecideResponse.java @@ -0,0 +1,210 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * PDP verdict returned by {@code POST /api/v1/decide} (ADR-056, epic #2563). Mirrors the platform + * {@code DecideResponse}. + * + *

{@code obligations} is always a (possibly empty) list so PEP code can iterate without a + * null-check. {@code traceId} is W3C-format (32 lowercase hex chars). {@code error} is set on the + * deny path when the request was malformed (still HTTP 200). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class DecideResponse { + + @JsonProperty("verdict") + private final String verdict; + + @JsonProperty("decision_id") + private final String decisionId; + + @JsonProperty("trace_id") + private final String traceId; + + @JsonProperty("reasons") + private final List reasons; + + @JsonProperty("obligations") + private final List obligations; + + @JsonProperty("evaluated_policies") + private final List evaluatedPolicies; + + @JsonProperty("stage") + private final String stage; + + @JsonProperty("expires_at") + private final Instant expiresAt; + + @JsonProperty("error") + private final String error; + + /** + * Creates a decide response. + * + * @param verdict the verdict: {@code allow}, {@code deny}, or {@code needs_approval} + * @param decisionId the audit correlator for this decision + * @param traceId the W3C trace id (32 lowercase hex chars) + * @param reasons human-readable reasons, or null + * @param obligations engine-fulfillable obligations; null is normalized to an empty list + * @param evaluatedPolicies the policies evaluated; null is normalized to an empty list + * @param stage the echoed decision stage, or null + * @param expiresAt the verdict expiry, or null + * @param error the error message on the malformed-deny path, or null + */ + @JsonCreator + public DecideResponse( + @JsonProperty("verdict") String verdict, + @JsonProperty("decision_id") String decisionId, + @JsonProperty("trace_id") String traceId, + @JsonProperty("reasons") List reasons, + @JsonProperty("obligations") List obligations, + @JsonProperty("evaluated_policies") List evaluatedPolicies, + @JsonProperty("stage") String stage, + @JsonProperty("expires_at") Instant expiresAt, + @JsonProperty("error") String error) { + this.verdict = verdict; + this.decisionId = decisionId; + this.traceId = traceId; + this.reasons = reasons; + // obligations is always a list so PEP code can iterate without a null-check. + this.obligations = + obligations != null + ? Collections.unmodifiableList(new ArrayList<>(obligations)) + : Collections.emptyList(); + this.evaluatedPolicies = + evaluatedPolicies != null + ? Collections.unmodifiableList(new ArrayList<>(evaluatedPolicies)) + : Collections.emptyList(); + this.stage = stage; + this.expiresAt = expiresAt; + this.error = error; + } + + /** Returns the verdict: {@code allow}, {@code deny}, or {@code needs_approval}. */ + public String getVerdict() { + return verdict; + } + + /** Returns the audit correlator for this decision, or null. */ + public String getDecisionId() { + return decisionId; + } + + /** Returns the W3C trace id (32 lowercase hex chars), or null. */ + public String getTraceId() { + return traceId; + } + + /** Returns the human-readable reasons, or null. */ + public List getReasons() { + return reasons; + } + + /** Returns the engine-fulfillable obligations; never null. */ + public List getObligations() { + return obligations; + } + + /** Returns the policies evaluated; never null. */ + public List getEvaluatedPolicies() { + return evaluatedPolicies; + } + + /** Returns the echoed decision stage, or null. */ + public String getStage() { + return stage; + } + + /** Returns the verdict expiry, or null. */ + public Instant getExpiresAt() { + return expiresAt; + } + + /** Returns the error message on the malformed-deny path, or null. */ + public String getError() { + return error; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DecideResponse that = (DecideResponse) o; + return Objects.equals(verdict, that.verdict) + && Objects.equals(decisionId, that.decisionId) + && Objects.equals(traceId, that.traceId) + && Objects.equals(reasons, that.reasons) + && Objects.equals(obligations, that.obligations) + && Objects.equals(evaluatedPolicies, that.evaluatedPolicies) + && Objects.equals(stage, that.stage) + && Objects.equals(expiresAt, that.expiresAt) + && Objects.equals(error, that.error); + } + + @Override + public int hashCode() { + return Objects.hash( + verdict, + decisionId, + traceId, + reasons, + obligations, + evaluatedPolicies, + stage, + expiresAt, + error); + } + + @Override + public String toString() { + return "DecideResponse{" + + "verdict='" + + verdict + + '\'' + + ", decisionId='" + + decisionId + + '\'' + + ", traceId='" + + traceId + + '\'' + + ", reasons=" + + reasons + + ", obligations=" + + obligations + + ", evaluatedPolicies=" + + evaluatedPolicies + + ", stage='" + + stage + + '\'' + + ", expiresAt=" + + expiresAt + + ", error='" + + error + + '\'' + + '}'; + } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/DecisionCallerIdentity.java b/src/main/java/com/getaxonflow/sdk/types/DecisionCallerIdentity.java new file mode 100644 index 0000000..d1c4c65 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/DecisionCallerIdentity.java @@ -0,0 +1,105 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Objects; + +/** + * Gateway-asserted identity for a {@code POST /api/v1/decide} request (ADR-056, epic #2563). + * + *

{@code orgId} / {@code tenantId} are optional in the body — the auth-derived identity is + * authoritative; body-supplied values are accepted only when they match. Mirrors the platform + * {@code DecisionCallerIdentity}. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public final class DecisionCallerIdentity { + + @JsonProperty("gateway_id") + private final String gatewayId; + + @JsonProperty("org_id") + private final String orgId; + + @JsonProperty("tenant_id") + private final String tenantId; + + /** + * Creates a caller-identity descriptor. All fields are optional. + * + * @param gatewayId the asserting gateway's identifier (may be null) + * @param orgId the organization identifier (may be null) + * @param tenantId the tenant identifier (may be null) + */ + @JsonCreator + public DecisionCallerIdentity( + @JsonProperty("gateway_id") String gatewayId, + @JsonProperty("org_id") String orgId, + @JsonProperty("tenant_id") String tenantId) { + this.gatewayId = gatewayId; + this.orgId = orgId; + this.tenantId = tenantId; + } + + /** Returns the asserting gateway's identifier, or null. */ + public String getGatewayId() { + return gatewayId; + } + + /** Returns the organization identifier, or null. */ + public String getOrgId() { + return orgId; + } + + /** Returns the tenant identifier, or null. */ + public String getTenantId() { + return tenantId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DecisionCallerIdentity that = (DecisionCallerIdentity) o; + return Objects.equals(gatewayId, that.gatewayId) + && Objects.equals(orgId, that.orgId) + && Objects.equals(tenantId, that.tenantId); + } + + @Override + public int hashCode() { + return Objects.hash(gatewayId, orgId, tenantId); + } + + @Override + public String toString() { + return "DecisionCallerIdentity{" + + "gatewayId='" + + gatewayId + + '\'' + + ", orgId='" + + orgId + + '\'' + + ", tenantId='" + + tenantId + + '\'' + + '}'; + } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/DecisionTarget.java b/src/main/java/com/getaxonflow/sdk/types/DecisionTarget.java new file mode 100644 index 0000000..2f86351 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/DecisionTarget.java @@ -0,0 +1,117 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Objects; + +/** + * Describes what the gateway is about to call on a {@code POST /api/v1/decide} request (ADR-056, + * epic #2563). Mirrors the platform {@code DecisionTarget}. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public final class DecisionTarget { + + @JsonProperty("type") + private final String type; + + @JsonProperty("model") + private final String model; + + @JsonProperty("provider") + private final String provider; + + @JsonProperty("tool") + private final String tool; + + /** + * Creates a target descriptor. All fields are optional. + * + * @param type {@code "llm"}, {@code "tool"}, or {@code "agent"} (may be null) + * @param model the model name when {@code type=llm} (may be null) + * @param provider the provider when {@code type=llm} (may be null) + * @param tool the tool name when {@code type=tool} (may be null) + */ + @JsonCreator + public DecisionTarget( + @JsonProperty("type") String type, + @JsonProperty("model") String model, + @JsonProperty("provider") String provider, + @JsonProperty("tool") String tool) { + this.type = type; + this.model = model; + this.provider = provider; + this.tool = tool; + } + + /** Returns the target type: {@code "llm"}, {@code "tool"}, or {@code "agent"}, or null. */ + public String getType() { + return type; + } + + /** Returns the model name (when {@code type=llm}), or null. */ + public String getModel() { + return model; + } + + /** Returns the provider (when {@code type=llm}), or null. */ + public String getProvider() { + return provider; + } + + /** Returns the tool name (when {@code type=tool}), or null. */ + public String getTool() { + return tool; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DecisionTarget that = (DecisionTarget) o; + return Objects.equals(type, that.type) + && Objects.equals(model, that.model) + && Objects.equals(provider, that.provider) + && Objects.equals(tool, that.tool); + } + + @Override + public int hashCode() { + return Objects.hash(type, model, provider, tool); + } + + @Override + public String toString() { + return "DecisionTarget{" + + "type='" + + type + + '\'' + + ", model='" + + model + + '\'' + + ", provider='" + + provider + + '\'' + + ", tool='" + + tool + + '\'' + + '}'; + } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/MCPCheckInputRequest.java b/src/main/java/com/getaxonflow/sdk/types/MCPCheckInputRequest.java index c82ca5f..b0bfe8c 100644 --- a/src/main/java/com/getaxonflow/sdk/types/MCPCheckInputRequest.java +++ b/src/main/java/com/getaxonflow/sdk/types/MCPCheckInputRequest.java @@ -41,6 +41,16 @@ public final class MCPCheckInputRequest { @JsonProperty("operation") private final String operation; + /** + * Selects the request-redaction detector (ADR-056 / #2563 addendum). Null defaults to {@code + * text/plain} server-side. A content type with no registered detector is rejected (415) so a PEP + * fulfilling a {@code redact_pii} obligation fails closed rather than forwarding content the + * engine cannot govern. Source of truth: {@code platform/agent/mcp_handler.go + * MCPCheckInputRequest}. + */ + @JsonProperty("content_type") + private final String contentType; + /** * Creates a request with connector type and statement only. Operation defaults to "execute". * @@ -52,7 +62,8 @@ public MCPCheckInputRequest(String connectorType, String statement) { } /** - * Creates a request with all fields. + * Creates a request with connector type, statement, parameters, and operation. Content type is + * left null (server defaults to {@code text/plain}). * * @param connectorType the MCP connector type (e.g., "postgres") * @param statement the statement to validate @@ -61,10 +72,30 @@ public MCPCheckInputRequest(String connectorType, String statement) { */ public MCPCheckInputRequest( String connectorType, String statement, Map parameters, String operation) { + this(connectorType, statement, parameters, operation, null); + } + + /** + * Creates a request with all fields, including the redaction content type. + * + * @param connectorType the MCP connector type (e.g., "postgres") + * @param statement the statement to validate + * @param parameters optional query parameters + * @param operation the operation type (e.g., "query", "execute") + * @param contentType the redaction content type (e.g., {@code text/plain}); null defaults + * server-side + */ + public MCPCheckInputRequest( + String connectorType, + String statement, + Map parameters, + String operation, + String contentType) { this.connectorType = connectorType; this.statement = statement; this.parameters = parameters; this.operation = operation; + this.contentType = contentType; } public String getConnectorType() { @@ -83,6 +114,11 @@ public String getOperation() { return operation; } + /** Returns the redaction content type (e.g., {@code text/plain}), or null when server-default. */ + public String getContentType() { + return contentType; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -91,12 +127,13 @@ public boolean equals(Object o) { return Objects.equals(connectorType, that.connectorType) && Objects.equals(statement, that.statement) && Objects.equals(parameters, that.parameters) - && Objects.equals(operation, that.operation); + && Objects.equals(operation, that.operation) + && Objects.equals(contentType, that.contentType); } @Override public int hashCode() { - return Objects.hash(connectorType, statement, parameters, operation); + return Objects.hash(connectorType, statement, parameters, operation, contentType); } @Override diff --git a/src/main/java/com/getaxonflow/sdk/types/MCPCheckInputResponse.java b/src/main/java/com/getaxonflow/sdk/types/MCPCheckInputResponse.java index 281e0c8..927314e 100644 --- a/src/main/java/com/getaxonflow/sdk/types/MCPCheckInputResponse.java +++ b/src/main/java/com/getaxonflow/sdk/types/MCPCheckInputResponse.java @@ -29,9 +29,9 @@ * and {@code policyInfo}. * *

The five Plugin Batch 1 / ADR-042 / ADR-043 fields ({@code decisionId}, {@code riskLevel}, - * {@code policyMatches}, {@code overrideAvailable}, {@code overrideExistingId}) are populated - * when the AxonFlow platform is v7.1.0+. Pre-v7.1.0 platforms leave these as {@code null}. - * Source of truth: {@code platform/agent/mcp_server_handler.go:880-940}. + * {@code policyMatches}, {@code overrideAvailable}, {@code overrideExistingId}) are populated when + * the AxonFlow platform is v7.1.0+. Pre-v7.1.0 platforms leave these as {@code null}. Source of + * truth: {@code platform/agent/mcp_server_handler.go:880-940}. */ @JsonIgnoreProperties(ignoreUnknown = true) public final class MCPCheckInputResponse { @@ -63,6 +63,28 @@ public final class MCPCheckInputResponse { @JsonProperty("override_existing_id") private final String overrideExistingId; + /** + * Request-phase redaction (ADR-056 / #2563). When an allowed statement carries PII under a redact + * (not block) policy, the engine returns the masked statement in {@code redactedStatement} so a + * PEP can forward redacted content WITHOUT hand-rolling its own patterns — this is what makes a + * {@code /decide} {@code redact_pii} obligation engine-fulfillable. Source of truth: {@code + * platform/agent/mcp_handler.go MCPCheckInputResponse}. + */ + @JsonProperty("redacted") + private final boolean redacted; + + @JsonProperty("redacted_statement") + private final String redactedStatement; + + /** + * Reports whether the redaction detector actually RAN (regardless of whether it masked anything). + * A PEP fulfilling a {@code redact_pii} obligation MUST fail closed when this is {@code false} — + * it means the redactor did not run (detection disabled), so {@code redacted:false} would + * otherwise be indistinguishable from "looked, found nothing" (#2563 B1). + */ + @JsonProperty("redaction_evaluated") + private final boolean redactionEvaluated; + @JsonCreator public MCPCheckInputResponse( @JsonProperty("allowed") boolean allowed, @@ -73,7 +95,10 @@ public MCPCheckInputResponse( @JsonProperty("risk_level") String riskLevel, @JsonProperty("policy_matches") List policyMatches, @JsonProperty("override_available") Boolean overrideAvailable, - @JsonProperty("override_existing_id") String overrideExistingId) { + @JsonProperty("override_existing_id") String overrideExistingId, + @JsonProperty("redacted") boolean redacted, + @JsonProperty("redacted_statement") String redactedStatement, + @JsonProperty("redaction_evaluated") boolean redactionEvaluated) { this.allowed = allowed; this.blockReason = blockReason; this.policiesEvaluated = policiesEvaluated; @@ -83,17 +108,61 @@ public MCPCheckInputResponse( this.policyMatches = policyMatches; this.overrideAvailable = overrideAvailable; this.overrideExistingId = overrideExistingId; + this.redacted = redacted; + this.redactedStatement = redactedStatement; + this.redactionEvaluated = redactionEvaluated; } /** - * Source-compat overload. Callers that build {@code MCPCheckInputResponse} instances locally - * with the v6.0.0 4-argument shape continue to compile — the five Plugin Batch 1 fields default - * to {@code null}. Server-side responses always go through the {@code @JsonCreator} 9-arg - * constructor regardless. + * Source-compat overload. Callers that build {@code MCPCheckInputResponse} instances locally with + * the v6.0.0 4-argument shape continue to compile — the five Plugin Batch 1 fields and the three + * #2563 redaction fields default to {@code null} / {@code false}. Server-side responses always go + * through the {@code @JsonCreator} constructor regardless. */ public MCPCheckInputResponse( boolean allowed, String blockReason, int policiesEvaluated, ConnectorPolicyInfo policyInfo) { - this(allowed, blockReason, policiesEvaluated, policyInfo, null, null, null, null, null); + this( + allowed, + blockReason, + policiesEvaluated, + policyInfo, + null, + null, + null, + null, + null, + false, + null, + false); + } + + /** + * Source-compat overload preserving the v7.1.0 9-argument shape — the three #2563 redaction + * fields default to {@code false} / {@code null}. + */ + public MCPCheckInputResponse( + boolean allowed, + String blockReason, + int policiesEvaluated, + ConnectorPolicyInfo policyInfo, + String decisionId, + String riskLevel, + List policyMatches, + Boolean overrideAvailable, + String overrideExistingId) { + this( + allowed, + blockReason, + policiesEvaluated, + policyInfo, + decisionId, + riskLevel, + policyMatches, + overrideAvailable, + overrideExistingId, + false, + null, + false); } /** Returns whether the input is allowed by policies. */ @@ -117,32 +186,30 @@ public ConnectorPolicyInfo getPolicyInfo() { } /** - * Returns the audit correlator for this policy decision (Plugin Batch 1, v7.1.0+). Null on - * older platforms. + * Returns the audit correlator for this policy decision (Plugin Batch 1, v7.1.0+). Null on older + * platforms. */ public String getDecisionId() { return decisionId; } /** - * Returns the highest risk level across matched policies ({@code low} | {@code medium} | - * {@code high} | {@code critical}; Plugin Batch 1, v7.1.0+). Null on older platforms. + * Returns the highest risk level across matched policies ({@code low} | {@code medium} | {@code + * high} | {@code critical}; Plugin Batch 1, v7.1.0+). Null on older platforms. */ public String getRiskLevel() { return riskLevel; } - /** - * Returns the per-policy explainability records (ADR-043, v7.1.0+). Null on older platforms. - */ + /** Returns the per-policy explainability records (ADR-043, v7.1.0+). Null on older platforms. */ public List getPolicyMatches() { return policyMatches; } /** * Returns whether at least one matched policy permits a session override (Plugin Batch 1, - * v7.1.0+). Null on older platforms; callers should treat null as "context not available" - * rather than {@code false}. + * v7.1.0+). Null on older platforms; callers should treat null as "context not available" rather + * than {@code false}. */ public Boolean getOverrideAvailable() { return overrideAvailable; @@ -156,6 +223,30 @@ public String getOverrideExistingId() { return overrideExistingId; } + /** + * Returns whether the engine masked PII in the statement (ADR-056 / #2563). A PEP forwards {@link + * #getRedactedStatement()} when this is true. + */ + public boolean isRedacted() { + return redacted; + } + + /** + * Returns the engine-masked statement when {@link #isRedacted()} is true, else null. This is the + * only content a PEP forwards for a {@code redact_pii} obligation; it is never produced locally. + */ + public String getRedactedStatement() { + return redactedStatement; + } + + /** + * Returns whether the redaction detector actually ran (ADR-056 / #2563). A PEP MUST fail closed + * when this is false — {@code redacted=false} alone cannot be trusted as "nothing to mask". + */ + public boolean isRedactionEvaluated() { + return redactionEvaluated; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -163,13 +254,16 @@ public boolean equals(Object o) { MCPCheckInputResponse that = (MCPCheckInputResponse) o; return allowed == that.allowed && policiesEvaluated == that.policiesEvaluated + && redacted == that.redacted + && redactionEvaluated == that.redactionEvaluated && Objects.equals(blockReason, that.blockReason) && Objects.equals(policyInfo, that.policyInfo) && Objects.equals(decisionId, that.decisionId) && Objects.equals(riskLevel, that.riskLevel) && Objects.equals(policyMatches, that.policyMatches) && Objects.equals(overrideAvailable, that.overrideAvailable) - && Objects.equals(overrideExistingId, that.overrideExistingId); + && Objects.equals(overrideExistingId, that.overrideExistingId) + && Objects.equals(redactedStatement, that.redactedStatement); } @Override @@ -183,7 +277,10 @@ public int hashCode() { riskLevel, policyMatches, overrideAvailable, - overrideExistingId); + overrideExistingId, + redacted, + redactedStatement, + redactionEvaluated); } @Override @@ -211,6 +308,10 @@ public String toString() { + ", overrideExistingId='" + overrideExistingId + '\'' + + ", redacted=" + + redacted + + ", redactionEvaluated=" + + redactionEvaluated + '}'; } } diff --git a/src/main/java/com/getaxonflow/sdk/types/MCPCheckOutputResponse.java b/src/main/java/com/getaxonflow/sdk/types/MCPCheckOutputResponse.java index 933f0b1..b9f41e4 100644 --- a/src/main/java/com/getaxonflow/sdk/types/MCPCheckOutputResponse.java +++ b/src/main/java/com/getaxonflow/sdk/types/MCPCheckOutputResponse.java @@ -28,10 +28,10 @@ * (tabular) or a redacted message (text) if PII redaction policies are active, and exfiltration * check information if data volume limits are configured. * - *

The three Plugin Batch 1 / ADR-043 fields ({@code decisionId}, {@code policyMatches}, - * {@code redactedMessage}) are populated when the AxonFlow platform is v7.1.0+. Pre-v7.1.0 - * platforms leave these as {@code null}. Source of truth: {@code - * platform/agent/mcp_server_handler.go:988, 1005, 1051}. + *

The three Plugin Batch 1 / ADR-043 fields ({@code decisionId}, {@code policyMatches}, {@code + * redactedMessage}) are populated when the AxonFlow platform is v7.1.0+. Pre-v7.1.0 platforms leave + * these as {@code null}. Source of truth: {@code platform/agent/mcp_server_handler.go:988, 1005, + * 1051}. */ @JsonIgnoreProperties(ignoreUnknown = true) public final class MCPCheckOutputResponse { @@ -63,6 +63,16 @@ public final class MCPCheckOutputResponse { @JsonProperty("policy_matches") private final List policyMatches; + /** + * Reports whether the response-phase redaction detector actually RAN (ADR-056 / #2563). A PEP + * fulfilling a response-phase {@code redact_pii} obligation MUST fail closed when this is {@code + * false} — the redactor did not run, so absence of redacted output cannot be trusted as "nothing + * to mask". Default {@code false} keeps a PEP fail-closed when the platform predates the field. + * Source of truth: {@code platform/agent/mcp_handler.go}. + */ + @JsonProperty("redaction_evaluated") + private final boolean redactionEvaluated; + @JsonCreator public MCPCheckOutputResponse( @JsonProperty("allowed") boolean allowed, @@ -73,7 +83,8 @@ public MCPCheckOutputResponse( @JsonProperty("exfiltration_info") ExfiltrationCheckInfo exfiltrationInfo, @JsonProperty("policy_info") ConnectorPolicyInfo policyInfo, @JsonProperty("decision_id") String decisionId, - @JsonProperty("policy_matches") List policyMatches) { + @JsonProperty("policy_matches") List policyMatches, + @JsonProperty("redaction_evaluated") boolean redactionEvaluated) { this.allowed = allowed; this.blockReason = blockReason; this.redactedData = redactedData; @@ -83,13 +94,15 @@ public MCPCheckOutputResponse( this.policyInfo = policyInfo; this.decisionId = decisionId; this.policyMatches = policyMatches; + this.redactionEvaluated = redactionEvaluated; } /** * Source-compat overload. Callers that build {@code MCPCheckOutputResponse} instances locally * with the v6.0.0 6-argument shape continue to compile — {@code redactedMessage}, {@code - * decisionId}, and {@code policyMatches} default to {@code null}. Server-side responses always - * go through the {@code @JsonCreator} 9-arg constructor regardless. + * decisionId}, {@code policyMatches}, and {@code redactionEvaluated} default to {@code null} / + * {@code false}. Server-side responses always go through the {@code @JsonCreator} constructor + * regardless. */ public MCPCheckOutputResponse( boolean allowed, @@ -107,7 +120,35 @@ public MCPCheckOutputResponse( exfiltrationInfo, policyInfo, null, - null); + null, + false); + } + + /** + * Source-compat overload preserving the v7.1.0 9-argument shape — {@code redactionEvaluated} + * defaults to {@code false}. + */ + public MCPCheckOutputResponse( + boolean allowed, + String blockReason, + Object redactedData, + String redactedMessage, + int policiesEvaluated, + ExfiltrationCheckInfo exfiltrationInfo, + ConnectorPolicyInfo policyInfo, + String decisionId, + List policyMatches) { + this( + allowed, + blockReason, + redactedData, + redactedMessage, + policiesEvaluated, + exfiltrationInfo, + policyInfo, + decisionId, + policyMatches, + false); } /** Returns whether the output data is allowed by policies. */ @@ -154,20 +195,26 @@ public ConnectorPolicyInfo getPolicyInfo() { } /** - * Returns the audit correlator for this policy decision (Plugin Batch 1, v7.1.0+). Null on - * older platforms. + * Returns the audit correlator for this policy decision (Plugin Batch 1, v7.1.0+). Null on older + * platforms. */ public String getDecisionId() { return decisionId; } - /** - * Returns the per-policy explainability records (ADR-043, v7.1.0+). Null on older platforms. - */ + /** Returns the per-policy explainability records (ADR-043, v7.1.0+). Null on older platforms. */ public List getPolicyMatches() { return policyMatches; } + /** + * Returns whether the response-phase redaction detector actually ran (ADR-056 / #2563). A PEP + * MUST fail closed when this is false. + */ + public boolean isRedactionEvaluated() { + return redactionEvaluated; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -175,6 +222,7 @@ public boolean equals(Object o) { MCPCheckOutputResponse that = (MCPCheckOutputResponse) o; return allowed == that.allowed && policiesEvaluated == that.policiesEvaluated + && redactionEvaluated == that.redactionEvaluated && Objects.equals(blockReason, that.blockReason) && Objects.equals(redactedData, that.redactedData) && Objects.equals(redactedMessage, that.redactedMessage) @@ -195,7 +243,8 @@ public int hashCode() { exfiltrationInfo, policyInfo, decisionId, - policyMatches); + policyMatches, + redactionEvaluated); } @Override @@ -217,6 +266,8 @@ public String toString() { + '\'' + ", policyMatches=" + policyMatches + + ", redactionEvaluated=" + + redactionEvaluated + '}'; } } diff --git a/src/main/java/com/getaxonflow/sdk/types/Obligation.java b/src/main/java/com/getaxonflow/sdk/types/Obligation.java new file mode 100644 index 0000000..30296e3 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/Obligation.java @@ -0,0 +1,106 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Objects; + +/** + * A self-describing, engine-fulfillable PEP requirement on an allow verdict (ADR-056, epic #2563). + * + *

Obligations are SELF-DESCRIBING and ENGINE-FULFILLABLE: {@code /decide} is a pure PDP and + * never mutates content, so a {@code redact_pii} obligation is not "go redact this yourself with + * your own patterns" — it is "call the AxonFlow engine endpoint named in {@code fulfillment} to + * obtain engine-redacted content." There is no other blessed way to satisfy it; client-side + * redaction is forbidden. Mirrors the platform {@code DecisionObligation}. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public final class Obligation { + + @JsonProperty("type") + private final String type; + + @JsonProperty("detail") + private final String detail; + + @JsonProperty("fulfillment") + private final ObligationFulfillment fulfillment; + + /** + * Creates an obligation. + * + * @param type the obligation type, e.g. {@code redact_pii} + * @param detail human-readable detail for audit logs (may be null) + * @param fulfillment how a PEP discharges this obligation via the engine (may be null) + */ + @JsonCreator + public Obligation( + @JsonProperty("type") String type, + @JsonProperty("detail") String detail, + @JsonProperty("fulfillment") ObligationFulfillment fulfillment) { + this.type = type; + this.detail = detail; + this.fulfillment = fulfillment; + } + + /** Returns the obligation type, e.g. {@code redact_pii}. */ + public String getType() { + return type; + } + + /** Returns the human-readable detail for audit logs, or null. */ + public String getDetail() { + return detail; + } + + /** Returns the engine fulfillment descriptor, or null when the obligation names no endpoint. */ + public ObligationFulfillment getFulfillment() { + return fulfillment; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Obligation that = (Obligation) o; + return Objects.equals(type, that.type) + && Objects.equals(detail, that.detail) + && Objects.equals(fulfillment, that.fulfillment); + } + + @Override + public int hashCode() { + return Objects.hash(type, detail, fulfillment); + } + + @Override + public String toString() { + return "Obligation{" + + "type='" + + type + + '\'' + + ", detail='" + + detail + + '\'' + + ", fulfillment=" + + fulfillment + + '}'; + } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/ObligationFulfillment.java b/src/main/java/com/getaxonflow/sdk/types/ObligationFulfillment.java new file mode 100644 index 0000000..0af64b1 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/ObligationFulfillment.java @@ -0,0 +1,124 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Objects; + +/** + * Names the engine call a PEP makes to discharge an {@link Obligation} (ADR-056, epic #2563). + * + *

Fulfillment is a property of the contract, not of PEP-author discipline: a conforming PEP + * POSTs the obligation's source content to {@code endpoint} and forwards the engine-redacted + * content the endpoint returns. There is no client-side redaction. + * + *

{@code contentTypes} advertises the mime-types the endpoint's detectors can handle today. The + * contract is content-type-agnostic: a PEP holding content of a type NOT in this list must fail + * closed rather than forward it unredacted. Mirrors the platform {@code ObligationFulfillment}. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ObligationFulfillment { + + @JsonProperty("endpoint") + private final String endpoint; + + @JsonProperty("method") + private final String method; + + @JsonProperty("phase") + private final String phase; + + @JsonProperty("content_types") + private final List contentTypes; + + /** + * Creates a fulfillment descriptor. + * + * @param endpoint the engine path, e.g. {@code /api/v1/mcp/check-input} + * @param method the HTTP method, e.g. {@code POST} + * @param phase {@code "request"} or {@code "response"} + * @param contentTypes mime-types the endpoint can redact today (may be null/empty) + */ + @JsonCreator + public ObligationFulfillment( + @JsonProperty("endpoint") String endpoint, + @JsonProperty("method") String method, + @JsonProperty("phase") String phase, + @JsonProperty("content_types") List contentTypes) { + this.endpoint = endpoint; + this.method = method; + this.phase = phase; + this.contentTypes = contentTypes; + } + + /** Returns the engine path the PEP POSTs content to, e.g. {@code /api/v1/mcp/check-input}. */ + public String getEndpoint() { + return endpoint; + } + + /** Returns the HTTP method, e.g. {@code POST}. */ + public String getMethod() { + return method; + } + + /** Returns the fulfillment phase: {@code "request"} or {@code "response"}. */ + public String getPhase() { + return phase; + } + + /** Returns the mime-types the endpoint can redact today, or null when unspecified. */ + public List getContentTypes() { + return contentTypes; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ObligationFulfillment that = (ObligationFulfillment) o; + return Objects.equals(endpoint, that.endpoint) + && Objects.equals(method, that.method) + && Objects.equals(phase, that.phase) + && Objects.equals(contentTypes, that.contentTypes); + } + + @Override + public int hashCode() { + return Objects.hash(endpoint, method, phase, contentTypes); + } + + @Override + public String toString() { + return "ObligationFulfillment{" + + "endpoint='" + + endpoint + + '\'' + + ", method='" + + method + + '\'' + + ", phase='" + + phase + + '\'' + + ", contentTypes=" + + contentTypes + + '}'; + } +} diff --git a/src/test/java/com/getaxonflow/sdk/PepTest.java b/src/test/java/com/getaxonflow/sdk/PepTest.java new file mode 100644 index 0000000..f34f894 --- /dev/null +++ b/src/test/java/com/getaxonflow/sdk/PepTest.java @@ -0,0 +1,499 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0. + */ +package com.getaxonflow.sdk; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.assertj.core.api.Assertions.*; + +import com.getaxonflow.sdk.exceptions.AuthenticationException; +import com.getaxonflow.sdk.exceptions.ObligationNotFulfillableException; +import com.getaxonflow.sdk.types.DecideRequest; +import com.getaxonflow.sdk.types.DecideResponse; +import com.getaxonflow.sdk.types.DecisionTarget; +import com.getaxonflow.sdk.types.Obligation; +import com.getaxonflow.sdk.types.ObligationFulfillment; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Tests for the Decision Mode PEP: decide → fulfill → forward (ADR-056, epic #2563). + * + *

Pins the wire shape of {@code /api/v1/decide} and {@code /api/v1/mcp/check-input}, and every + * fail-closed branch of {@code fulfillRequest} / {@code decideAndFulfill}. There is NO local + * redaction anywhere in the SDK — fulfillment is always the engine round-trip — so every "redacted" + * assertion below is satisfied only by the stubbed engine response, never by client logic. + */ +@WireMockTest +@DisplayName("Decision Mode PEP (ADR-056 / #2563)") +class PepTest { + + private AxonFlow axonflow; + + @BeforeEach + void setUp(WireMockRuntimeInfo wm) { + axonflow = + AxonFlow.create( + AxonFlowConfig.builder() + .endpoint(wm.getHttpBaseUrl()) + .clientId("org-test") + .clientSecret("license-test") + .build()); + } + + // Builds a /decide allow body carrying a request-phase redact_pii obligation pointing at the + // request-redaction endpoint. + private static String allowWithRedactObligation(String endpoint, String contentTypesJson) { + String fulfillment = + "{\"endpoint\":\"" + + endpoint + + "\",\"method\":\"POST\",\"phase\":\"request\"" + + (contentTypesJson == null ? "" : ",\"content_types\":" + contentTypesJson) + + "}"; + return "{" + + "\"verdict\":\"allow\"," + + "\"decision_id\":\"dec-1\"," + + "\"trace_id\":\"04110a0b50577bbbdda23a00dcbaf6da\"," + + "\"obligations\":[{\"type\":\"redact_pii\",\"fulfillment\":" + + fulfillment + + "}]," + + "\"evaluated_policies\":[\"sys_pii_email\",\"sys_pii_credit_card\"]," + + "\"stage\":\"tool\"," + + "\"expires_at\":\"2026-06-09T05:05:06.8Z\"" + + "}"; + } + + private void stubDecide(String body) { + stubFor(post(urlEqualTo("/api/v1/decide")).willReturn(okJson(body))); + } + + private void stubCheckInput(String body) { + stubFor(post(urlEqualTo("/api/v1/mcp/check-input")).willReturn(okJson(body))); + } + + private static String checkInputResponse( + boolean redacted, String redactedStatement, boolean redactionEvaluated) { + return "{" + + "\"allowed\":true," + + "\"policies_evaluated\":124," + + "\"decision_id\":\"ci-1\"," + + "\"redacted\":" + + redacted + + "," + + (redactedStatement == null ? "" : "\"redacted_statement\":\"" + redactedStatement + "\",") + + "\"redaction_evaluated\":" + + redactionEvaluated + + "}"; + } + + @Nested + @DisplayName("decide()") + class Decide { + + @Test + @DisplayName("parses an allow verdict with a redact_pii obligation") + void parsesAllowWithObligation() { + stubDecide(allowWithRedactObligation("/api/v1/mcp/check-input", "[\"text/plain\"]")); + + DecideResponse r = + axonflow.decide( + DecideRequest.builder("tool", "Email me at a@b.com") + .target(new DecisionTarget("tool", null, null, "send_email")) + .build()); + + assertThat(r.getVerdict()).isEqualTo("allow"); + assertThat(r.getDecisionId()).isEqualTo("dec-1"); + assertThat(r.getTraceId()).isEqualTo("04110a0b50577bbbdda23a00dcbaf6da"); + assertThat(r.getObligations()).hasSize(1); + Obligation ob = r.getObligations().get(0); + assertThat(ob.getType()).isEqualTo("redact_pii"); + assertThat(ob.getFulfillment().getPhase()).isEqualTo("request"); + assertThat(ob.getFulfillment().getEndpoint()).isEqualTo("/api/v1/mcp/check-input"); + assertThat(ob.getFulfillment().getContentTypes()).containsExactly("text/plain"); + assertThat(r.getEvaluatedPolicies()).contains("sys_pii_email"); + assertThat(r.getExpiresAt()).isNotNull(); + } + + @Test + @DisplayName("sends the stage and query on the wire") + void sendsStageAndQuery() { + stubDecide("{\"verdict\":\"allow\",\"obligations\":[]}"); + + axonflow.decide(DecideRequest.builder("llm", "hello").build()); + + verify( + postRequestedFor(urlEqualTo("/api/v1/decide")) + .withRequestBody(matchingJsonPath("$.stage", equalTo("llm"))) + .withRequestBody(matchingJsonPath("$.query", equalTo("hello")))); + } + + @Test + @DisplayName("omits a blank user_token and empty context from the wire") + void omitsEmptyOptionalFields() { + stubDecide("{\"verdict\":\"allow\",\"obligations\":[]}"); + + axonflow.decide(DecideRequest.builder("llm", "hello").userToken("").build()); + + verify( + postRequestedFor(urlEqualTo("/api/v1/decide")) + .withRequestBody(notMatching(".*user_token.*")) + .withRequestBody(notMatching(".*\"context\".*"))); + } + + @Test + @DisplayName("returns a deny verdict in the body (HTTP 200), not as an error") + void denyIsBodyNotError() { + stubDecide("{\"verdict\":\"deny\",\"error\":\"stage is required\",\"obligations\":[]}"); + + DecideResponse r = axonflow.decide(DecideRequest.builder("", "q").build()); + + assertThat(r.getVerdict()).isEqualTo("deny"); + assertThat(r.getError()).contains("stage is required"); + assertThat(r.getObligations()).isEmpty(); + } + + @Test + @DisplayName("raises AuthenticationException on HTTP 401") + void raisesAuthOn401() { + stubFor( + post(urlEqualTo("/api/v1/decide")) + .willReturn(aResponse().withStatus(401).withBody("{\"error\":\"Invalid\"}"))); + + assertThatThrownBy(() -> axonflow.decide(DecideRequest.builder("tool", "q").build())) + .isInstanceOf(AuthenticationException.class); + } + + @Test + @DisplayName("sends HTTP Basic org:license auth") + void sendsBasicAuth() { + stubDecide("{\"verdict\":\"allow\",\"obligations\":[]}"); + + axonflow.decide(DecideRequest.builder("tool", "q").build()); + + verify( + postRequestedFor(urlEqualTo("/api/v1/decide")) + .withBasicAuth( + new com.github.tomakehurst.wiremock.client.BasicCredentials( + "org-test", "license-test"))); + } + } + + @Nested + @DisplayName("fulfillRequest()") + class FulfillRequest { + + @Test + @DisplayName("passthrough: null decision returns the original statement, did_redact=false") + void nullDecisionPassthrough() { + AxonFlow.FulfillResult res = axonflow.fulfillRequest(null, "secret a@b.com"); + assertThat(res.getContent()).isEqualTo("secret a@b.com"); + assertThat(res.didRedact()).isFalse(); + } + + @Test + @DisplayName("passthrough: no obligations returns the original statement") + void noObligationsPassthrough() { + DecideResponse d = + new DecideResponse( + "allow", "d", "t", null, Collections.emptyList(), null, null, null, null); + AxonFlow.FulfillResult res = axonflow.fulfillRequest(d, "secret a@b.com"); + assertThat(res.getContent()).isEqualTo("secret a@b.com"); + assertThat(res.didRedact()).isFalse(); + } + + @Test + @DisplayName("passthrough: a non-redact obligation is ignored") + void nonRedactObligationIgnored() { + DecideResponse d = + new DecideResponse( + "allow", + "d", + "t", + null, + Arrays.asList(new Obligation("log_only", null, null)), + null, + null, + null, + null); + AxonFlow.FulfillResult res = axonflow.fulfillRequest(d, "keep me"); + assertThat(res.getContent()).isEqualTo("keep me"); + assertThat(res.didRedact()).isFalse(); + } + + @Test + @DisplayName("engine-redacts and returns masked content; sends content_type=text/plain") + void engineRedacts() { + stubCheckInput(checkInputResponse(true, "Email jo****om and card 4****1", true)); + DecideResponse d = decisionWithRedactObligation("/api/v1/mcp/check-input", null); + + AxonFlow.FulfillResult res = + axonflow.fulfillRequest(d, "Email john.doe@example.com and card 4111111111111111"); + + assertThat(res.getContent()).isEqualTo("Email jo****om and card 4****1"); + assertThat(res.getContent()).doesNotContain("john.doe@example.com"); + assertThat(res.getContent()).doesNotContain("4111111111111111"); + assertThat(res.didRedact()).isTrue(); + verify( + postRequestedFor(urlEqualTo("/api/v1/mcp/check-input")) + .withRequestBody(matchingJsonPath("$.content_type", equalTo("text/plain"))) + .withRequestBody(matchingJsonPath("$.connector_type", equalTo("gateway")))); + } + + @Test + @DisplayName("engine found nothing: forwards the statement unchanged, did_redact=false") + void engineFoundNothing() { + stubCheckInput(checkInputResponse(false, null, true)); + DecideResponse d = decisionWithRedactObligation("/api/v1/mcp/check-input", null); + + AxonFlow.FulfillResult res = axonflow.fulfillRequest(d, "no pii here"); + + assertThat(res.getContent()).isEqualTo("no pii here"); + assertThat(res.didRedact()).isFalse(); + } + + @Test + @DisplayName("fail-closed: redact_pii with no fulfillment block") + void failClosedNoFulfillment() { + DecideResponse d = + new DecideResponse( + "allow", + "d", + "t", + null, + Arrays.asList(new Obligation("redact_pii", null, null)), + null, + null, + null, + null); + assertThatThrownBy(() -> axonflow.fulfillRequest(d, "a@b.com")) + .isInstanceOf(ObligationNotFulfillableException.class) + .hasMessageContaining("request-phase fulfillment"); + } + + @Test + @DisplayName("fail-closed: redact_pii with a response-phase fulfillment") + void failClosedResponsePhase() { + DecideResponse d = + new DecideResponse( + "allow", + "d", + "t", + null, + Arrays.asList( + new Obligation( + "redact_pii", + null, + new ObligationFulfillment( + "/api/v1/mcp/check-output", "POST", "response", null))), + null, + null, + null, + null); + assertThatThrownBy(() -> axonflow.fulfillRequest(d, "a@b.com")) + .isInstanceOf(ObligationNotFulfillableException.class) + .hasMessageContaining("request-phase fulfillment"); + } + + @Test + @DisplayName("fail-closed: endpoint advertises content types that exclude text/plain") + void failClosedUnadvertisedContentType() { + DecideResponse d = + decisionWithRedactObligation( + "/api/v1/mcp/check-input", Arrays.asList("image/png", "application/pdf")); + assertThatThrownBy(() -> axonflow.fulfillRequest(d, "a@b.com")) + .isInstanceOf(ObligationNotFulfillableException.class) + .hasMessageContaining("text/plain"); + } + + @Test + @DisplayName("fail-closed: foreign endpoint is rejected (no arbitrary URL POST)") + void failClosedForeignEndpoint() { + DecideResponse d = decisionWithRedactObligation("https://evil.example/steal", null); + assertThatThrownBy(() -> axonflow.fulfillRequest(d, "a@b.com")) + .isInstanceOf(ObligationNotFulfillableException.class) + .hasMessageContaining("request-redaction endpoint"); + } + + @Test + @DisplayName("fail-closed: engine returns a non-200") + void failClosedEngineError() { + stubFor( + post(urlEqualTo("/api/v1/mcp/check-input")) + .willReturn(aResponse().withStatus(500).withBody("{\"error\":\"boom\"}"))); + DecideResponse d = decisionWithRedactObligation("/api/v1/mcp/check-input", null); + assertThatThrownBy(() -> axonflow.fulfillRequest(d, "a@b.com")) + .isInstanceOf(ObligationNotFulfillableException.class) + .hasMessageContaining("engine call failed"); + } + + @Test + @DisplayName("fail-closed: redaction_evaluated=false (redactor did not run)") + void failClosedRedactionNotEvaluated() { + stubCheckInput(checkInputResponse(false, null, false)); + DecideResponse d = decisionWithRedactObligation("/api/v1/mcp/check-input", null); + assertThatThrownBy(() -> axonflow.fulfillRequest(d, "a@b.com")) + .isInstanceOf(ObligationNotFulfillableException.class) + .hasMessageContaining("redactor did not run"); + } + + @Test + @DisplayName("fail-closed: redaction_evaluated absent defaults false → fails closed") + void failClosedRedactionEvaluatedAbsent() { + // No redaction_evaluated key at all — Jackson defaults the primitive to false. + stubCheckInput("{\"allowed\":true,\"policies_evaluated\":1,\"redacted\":false}"); + DecideResponse d = decisionWithRedactObligation("/api/v1/mcp/check-input", null); + assertThatThrownBy(() -> axonflow.fulfillRequest(d, "a@b.com")) + .isInstanceOf(ObligationNotFulfillableException.class) + .hasMessageContaining("redactor did not run"); + } + + @Test + @DisplayName("accepts an absolute fulfillment URL whose path is the request-redaction path") + void acceptsAbsoluteUrlWithMatchingPath(WireMockRuntimeInfo wm) { + stubCheckInput(checkInputResponse(true, "masked", true)); + DecideResponse d = + decisionWithRedactObligation(wm.getHttpBaseUrl() + "/api/v1/mcp/check-input", null); + AxonFlow.FulfillResult res = axonflow.fulfillRequest(d, "secret"); + assertThat(res.getContent()).isEqualTo("masked"); + assertThat(res.didRedact()).isTrue(); + } + } + + @Nested + @DisplayName("decideAndFulfill()") + class DecideAndFulfill { + + @Test + @DisplayName("allow: returns engine-redacted content") + void allowRedacts() { + stubDecide(allowWithRedactObligation("/api/v1/mcp/check-input", "[\"text/plain\"]")); + stubCheckInput(checkInputResponse(true, "Email jo****om", true)); + + AxonFlow.DecideAndFulfillResult res = + axonflow.decideAndFulfill( + DecideRequest.builder("tool", "Email john.doe@example.com").build()); + + assertThat(res.getVerdict()).isEqualTo("allow"); + assertThat(res.getContent()).isEqualTo("Email jo****om"); + assertThat(res.getContent()).doesNotContain("john.doe@example.com"); + assertThat(res.getDecision().getDecisionId()).isEqualTo("dec-1"); + } + + @Test + @DisplayName("deny: returns the original query and the deny verdict, no fulfill call") + void denyReturnsOriginal() { + stubDecide("{\"verdict\":\"deny\",\"obligations\":[],\"error\":\"blocked\"}"); + + AxonFlow.DecideAndFulfillResult res = + axonflow.decideAndFulfill(DecideRequest.builder("tool", "original query").build()); + + assertThat(res.getVerdict()).isEqualTo("deny"); + assertThat(res.getContent()).isEqualTo("original query"); + verify(0, postRequestedFor(urlEqualTo("/api/v1/mcp/check-input"))); + } + + @Test + @DisplayName("unfulfillable allow: throws — caller has no content to forward") + void unfulfillableThrows() { + stubDecide(allowWithRedactObligation("https://evil.example/steal", null)); + + assertThatThrownBy( + () -> + axonflow.decideAndFulfill( + DecideRequest.builder("tool", "leak john.doe@example.com").build())) + .isInstanceOf(ObligationNotFulfillableException.class); + } + } + + @Nested + @DisplayName("Pep.hasRequestRedaction()") + class HasRequestRedaction { + + @Test + @DisplayName("true when a redact_pii request-phase obligation is present") + void trueForRequestPhase() { + List obs = + Arrays.asList( + new Obligation( + "redact_pii", + null, + new ObligationFulfillment("/api/v1/mcp/check-input", "POST", "request", null))); + assertThat(Pep.hasRequestRedaction(obs)).isTrue(); + } + + @Test + @DisplayName("false for response-phase, non-redact, no-fulfillment, null, and empty") + void falseOtherwise() { + assertThat(Pep.hasRequestRedaction(null)).isFalse(); + assertThat(Pep.hasRequestRedaction(Collections.emptyList())).isFalse(); + assertThat(Pep.hasRequestRedaction(Arrays.asList(new Obligation("redact_pii", null, null)))) + .isFalse(); + assertThat( + Pep.hasRequestRedaction( + Arrays.asList( + new Obligation( + "redact_pii", + null, + new ObligationFulfillment( + "/api/v1/mcp/check-output", "POST", "response", null))))) + .isFalse(); + assertThat( + Pep.hasRequestRedaction( + Arrays.asList( + new Obligation( + "log_only", + null, + new ObligationFulfillment( + "/api/v1/mcp/check-input", "POST", "request", null))))) + .isFalse(); + } + } + + @Nested + @DisplayName("Pep.endpointPathMatches()") + class EndpointPathMatches { + @Test + @DisplayName("matches exact path, absolute URL path, and rejects mismatches") + void matches() { + assertThat(Pep.endpointPathMatches("/api/v1/mcp/check-input", "/api/v1/mcp/check-input")) + .isTrue(); + assertThat( + Pep.endpointPathMatches( + "https://pdp:8443/api/v1/mcp/check-input?x=1", "/api/v1/mcp/check-input")) + .isTrue(); + assertThat(Pep.endpointPathMatches("/api/v1/mcp/check-output", "/api/v1/mcp/check-input")) + .isFalse(); + assertThat(Pep.endpointPathMatches("", "/api/v1/mcp/check-input")).isFalse(); + assertThat(Pep.endpointPathMatches(null, "/api/v1/mcp/check-input")).isFalse(); + assertThat(Pep.endpointPathMatches("https://evil.example", "/api/v1/mcp/check-input")) + .isFalse(); + } + } + + private static DecideResponse decisionWithRedactObligation( + String endpoint, List contentTypes) { + return new DecideResponse( + "allow", + "dec-1", + "trace-1", + null, + Arrays.asList( + new Obligation( + "redact_pii", + null, + new ObligationFulfillment(endpoint, "POST", "request", contentTypes))), + Arrays.asList("sys_pii_email"), + "tool", + null, + null); + } +} diff --git a/src/test/java/com/getaxonflow/sdk/types/PepTypesTest.java b/src/test/java/com/getaxonflow/sdk/types/PepTypesTest.java new file mode 100644 index 0000000..d3751c8 --- /dev/null +++ b/src/test/java/com/getaxonflow/sdk/types/PepTypesTest.java @@ -0,0 +1,232 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0. + */ +package com.getaxonflow.sdk.types; + +import static org.assertj.core.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Wire-shape + value-semantics tests for the Decision Mode PEP DTOs (ADR-056 / #2563). Pins the + * snake_case JSON field names, the request/response (de)serialization, and the + * equals/hashCode/toString contracts of the new types. + */ +@DisplayName("PEP DTO wire shape + value semantics (#2563)") +class PepTypesTest { + + private static final ObjectMapper MAPPER = + new ObjectMapper().registerModule(new JavaTimeModule()); + + @Test + @DisplayName("DecideRequest serializes snake_case and omits blank user_token + empty context") + void decideRequestWireShape() throws Exception { + DecideRequest req = + DecideRequest.builder("tool", "send to a@b.com") + .callerIdentity(new DecisionCallerIdentity("gw-1", "org-1", "tenant-1")) + .target(new DecisionTarget("tool", null, null, "send_email")) + .userToken("") + .context(new HashMap<>()) + .build(); + + String json = MAPPER.writeValueAsString(req); + + assertThat(json).contains("\"stage\":\"tool\""); + assertThat(json).contains("\"caller_identity\""); + assertThat(json).contains("\"gateway_id\":\"gw-1\""); + assertThat(json).contains("\"org_id\":\"org-1\""); + assertThat(json).contains("\"tenant_id\":\"tenant-1\""); + assertThat(json).contains("\"tool\":\"send_email\""); + assertThat(json).doesNotContain("user_token"); + assertThat(json).doesNotContain("\"context\""); + + // toString masks the token and exposes the rest. + assertThat(req.toString()).contains("stage='tool'"); + assertThat(req.getStage()).isEqualTo("tool"); + assertThat(req.getQuery()).isEqualTo("send to a@b.com"); + assertThat(req.getUserToken()).isNull(); + assertThat(req.getContext()).isNull(); + assertThat(req.getTarget().getTool()).isEqualTo("send_email"); + assertThat(req.getCallerIdentity().getOrgId()).isEqualTo("org-1"); + } + + @Test + @DisplayName("DecideRequest keeps a non-empty user_token + context") + void decideRequestKeepsPopulatedOptionals() throws Exception { + Map ctx = new HashMap<>(); + ctx.put("k", "v"); + DecideRequest req = DecideRequest.builder("llm", "q").userToken("jwt-123").context(ctx).build(); + String json = MAPPER.writeValueAsString(req); + assertThat(json).contains("\"user_token\":\"jwt-123\""); + assertThat(json).contains("\"context\":{\"k\":\"v\"}"); + assertThat(req.getUserToken()).isEqualTo("jwt-123"); + assertThat(req.getContext()).containsEntry("k", "v"); + assertThat(req.toString()).contains(""); + } + + @Test + @DisplayName("DecideResponse parses the canonical /decide allow body") + void decideResponseParse() throws Exception { + String body = + "{" + + "\"verdict\":\"allow\",\"decision_id\":\"66e7c30a\"," + + "\"trace_id\":\"04110a0b50577bbbdda23a00dcbaf6da\"," + + "\"reasons\":[\"r1\"]," + + "\"obligations\":[{\"type\":\"redact_pii\",\"detail\":\"mask\"," + + "\"fulfillment\":{\"endpoint\":\"/api/v1/mcp/check-input\",\"method\":\"POST\"," + + "\"phase\":\"request\",\"content_types\":[\"text/plain\"]}}]," + + "\"evaluated_policies\":[\"sys_pii_email\"],\"stage\":\"tool\"," + + "\"expires_at\":\"2026-06-09T05:05:06.8Z\",\"future\":\"ignored\"}"; + + DecideResponse r = MAPPER.readValue(body, DecideResponse.class); + + assertThat(r.getVerdict()).isEqualTo("allow"); + assertThat(r.getDecisionId()).isEqualTo("66e7c30a"); + assertThat(r.getTraceId()).isEqualTo("04110a0b50577bbbdda23a00dcbaf6da"); + assertThat(r.getReasons()).containsExactly("r1"); + assertThat(r.getObligations()).hasSize(1); + Obligation ob = r.getObligations().get(0); + assertThat(ob.getType()).isEqualTo("redact_pii"); + assertThat(ob.getDetail()).isEqualTo("mask"); + ObligationFulfillment f = ob.getFulfillment(); + assertThat(f.getEndpoint()).isEqualTo("/api/v1/mcp/check-input"); + assertThat(f.getMethod()).isEqualTo("POST"); + assertThat(f.getPhase()).isEqualTo("request"); + assertThat(f.getContentTypes()).containsExactly("text/plain"); + assertThat(r.getEvaluatedPolicies()).containsExactly("sys_pii_email"); + assertThat(r.getStage()).isEqualTo("tool"); + assertThat(r.getExpiresAt()).isNotNull(); + assertThat(r.getError()).isNull(); + assertThat(r.toString()).contains("verdict='allow'"); + } + + @Test + @DisplayName("DecideResponse normalizes null obligations + evaluated_policies to empty lists") + void decideResponseNormalizesNulls() throws Exception { + DecideResponse r = + MAPPER.readValue("{\"verdict\":\"deny\",\"error\":\"bad\"}", DecideResponse.class); + assertThat(r.getObligations()).isEmpty(); + assertThat(r.getEvaluatedPolicies()).isEmpty(); + assertThat(r.getError()).isEqualTo("bad"); + } + + @Test + @DisplayName("DecideResponse obligations list is unmodifiable") + void obligationsUnmodifiable() { + DecideResponse r = + new DecideResponse( + "allow", + "d", + "t", + null, + Arrays.asList(new Obligation("redact_pii", null, null)), + Arrays.asList("p"), + "tool", + null, + null); + assertThatThrownBy(() -> r.getObligations().add(new Obligation("x", null, null))) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + @DisplayName("MCPCheckInputRequest serializes content_type") + void checkInputRequestContentType() throws Exception { + MCPCheckInputRequest req = + new MCPCheckInputRequest("gateway", "secret", null, "execute", "text/plain"); + String json = MAPPER.writeValueAsString(req); + assertThat(json).contains("\"content_type\":\"text/plain\""); + assertThat(req.getContentType()).isEqualTo("text/plain"); + // Null content type is omitted (NON_NULL). + MCPCheckInputRequest noCt = new MCPCheckInputRequest("gateway", "x"); + assertThat(MAPPER.writeValueAsString(noCt)).doesNotContain("content_type"); + assertThat(noCt.getContentType()).isNull(); + } + + @Test + @DisplayName("MCPCheckInputResponse parses redacted/redacted_statement/redaction_evaluated") + void checkInputResponseRedactionFields() throws Exception { + String body = + "{\"allowed\":true,\"policies_evaluated\":124,\"redacted\":true," + + "\"redacted_statement\":\"Email jo****om\",\"redaction_evaluated\":true}"; + MCPCheckInputResponse r = MAPPER.readValue(body, MCPCheckInputResponse.class); + assertThat(r.isRedacted()).isTrue(); + assertThat(r.getRedactedStatement()).isEqualTo("Email jo****om"); + assertThat(r.isRedactionEvaluated()).isTrue(); + assertThat(r.toString()).contains("redactionEvaluated=true"); + } + + @Test + @DisplayName("MCPCheckOutputResponse parses redaction_evaluated; default is false") + void checkOutputResponseRedactionEvaluated() throws Exception { + MCPCheckOutputResponse r = + MAPPER.readValue( + "{\"allowed\":true,\"redaction_evaluated\":true}", MCPCheckOutputResponse.class); + assertThat(r.isRedactionEvaluated()).isTrue(); + MCPCheckOutputResponse def = + MAPPER.readValue("{\"allowed\":true}", MCPCheckOutputResponse.class); + assertThat(def.isRedactionEvaluated()).isFalse(); + assertThat(r.toString()).contains("redactionEvaluated=true"); + } + + @Test + @DisplayName("value semantics: equals / hashCode / getters on all PEP DTOs") + void valueSemantics() { + DecisionCallerIdentity ci1 = new DecisionCallerIdentity("g", "o", "t"); + DecisionCallerIdentity ci2 = new DecisionCallerIdentity("g", "o", "t"); + DecisionCallerIdentity ci3 = new DecisionCallerIdentity("g", "o", "other"); + assertThat(ci1).isEqualTo(ci2).hasSameHashCodeAs(ci2).isNotEqualTo(ci3).isNotEqualTo(null); + assertThat(ci1.getGatewayId()).isEqualTo("g"); + assertThat(ci1.getTenantId()).isEqualTo("t"); + assertThat(ci1.toString()).contains("orgId='o'"); + + DecisionTarget t1 = new DecisionTarget("llm", "gpt-4o", "openai", null); + DecisionTarget t2 = new DecisionTarget("llm", "gpt-4o", "openai", null); + assertThat(t1) + .isEqualTo(t2) + .hasSameHashCodeAs(t2) + .isNotEqualTo(new DecisionTarget(null, null, null, null)); + assertThat(t1.getType()).isEqualTo("llm"); + assertThat(t1.getModel()).isEqualTo("gpt-4o"); + assertThat(t1.getProvider()).isEqualTo("openai"); + assertThat(t1.toString()).contains("provider='openai'"); + + ObligationFulfillment f1 = + new ObligationFulfillment( + "/api/v1/mcp/check-input", "POST", "request", List.of("text/plain")); + ObligationFulfillment f2 = + new ObligationFulfillment( + "/api/v1/mcp/check-input", "POST", "request", List.of("text/plain")); + assertThat(f1).isEqualTo(f2).hasSameHashCodeAs(f2).isNotEqualTo("nope"); + assertThat(f1.toString()).contains("phase='request'"); + + Obligation o1 = new Obligation("redact_pii", "d", f1); + Obligation o2 = new Obligation("redact_pii", "d", f2); + Obligation o3 = new Obligation("log_only", null, null); + assertThat(o1).isEqualTo(o2).hasSameHashCodeAs(o2).isNotEqualTo(o3); + assertThat(o1.getType()).isEqualTo("redact_pii"); + assertThat(o1.getDetail()).isEqualTo("d"); + assertThat(o1.getFulfillment()).isEqualTo(f1); + assertThat(o1.toString()).contains("type='redact_pii'"); + + DecideRequest req1 = DecideRequest.builder("tool", "q").build(); + DecideRequest req2 = DecideRequest.builder("tool", "q").build(); + assertThat(req1).isEqualTo(req2).hasSameHashCodeAs(req2); + + DecideResponse r1 = + new DecideResponse("allow", "d", "t", null, List.of(o1), List.of("p"), "tool", null, null); + DecideResponse r2 = + new DecideResponse("allow", "d", "t", null, List.of(o2), List.of("p"), "tool", null, null); + assertThat(r1).isEqualTo(r2).hasSameHashCodeAs(r2); + assertThat(r1) + .isNotEqualTo(new DecideResponse("deny", null, null, null, null, null, null, null, null)); + } +} diff --git a/tests/fixtures/wire-shape-baseline.json b/tests/fixtures/wire-shape-baseline.json index 65d9165..c55bf83 100644 --- a/tests/fixtures/wire-shape-baseline.json +++ b/tests/fixtures/wire-shape-baseline.json @@ -366,7 +366,9 @@ ] }, "MCPCheckInputRequest": { - "sdk_only": [], + "sdk_only": [ + "content_type" + ], "spec_only": [ "client_id", "tenant_id", @@ -375,6 +377,14 @@ "user_token" ] }, + "MCPCheckInputResponse": { + "sdk_only": [ + "redacted", + "redacted_statement", + "redaction_evaluated" + ], + "spec_only": [] + }, "MCPCheckOutputRequest": { "sdk_only": [], "spec_only": [ @@ -384,6 +394,12 @@ "user_token" ] }, + "MCPCheckOutputResponse": { + "sdk_only": [ + "redaction_evaluated" + ], + "spec_only": [] + }, "MarkStepCompletedRequest": { "sdk_only": [ "metadata" From 927854102e9e86c3f56e0a1db45356e8fc96f39d Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Tue, 9 Jun 2026 09:50:18 +0200 Subject: [PATCH 2/2] fix(pep): fail closed on redacted=true with no redacted_statement (#2571) R3 parity follow-up: fulfillRequest now throws ObligationNotFulfillableException on a self-contradictory engine response (redacted=true but null/empty redacted_statement) instead of forwarding the unredacted original. Adds a unit test. Brings Java to parity with the same hardening applied to Python/Go/TS/Rust in this pass. Signed-off-by: Saurabh Jain --- src/main/java/com/getaxonflow/sdk/AxonFlow.java | 12 +++++++++--- src/test/java/com/getaxonflow/sdk/PepTest.java | 12 ++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index 459f839..2ef116f 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -2682,9 +2682,15 @@ private String fulfillViaCheckInput(String statement) { throw new ObligationNotFulfillableException( "engine reported the redactor did not run (redaction disabled)"); } - if (result.isRedacted() - && result.getRedactedStatement() != null - && !result.getRedactedStatement().isEmpty()) { + if (result.isRedacted()) { + // FAIL CLOSED on a self-contradictory engine response: redacted=true with + // no (or empty) redacted_statement means the engine claims it masked + // something but gave us nothing to forward — never fall back to the + // unredacted original. + if (result.getRedactedStatement() == null || result.getRedactedStatement().isEmpty()) { + throw new ObligationNotFulfillableException( + "engine reported redacted=true but returned no redacted_statement"); + } return result.getRedactedStatement(); } // Redactor ran and found nothing to mask — forward unchanged. diff --git a/src/test/java/com/getaxonflow/sdk/PepTest.java b/src/test/java/com/getaxonflow/sdk/PepTest.java index f34f894..bbe6d46 100644 --- a/src/test/java/com/getaxonflow/sdk/PepTest.java +++ b/src/test/java/com/getaxonflow/sdk/PepTest.java @@ -345,6 +345,18 @@ void failClosedRedactionNotEvaluated() { .hasMessageContaining("redactor did not run"); } + @Test + @DisplayName("fail-closed: redacted=true but no redacted_statement (self-contradictory)") + void failClosedRedactedTrueNoStatement() { + // Engine claims it masked something but returned nothing to forward — + // must fail closed, never fall back to the unredacted original. + stubCheckInput(checkInputResponse(true, null, true)); + DecideResponse d = decisionWithRedactObligation("/api/v1/mcp/check-input", null); + assertThatThrownBy(() -> axonflow.fulfillRequest(d, "secret a@b.com")) + .isInstanceOf(ObligationNotFulfillableException.class) + .hasMessageContaining("no redacted_statement"); + } + @Test @DisplayName("fail-closed: redaction_evaluated absent defaults false → fails closed") void failClosedRedactionEvaluatedAbsent() {