Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,35 @@ 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.4.0] - 2026-05-30 — Decision request context + Pasal 56(b) transfer basis

Targets AxonFlow platform **v8.5.0**.

### Added

- **`context` field on `DecisionSummary` and `DecisionExplanation`** —
`Map<String, String>` (nullable). Surfaces the sanitized request context a PEP
attaches to a Decision Mode call (canonical `lower_snake_case` keys such as
`x_ai_agent`, `x_session_id`, `x_leader_identity`, and `x-bukuwarung-*`),
persisted by the platform at the audit row's `policy_details->'context'`.
`listDecisions()` returns the platform-truncated summary (5 keys);
`explainDecision()` returns the full map. `null` for pre-v8.4.0 audit rows.
- **`contextTruncated` accessor on `DecisionExplanation`** (`boolean`,
`isContextTruncated()`). True when the agent dropped surplus context keys at
write time.
- **`AuditLogEntry.TRANSFER_BASIS_*` constants** (`TRANSFER_BASIS_ADEQUACY`,
`TRANSFER_BASIS_SAFEGUARDS`, `TRANSFER_BASIS_PASAL_56B_DPA` = `"pasal_56b_dpa"`,
`TRANSFER_BASIS_CONSENT`). Type-safe access to the Indonesia UU PDP Pasal 56
legal bases.

### Changed

- **`AuditLogEntry.getTransferBasis()` documentation** now records `pasal_56b_dpa`
(Pasal 56(b) explicit DPA tag) alongside `adequacy`, `safeguards`, and
`consent`. The field stays a `String` (surfaced verbatim), so existing code
reading `safeguards` is unaffected and the SDK never rejects a value a newer
platform may add.

## [8.3.0] - 2026-05-27 — Indonesia PII category + cross-border audit fields

### Added
Expand Down
2 changes: 1 addition & 1 deletion examples/explain-decision/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<dependency>
<groupId>com.getaxonflow</groupId>
<artifactId>axonflow-sdk</artifactId>
<version>8.0.0</version>
<version>8.4.0</version>
</dependency>
</dependencies>

Expand Down
2 changes: 1 addition & 1 deletion examples/list-decisions/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
<dependency>
<groupId>com.getaxonflow</groupId>
<artifactId>axonflow-sdk</artifactId>
<version>8.0.0</version>
<version>8.4.0</version>
</dependency>
</dependencies>

Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>com.getaxonflow</groupId>
<artifactId>axonflow-sdk</artifactId>
<version>8.3.0</version>
<version>8.4.0</version>
<packaging>jar</packaging>

<name>AxonFlow Java SDK</name>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
* runtime-e2e/decision_context_transfer_basis/DecisionContextTransferBasisTest.java
*
* Real-wire test of the v8.4.0 SDK surface (platform #2509, epic #2508)
* against a running AxonFlow agent:
*
* 1. DecisionSummary.getContext() / DecisionExplanation.getContext() surface
* the sanitized request context a PEP attaches to a Decision Mode call. We
* act as the PEP via a raw POST /api/v1/decide (that endpoint is not
* SDK-wrapped per ADR-056), then read the decision back through the SDK's
* listDecisions + explainDecision and assert getContext() is populated.
* 2. AuditLogEntry transfer_basis = "pasal_56b_dpa" round-trips through
* Jackson serialize -> deserialize verbatim.
*
* Run:
* mvn -DskipTests dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt -q
* mvn -DskipTests -q package # build the SDK jar
* SDK_JAR=$(ls target/axonflow-sdk-*.jar | head -1)
* CP="$SDK_JAR:$(cat /tmp/cp.txt)"
* AXONFLOW_AGENT_URL=http://localhost:8080 \
* AXONFLOW_TENANT_ID=buku-e-java-e2e AXONFLOW_TENANT_SECRET=buku-e-secret \
* java -cp "$CP" \
* runtime-e2e/decision_context_transfer_basis/DecisionContextTransferBasisTest.java
*/
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.getaxonflow.sdk.AxonFlow;
import com.getaxonflow.sdk.AxonFlowConfig;
import com.getaxonflow.sdk.types.AuditLogEntry;
import com.getaxonflow.sdk.types.DecisionExplanation;
import com.getaxonflow.sdk.types.DecisionSummary;
import com.getaxonflow.sdk.types.ListDecisionsOptions;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Base64;
import java.util.List;
import java.util.Map;

public class DecisionContextTransferBasisTest {

static void fail(String msg) {
System.err.println("FAIL: " + msg);
System.exit(1);
}

public static void main(String[] args) throws Exception {
String endpoint = System.getenv().getOrDefault("AXONFLOW_AGENT_URL", "http://localhost:8080");
String clientId = System.getenv().getOrDefault("AXONFLOW_TENANT_ID", "buku-e-java-e2e");
String secret = System.getenv().getOrDefault("AXONFLOW_TENANT_SECRET", "buku-e-secret");
Map<String, String> want =
Map.of(
"x_ai_agent", "refund-bot",
"x_session_id", "sess-buku-42",
"x_leader_identity", "ops-lead");

// 1. PEP: create a decision carrying request context (body 'context' map).
String decisionId = createDecision(endpoint, clientId, secret);
System.out.println("PEP decide -> decision_id=" + decisionId);

AxonFlow client =
AxonFlow.create(
AxonFlowConfig.builder()
.endpoint(endpoint)
.clientId(clientId)
.clientSecret(secret)
.build());

// 2. Read it back through the SDK.
List<DecisionSummary> rows =
client.listDecisions(ListDecisionsOptions.builder().limit(5).build());
DecisionSummary found =
rows.stream().filter(r -> decisionId.equals(r.getDecisionId())).findFirst().orElse(null);
if (found == null) {
fail("listDecisions did not return " + decisionId + " (got " + rows.size() + " rows)");
}
System.out.println("SDK listDecisions -> context=" + found.getContext());
if (found.getContext() == null || !found.getContext().entrySet().containsAll(want.entrySet())) {
fail("listDecisions context = " + found.getContext() + ", want superset of " + want);
}
System.out.println(
"PASS: listDecisions DecisionSummary.getContext() populated with "
+ found.getContext().size()
+ " PEP-forwarded keys");

DecisionExplanation exp = client.explainDecision(decisionId);
System.out.println(
"SDK explainDecision -> context="
+ exp.getContext()
+ " contextTruncated="
+ exp.isContextTruncated());
if (exp.getContext() == null || !exp.getContext().entrySet().containsAll(want.entrySet())) {
fail("explainDecision context = " + exp.getContext());
}
System.out.println(
"PASS: explainDecision returned full context (contextTruncated="
+ exp.isContextTruncated()
+ ")");

// 3. transfer_basis = pasal_56b_dpa round-trip (Pasal 56(b)).
ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
String json =
"{\"id\":\"e2e-audit\",\"timestamp\":\"2026-05-30T10:00:00Z\","
+ "\"data_residency\":\"ID\",\"transfer_basis\":\""
+ AuditLogEntry.TRANSFER_BASIS_PASAL_56B_DPA
+ "\"}";
AuditLogEntry entry = mapper.readValue(json, AuditLogEntry.class);
String reserialized = mapper.writeValueAsString(entry);
AuditLogEntry back = mapper.readValue(reserialized, AuditLogEntry.class);
if (!"pasal_56b_dpa".equals(back.getTransferBasis())) {
fail("transfer_basis round-trip = " + back.getTransferBasis() + ", want pasal_56b_dpa");
}
System.out.println("SDK AuditLogEntry round-trip -> " + reserialized);
System.out.println(
"PASS: AuditLogEntry.getTransferBasis() = \"" + back.getTransferBasis() + "\" round-trips verbatim");

System.out.println("ALL PASS: v8.4.0 context + pasal_56b_dpa verified through SDK runtime");
}

/** Acts as the PEP: the request context lives in the body's 'context' map. */
static String createDecision(String endpoint, String clientId, String secret) throws Exception {
String body =
"{\"stage\":\"llm\",\"query\":\"summarize this support ticket\","
+ "\"target\":{\"type\":\"llm\",\"model\":\"gpt-4\",\"provider\":\"openai\"},"
+ "\"context\":{\"x-ai-agent\":\"refund-bot\",\"x-session-id\":\"sess-buku-42\","
+ "\"x-leader-identity\":\"ops-lead\"}}";
String auth = Base64.getEncoder().encodeToString((clientId + ":" + secret).getBytes());
HttpRequest req =
HttpRequest.newBuilder(URI.create(endpoint + "/api/v1/decide"))
.header("Content-Type", "application/json")
.header("X-Client-ID", clientId)
.header("Authorization", "Basic " + auth)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> resp =
HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
fail("decide HTTP " + resp.statusCode() + ": " + resp.body());
}
System.out.println("server /decide response: " + resp.body());
String marker = "\"decision_id\":\"";
int i = resp.body().indexOf(marker);
if (i < 0) {
fail("no decision_id in response: " + resp.body());
}
int start = i + marker.length();
return resp.body().substring(start, resp.body().indexOf('"', start));
}
}
27 changes: 27 additions & 0 deletions runtime-e2e/decision_context_transfer_basis/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# decision_context_transfer_basis (v8.4.0)

Real-stack proof for the v8.4.0 SDK surface (platform epic #2508):

- **`DecisionSummary.getContext()` / `DecisionExplanation.getContext()`
(+ `isContextTruncated()`)** — the sanitized request context a PEP attaches to a
Decision Mode call is surfaced back through `listDecisions` and `explainDecision`.
- **`AuditLogEntry` `transfer_basis = "pasal_56b_dpa"`** — the Pasal 56(b) explicit
DPA tag round-trips through Jackson verbatim.

The driver acts as the PEP (raw `POST /api/v1/decide` — that endpoint is not
SDK-wrapped per ADR-056), then reads the decision back through the SDK against a
real running agent.

## Run

```
mvn -DskipTests dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt -q
mvn -DskipTests -q package
SDK_JAR=$(ls target/axonflow-sdk-*.jar | grep -v sources | grep -v javadoc | head -1)
CP="$SDK_JAR:$(cat /tmp/cp.txt)"
AXONFLOW_AGENT_URL=http://localhost:8080 \
AXONFLOW_TENANT_ID=buku-e-java-e2e AXONFLOW_TENANT_SECRET=buku-e-secret \
java -cp "$CP" runtime-e2e/decision_context_transfer_basis/DecisionContextTransferBasisTest.java
```

Exits non-zero if the SDK does not surface the new fields.
29 changes: 28 additions & 1 deletion src/main/java/com/getaxonflow/sdk/types/AuditLogEntry.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,28 @@
@JsonIgnoreProperties(ignoreUnknown = true)
public final class AuditLogEntry {

/**
* Cross-border transfer-basis values recognized under Indonesia UU PDP Pasal 56,
* for the {@link #getTransferBasis()} field:
*
* <ul>
* <li>{@code adequacy} — Pasal 56(a): destination with adequate protection
* <li>{@code safeguards} — Pasal 56(b): binding legal instrument (generic label)
* <li>{@code pasal_56b_dpa} — Pasal 56(b): binding legal instrument, explicit DPA tag
* <li>{@code consent} — Pasal 56(c): explicit data-subject consent
* </ul>
*
* <p>{@code safeguards} and {@code pasal_56b_dpa} are semantic equivalents; the
* platform surfaces whichever was recorded at decision time, verbatim. The field
* itself stays a {@code String} so the SDK never rejects a value a newer platform
* may add. (platform #2513 / epic #2508)
*/
public static final String TRANSFER_BASIS_ADEQUACY = "adequacy";

public static final String TRANSFER_BASIS_SAFEGUARDS = "safeguards";
public static final String TRANSFER_BASIS_PASAL_56B_DPA = "pasal_56b_dpa";
public static final String TRANSFER_BASIS_CONSENT = "consent";

@JsonProperty("id")
private final String id;

Expand Down Expand Up @@ -215,7 +237,12 @@ public String getDataResidency() {
return dataResidency;
}

/** Returns the cross-border transfer basis (adequacy, safeguards, or consent), or null if not set. */
/**
* Returns the cross-border transfer basis under Indonesia UU PDP Pasal 56
* ({@code adequacy}, {@code safeguards}, {@code pasal_56b_dpa}, or
* {@code consent}), or null if not set. Surfaced verbatim — see the
* {@code TRANSFER_BASIS_*} constants.
*/
public String getTransferBasis() {
return transferBasis;
}
Expand Down
26 changes: 25 additions & 1 deletion src/main/java/com/getaxonflow/sdk/types/DecisionExplanation.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;

/**
* Canonical payload returned by {@code AxonFlow.explainDecision}.
Expand All @@ -36,6 +37,8 @@ public final class DecisionExplanation {
private final int historicalHitCountSession;
private final String policySourceLink;
private final String toolSignature;
private final Map<String, String> context;
private final boolean contextTruncated;

@JsonCreator
public DecisionExplanation(
Expand All @@ -50,7 +53,9 @@ public DecisionExplanation(
@JsonProperty("override_existing_id") String overrideExistingId,
@JsonProperty("historical_hit_count_session") int historicalHitCountSession,
@JsonProperty("policy_source_link") String policySourceLink,
@JsonProperty("tool_signature") String toolSignature) {
@JsonProperty("tool_signature") String toolSignature,
@JsonProperty("context") Map<String, String> context,
@JsonProperty("context_truncated") boolean contextTruncated) {
this.decisionId = decisionId;
this.timestamp = timestamp;
this.policyMatches = policyMatches != null ? policyMatches : Collections.emptyList();
Expand All @@ -63,6 +68,8 @@ public DecisionExplanation(
this.historicalHitCountSession = historicalHitCountSession;
this.policySourceLink = policySourceLink;
this.toolSignature = toolSignature;
this.context = context;
this.contextTruncated = contextTruncated;
}

public String getDecisionId() {
Expand Down Expand Up @@ -112,4 +119,21 @@ public String getPolicySourceLink() {
public String getToolSignature() {
return toolSignature;
}

/**
* The FULL sanitized request context the PEP attached to the decision
* (canonical {@code lower_snake_case} keys, string values), read from the
* audit row's {@code policy_details->'context'}. Unlike {@link DecisionSummary}
* (truncated to 5 keys), explain returns every persisted key up to the
* platform's 10-key cap. May be {@code null} for pre-v8.4.0 audit rows.
* (platform #2509 / epic #2508)
*/
public Map<String, String> getContext() {
return context;
}

/** True when the agent dropped surplus context keys at write time. */
public boolean isContextTruncated() {
return contextTruncated;
}
}
18 changes: 17 additions & 1 deletion src/main/java/com/getaxonflow/sdk/types/DecisionSummary.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;
import java.util.Map;

/**
* Slim 5-field row returned by {@code AxonFlow.listDecisions}.
Expand Down Expand Up @@ -36,19 +37,22 @@ public final class DecisionSummary {
private final String decision;
private final String policyId;
private final String toolSignature;
private final Map<String, String> context;

@JsonCreator
public DecisionSummary(
@JsonProperty("decision_id") String decisionId,
@JsonProperty("timestamp") Instant timestamp,
@JsonProperty("decision") String decision,
@JsonProperty("policy_id") String policyId,
@JsonProperty("tool_signature") String toolSignature) {
@JsonProperty("tool_signature") String toolSignature,
@JsonProperty("context") Map<String, String> context) {
this.decisionId = decisionId;
this.timestamp = timestamp;
this.decision = decision;
this.policyId = policyId;
this.toolSignature = toolSignature;
this.context = context;
}

public String getDecisionId() {
Expand All @@ -73,4 +77,16 @@ public String getPolicyId() {
public String getToolSignature() {
return toolSignature;
}

/**
* The sanitized request context the PEP attached to the decision (canonical
* {@code lower_snake_case} keys, string values), surfaced from the audit
* row's {@code policy_details->'context'}. The list summary is truncated by
* the platform to the 5 most-correlated keys; the full map is available via
* {@code AxonFlow.explainDecision}. May be {@code null} for pre-v8.4.0 audit
* rows or decisions with no context. (platform #2509 / epic #2508)
*/
public Map<String, String> getContext() {
return context;
}
}
Loading
Loading