Skip to content

Commit 211750e

Browse files
feat(pep): decide -> fulfill -> forward Decision Mode PEP (#2571) (#191)
* 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 <saurabh.jain@getaxonflow.com> * 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 <saurabh.jain@getaxonflow.com> --------- Signed-off-by: Saurabh Jain <saurabh.jain@getaxonflow.com>
1 parent 2e135b8 commit 211750e

21 files changed

Lines changed: 2626 additions & 138 deletions

CHANGELOG.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,66 @@ All notable changes to the AxonFlow Java SDK will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [8.5.0] - 2026-06-09 — Decision Mode PEP: decide → fulfill → forward
9+
10+
Adds the SDK analog of the platform PEP client (`platform/shared/pep`, ADR-056,
11+
epic #2563). A Policy Enforcement Point now follows one path —
12+
**decide → fulfill → forward** — and the SDK makes the engine-fulfillable
13+
obligation contract impossible to misuse: there is **no local redaction path**,
14+
so a `redact_pii` obligation can only be discharged by round-tripping content
15+
through the engine endpoint the obligation names.
16+
17+
This is a minor, additive release (the SDK's semver is decoupled from the
18+
platform's).
19+
20+
### Added
21+
22+
- **`AxonFlow.decide(DecideRequest)`** — the PDP step. `POST /api/v1/decide`
23+
returns a `DecideResponse` whose `getObligations()` is always a (possibly
24+
empty) list of self-describing `Obligation`s. Decision Mode auth is HTTP Basic
25+
(org:license), which the client already sends; wrong/demo credentials are
26+
refused with `AuthenticationException`. A `deny` verdict is returned in the
27+
body (HTTP 200), not as an error. `decideAsync(...)` mirror provided.
28+
- **`AxonFlow.fulfillRequest(DecideResponse, String)`** — discharges every
29+
request-phase `redact_pii` obligation by POSTing the statement to the engine's
30+
`check-input` endpoint and returning the **engine-redacted** statement
31+
(`FulfillResult`: content + `didRedact()`). Fails closed with
32+
`ObligationNotFulfillableException` when an obligation names no request-phase
33+
fulfillment, advertises a content-type the PEP is not holding, names an
34+
endpoint the client will not call, the engine call fails, or the engine reports
35+
`redaction_evaluated=false`. Never redacts locally.
36+
- **`AxonFlow.decideAndFulfill(DecideRequest)`** — the blessed one-call path
37+
(decide, then fulfill any request-phase obligation; `DecideAndFulfillResult`
38+
carries verdict, content, and decision); fail-closed by construction.
39+
`decideAndFulfillAsync(...)` mirror provided.
40+
- **New types**: `DecideRequest` (fluent builder), `DecideResponse`,
41+
`Obligation`, `ObligationFulfillment`, `DecisionCallerIdentity`,
42+
`DecisionTarget`.
43+
- **New exception**: `ObligationNotFulfillableException` (a fail-closed signal,
44+
extends `AxonFlowException`).
45+
- **PEP constants + `Pep.hasRequestRedaction(List<Obligation>)` helper**
46+
(`OBLIGATION_REDACT_PII`, `PHASE_REQUEST`/`PHASE_RESPONSE`,
47+
`CONTENT_TYPE_TEXT`, `VERDICT_ALLOW`/`VERDICT_DENY`/`VERDICT_NEEDS_APPROVAL`,
48+
endpoint-path constants).
49+
- **`redacted` / `redactedStatement` / `redactionEvaluated` on
50+
`MCPCheckInputResponse`** and **`redactionEvaluated` on
51+
`MCPCheckOutputResponse`** — the request-redaction contract fields the agent
52+
emits (ADR-056). A PEP fulfilling an obligation fails closed when
53+
`redactionEvaluated` is false.
54+
- **`contentType` on `MCPCheckInputRequest`** (new 5-arg constructor) and a
55+
`content_type` option on `mcpCheckInput(connectorType, statement, options)`
56+
selects the request-redaction detector (defaults to `text/plain`
57+
server-side).
58+
59+
### Notes
60+
61+
- Wire field names are byte-identical across the Go / Python / TypeScript / Java
62+
SDKs (snake_case on the wire). The new MCP response fields are an acknowledged
63+
SDK superset of the pinned community OpenAPI spec; the wire-shape baseline is
64+
annotated without bumping the pinned spec SHA.
65+
- Existing source-compatible `MCPCheckInputResponse` / `MCPCheckOutputResponse`
66+
constructors are preserved; the new fields default to `false` / `null`.
67+
868
## [8.4.0] - 2026-05-30 — Decision request context + Pasal 56(b) transfer basis
969

1070
Targets AxonFlow platform **v8.5.0**.

examples/explain-decision/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
<dependency>
2626
<groupId>com.getaxonflow</groupId>
2727
<artifactId>axonflow-sdk</artifactId>
28-
<version>8.4.0</version>
28+
<version>8.5.0</version>
2929
</dependency>
3030
</dependencies>
3131

examples/list-decisions/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
<dependency>
2525
<groupId>com.getaxonflow</groupId>
2626
<artifactId>axonflow-sdk</artifactId>
27-
<version>8.4.0</version>
27+
<version>8.5.0</version>
2828
</dependency>
2929
</dependencies>
3030

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>com.getaxonflow</groupId>
88
<artifactId>axonflow-sdk</artifactId>
9-
<version>8.4.0</version>
9+
<version>8.5.0</version>
1010
<packaging>jar</packaging>
1111

1212
<name>AxonFlow Java SDK</name>
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* runtime-e2e/decide_fulfill_obligation/DecideFulfillObligationTest.java
3+
*
4+
* Real-wire test of the Decision Mode PEP surface (ADR-056, epic #2563,
5+
* tracking #2571) against a running AxonFlow enterprise agent.
6+
*
7+
* Proves, with NO mocks, that the SDK can run the decide -> fulfill -> forward
8+
* path end-to-end:
9+
*
10+
* 1. decide() on a PII-bearing query returns an allow verdict carrying a
11+
* request-phase redact_pii obligation whose fulfillment names the
12+
* request-redaction engine endpoint.
13+
* 2. fulfillRequest() discharges that obligation through the engine and
14+
* returns engine-masked content in which neither the email
15+
* (john.doe@example.com) nor the card (4111111111111111) survives, and
16+
* the content differs from the original. (No local redaction exists in
17+
* the SDK — only the engine can produce this.)
18+
* 3. decideAndFulfill() yields the same masked content in one call.
19+
* 4. Demo credentials (demo-org / demo-license-not-real) are refused with an
20+
* AuthenticationException (HTTP 401).
21+
*
22+
* Run:
23+
* source /tmp/axonflow-e2e-env.sh
24+
* mvn -q -DskipTests package
25+
* mvn -q -DskipTests dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt
26+
* SDK_JAR=$(ls target/axonflow-sdk-*.jar | grep -v sources | grep -v javadoc | head -1)
27+
* java -cp "$SDK_JAR:$(cat /tmp/cp.txt)" \
28+
* runtime-e2e/decide_fulfill_obligation/DecideFulfillObligationTest.java
29+
*/
30+
import com.getaxonflow.sdk.AxonFlow;
31+
import com.getaxonflow.sdk.AxonFlowConfig;
32+
import com.getaxonflow.sdk.Pep;
33+
import com.getaxonflow.sdk.exceptions.AuthenticationException;
34+
import com.getaxonflow.sdk.types.DecideRequest;
35+
import com.getaxonflow.sdk.types.DecideResponse;
36+
import com.getaxonflow.sdk.types.DecisionTarget;
37+
38+
public class DecideFulfillObligationTest {
39+
40+
static final String EMAIL = "john.doe@example.com";
41+
static final String CARD = "4111111111111111";
42+
static final String QUERY = "Send the receipt to " + EMAIL + " and charge card " + CARD;
43+
44+
static void fail(String msg) {
45+
System.err.println("FAIL: " + msg);
46+
System.exit(1);
47+
}
48+
49+
static void check(boolean cond, String msg) {
50+
if (!cond) {
51+
fail(msg);
52+
}
53+
}
54+
55+
public static void main(String[] args) {
56+
String endpoint = System.getenv().getOrDefault("AXONFLOW_ENDPOINT", "http://localhost:8080");
57+
String clientId = System.getenv("AXONFLOW_CLIENT_ID");
58+
String clientSecret = System.getenv("AXONFLOW_CLIENT_SECRET");
59+
String tenantId = System.getenv("AXONFLOW_TENANT_ID");
60+
String userToken = System.getenv("AXONFLOW_USER_TOKEN");
61+
if (clientId == null || clientSecret == null) {
62+
fail("AXONFLOW_CLIENT_ID / AXONFLOW_CLIENT_SECRET unset — source /tmp/axonflow-e2e-env.sh");
63+
}
64+
65+
AxonFlow client =
66+
AxonFlow.create(
67+
AxonFlowConfig.builder()
68+
.endpoint(endpoint)
69+
.clientId(clientId)
70+
.clientSecret(clientSecret)
71+
.build());
72+
73+
DecideRequest req =
74+
DecideRequest.builder("tool", QUERY)
75+
.target(new DecisionTarget("tool", null, null, "send_receipt"))
76+
.userToken(userToken)
77+
.build();
78+
79+
// 1. decide -> allow + request-phase redact_pii obligation.
80+
DecideResponse decision = client.decide(req);
81+
System.out.println(
82+
"decide -> verdict="
83+
+ decision.getVerdict()
84+
+ " decision_id="
85+
+ decision.getDecisionId()
86+
+ " obligations="
87+
+ decision.getObligations().size()
88+
+ " evaluated_policies="
89+
+ decision.getEvaluatedPolicies());
90+
check(Pep.VERDICT_ALLOW.equals(decision.getVerdict()), "expected allow, got " + decision.getVerdict());
91+
check(
92+
Pep.hasRequestRedaction(decision.getObligations()),
93+
"expected a request-phase redact_pii obligation, got " + decision.getObligations());
94+
System.out.println("PASS step 1: decide returned allow + redact_pii request-phase obligation");
95+
96+
// 2. fulfillRequest -> engine-masked content; PII must NOT survive.
97+
AxonFlow.FulfillResult fr = client.fulfillRequest(decision, QUERY);
98+
System.out.println("fulfillRequest -> didRedact=" + fr.didRedact() + " content=" + fr.getContent());
99+
assertMasked(fr.getContent());
100+
check(fr.didRedact(), "expected the engine to have changed the content (didRedact=true)");
101+
System.out.println("PASS step 2: fulfillRequest masked email + card via the engine (no local redaction)");
102+
103+
// 3. decideAndFulfill -> same masked content in one call.
104+
AxonFlow.DecideAndFulfillResult daf = client.decideAndFulfill(req);
105+
System.out.println(
106+
"decideAndFulfill -> verdict=" + daf.getVerdict() + " content=" + daf.getContent());
107+
check(Pep.VERDICT_ALLOW.equals(daf.getVerdict()), "decideAndFulfill verdict=" + daf.getVerdict());
108+
assertMasked(daf.getContent());
109+
System.out.println("PASS step 3: decideAndFulfill returned engine-masked content in one call");
110+
111+
// 4. Demo credentials are refused with 401.
112+
AxonFlow demo =
113+
AxonFlow.create(
114+
AxonFlowConfig.builder()
115+
.endpoint(endpoint)
116+
.clientId("demo-org")
117+
.clientSecret("demo-license-not-real")
118+
.build());
119+
try {
120+
demo.decide(DecideRequest.builder("tool", "ping").build());
121+
fail("expected demo credentials to be refused with AuthenticationException");
122+
} catch (AuthenticationException e) {
123+
System.out.println("PASS step 4: demo credentials refused -> AuthenticationException: " + e.getMessage());
124+
}
125+
126+
System.out.println("ALL PASS: decide -> fulfill -> forward verified through the SDK against the live agent");
127+
}
128+
129+
static void assertMasked(String content) {
130+
check(content != null, "content is null");
131+
check(!content.contains(EMAIL), "email '" + EMAIL + "' survived in: " + content);
132+
check(!content.contains(CARD), "card '" + CARD + "' survived in: " + content);
133+
check(!content.equals(QUERY), "content equals the original (no redaction happened): " + content);
134+
}
135+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# decide_fulfill_obligation (v8.5.0 — Decision Mode PEP, #2563 / #2571)
2+
3+
Real-stack proof that the SDK runs the Decision Mode PEP path
4+
**decide → fulfill → forward** against a live AxonFlow enterprise agent, with
5+
NO mocks and NO local redaction:
6+
7+
1. **`decide()`** on the PII-bearing query
8+
`"Send the receipt to john.doe@example.com and charge card 4111111111111111"`
9+
returns an `allow` verdict carrying a request-phase `redact_pii` obligation
10+
whose fulfillment names the request-redaction engine endpoint.
11+
2. **`fulfillRequest()`** discharges that obligation through the engine and
12+
returns engine-masked content in which neither `john.doe@example.com` nor
13+
`4111111111111111` survives, and the content differs from the original. The
14+
SDK has no local redaction path — only the engine can produce this.
15+
3. **`decideAndFulfill()`** yields the same masked content in one call.
16+
4. **Demo credentials** (`demo-org` / `demo-license-not-real`) are refused with
17+
an `AuthenticationException` (HTTP 401).
18+
19+
## Run
20+
21+
```
22+
source /tmp/axonflow-e2e-env.sh # AXONFLOW_CLIENT_ID / _SECRET / _TENANT_ID / _USER_TOKEN
23+
mvn -q -DskipTests package
24+
mvn -q -DskipTests dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt
25+
SDK_JAR=$(ls target/axonflow-sdk-*.jar | grep -v sources | grep -v javadoc | head -1)
26+
java -cp "$SDK_JAR:$(cat /tmp/cp.txt)" \
27+
runtime-e2e/decide_fulfill_obligation/DecideFulfillObligationTest.java
28+
```
29+
30+
Exits non-zero (and prints `FAIL: ...`) if any step fails — e.g. if the PII
31+
survives fulfillment or demo credentials are not refused.

0 commit comments

Comments
 (0)