diff --git a/CHANGELOG.md b/CHANGELOG.md index e059b13..14b5fe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` (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 diff --git a/examples/explain-decision/pom.xml b/examples/explain-decision/pom.xml index f778def..7b5ee0b 100644 --- a/examples/explain-decision/pom.xml +++ b/examples/explain-decision/pom.xml @@ -25,7 +25,7 @@ com.getaxonflow axonflow-sdk - 8.0.0 + 8.4.0 diff --git a/examples/list-decisions/pom.xml b/examples/list-decisions/pom.xml index 87c5214..5f7f594 100644 --- a/examples/list-decisions/pom.xml +++ b/examples/list-decisions/pom.xml @@ -24,7 +24,7 @@ com.getaxonflow axonflow-sdk - 8.0.0 + 8.4.0 diff --git a/pom.xml b/pom.xml index dc68b66..b46fe70 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.getaxonflow axonflow-sdk - 8.3.0 + 8.4.0 jar AxonFlow Java SDK diff --git a/runtime-e2e/decision_context_transfer_basis/DecisionContextTransferBasisTest.java b/runtime-e2e/decision_context_transfer_basis/DecisionContextTransferBasisTest.java new file mode 100644 index 0000000..914b63b --- /dev/null +++ b/runtime-e2e/decision_context_transfer_basis/DecisionContextTransferBasisTest.java @@ -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 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 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 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)); + } +} diff --git a/runtime-e2e/decision_context_transfer_basis/README.md b/runtime-e2e/decision_context_transfer_basis/README.md new file mode 100644 index 0000000..d0c06f3 --- /dev/null +++ b/runtime-e2e/decision_context_transfer_basis/README.md @@ -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. diff --git a/src/main/java/com/getaxonflow/sdk/types/AuditLogEntry.java b/src/main/java/com/getaxonflow/sdk/types/AuditLogEntry.java index 9df545e..d6405ee 100644 --- a/src/main/java/com/getaxonflow/sdk/types/AuditLogEntry.java +++ b/src/main/java/com/getaxonflow/sdk/types/AuditLogEntry.java @@ -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: + * + *
    + *
  • {@code adequacy} — Pasal 56(a): destination with adequate protection + *
  • {@code safeguards} — Pasal 56(b): binding legal instrument (generic label) + *
  • {@code pasal_56b_dpa} — Pasal 56(b): binding legal instrument, explicit DPA tag + *
  • {@code consent} — Pasal 56(c): explicit data-subject consent + *
+ * + *

{@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; @@ -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; } diff --git a/src/main/java/com/getaxonflow/sdk/types/DecisionExplanation.java b/src/main/java/com/getaxonflow/sdk/types/DecisionExplanation.java index 3293cef..c7a0247 100644 --- a/src/main/java/com/getaxonflow/sdk/types/DecisionExplanation.java +++ b/src/main/java/com/getaxonflow/sdk/types/DecisionExplanation.java @@ -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}. @@ -36,6 +37,8 @@ public final class DecisionExplanation { private final int historicalHitCountSession; private final String policySourceLink; private final String toolSignature; + private final Map context; + private final boolean contextTruncated; @JsonCreator public DecisionExplanation( @@ -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 context, + @JsonProperty("context_truncated") boolean contextTruncated) { this.decisionId = decisionId; this.timestamp = timestamp; this.policyMatches = policyMatches != null ? policyMatches : Collections.emptyList(); @@ -63,6 +68,8 @@ public DecisionExplanation( this.historicalHitCountSession = historicalHitCountSession; this.policySourceLink = policySourceLink; this.toolSignature = toolSignature; + this.context = context; + this.contextTruncated = contextTruncated; } public String getDecisionId() { @@ -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 getContext() { + return context; + } + + /** True when the agent dropped surplus context keys at write time. */ + public boolean isContextTruncated() { + return contextTruncated; + } } diff --git a/src/main/java/com/getaxonflow/sdk/types/DecisionSummary.java b/src/main/java/com/getaxonflow/sdk/types/DecisionSummary.java index a31912a..ab12458 100644 --- a/src/main/java/com/getaxonflow/sdk/types/DecisionSummary.java +++ b/src/main/java/com/getaxonflow/sdk/types/DecisionSummary.java @@ -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}. @@ -36,6 +37,7 @@ public final class DecisionSummary { private final String decision; private final String policyId; private final String toolSignature; + private final Map context; @JsonCreator public DecisionSummary( @@ -43,12 +45,14 @@ public DecisionSummary( @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 context) { this.decisionId = decisionId; this.timestamp = timestamp; this.decision = decision; this.policyId = policyId; this.toolSignature = toolSignature; + this.context = context; } public String getDecisionId() { @@ -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 getContext() { + return context; + } } diff --git a/src/test/java/com/getaxonflow/sdk/DecisionExplainTest.java b/src/test/java/com/getaxonflow/sdk/DecisionExplainTest.java index 78e8d57..453d553 100644 --- a/src/test/java/com/getaxonflow/sdk/DecisionExplainTest.java +++ b/src/test/java/com/getaxonflow/sdk/DecisionExplainTest.java @@ -101,6 +101,49 @@ void parsesFullPayload() { assertThat(exp.getToolSignature()).isEqualTo("Bash"); } + @Test + @DisplayName("v8.4.0 — surfaces the full request context + contextTruncated") + void surfacesRequestContext() { + String body = + "{" + + "\"decision_id\": \"dec-ctx\"," + + "\"timestamp\": \"2026-05-30T12:00:00Z\"," + + "\"decision\": \"deny\"," + + "\"reason\": \"\"," + + "\"policy_matches\": []," + + "\"override_available\": false," + + "\"historical_hit_count_session\": 0," + + "\"context\": {\"x_ai_agent\":\"refund-bot\",\"x_session_id\":\"sess-42\"}," + + "\"context_truncated\": true" + + "}"; + stubFor( + get(urlEqualTo("/api/v1/decisions/dec-ctx/explain")) + .willReturn(aResponse().withStatus(200).withBody(body))); + + DecisionExplanation exp = axonflow.explainDecision("dec-ctx"); + assertThat(exp.getContext()) + .containsEntry("x_ai_agent", "refund-bot") + .containsEntry("x_session_id", "sess-42") + .hasSize(2); + assertThat(exp.isContextTruncated()).isTrue(); + } + + @Test + @DisplayName("v8.4.0 — context is null + contextTruncated false for pre-v8.4.0 rows") + void contextAbsentDefaults() { + String body = + "{\"decision_id\":\"dec-1\",\"timestamp\":\"2026-04-17T12:00:00Z\"," + + "\"decision\":\"allow\",\"reason\":\"\",\"policy_matches\":[]," + + "\"override_available\":false,\"historical_hit_count_session\":0}"; + stubFor( + get(urlEqualTo("/api/v1/decisions/dec-1/explain")) + .willReturn(aResponse().withStatus(200).withBody(body))); + + DecisionExplanation exp = axonflow.explainDecision("dec-1"); + assertThat(exp.getContext()).isNull(); + assertThat(exp.isContextTruncated()).isFalse(); + } + @Test @DisplayName("ignores unknown fields for forward compatibility (ADR-043)") void forwardCompat() { @@ -204,9 +247,13 @@ void decisionExplanationGetters() { null, 0, null, - null); + null, + null, // context + false); // contextTruncated assertThat(exp.getPolicyMatches()).isEmpty(); assertThat(exp.getMatchedRules()).isNull(); + assertThat(exp.getContext()).isNull(); + assertThat(exp.isContextTruncated()).isFalse(); } @Test diff --git a/src/test/java/com/getaxonflow/sdk/ListDecisionsTest.java b/src/test/java/com/getaxonflow/sdk/ListDecisionsTest.java index bbd6817..f3525b4 100644 --- a/src/test/java/com/getaxonflow/sdk/ListDecisionsTest.java +++ b/src/test/java/com/getaxonflow/sdk/ListDecisionsTest.java @@ -64,6 +64,36 @@ void happyPath() { assertThat(got.get(2).getDecision()).isEqualTo("require_approval"); } + @Test + @DisplayName("v8.4.0 — surfaces the PEP-forwarded request context on the summary") + void surfacesRequestContext() { + stubFor( + get(urlPathEqualTo("/api/v1/decisions")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody( + "{\"decisions\":[" + + "{\"decision_id\":\"dec-ctx\",\"timestamp\":\"2026-05-30T12:00:00Z\"," + + "\"decision\":\"deny\",\"context\":{" + + "\"x_ai_agent\":\"refund-bot\",\"x_session_id\":\"sess-42\"," + + "\"x_leader_identity\":\"ops-lead\"}}," + + "{\"decision_id\":\"dec-noctx\",\"timestamp\":\"2026-05-30T11:00:00Z\"," + + "\"decision\":\"allow\"}" + + "]}"))); + + List got = axonflow.listDecisions(null); + assertThat(got).hasSize(2); + assertThat(got.get(0).getContext()) + .containsEntry("x_ai_agent", "refund-bot") + .containsEntry("x_session_id", "sess-42") + .containsEntry("x_leader_identity", "ops-lead") + .hasSize(3); + // A decision with no context keeps a null map (pre-v8.4.0 byte-shape). + assertThat(got.get(1).getContext()).isNull(); + } + @Test @DisplayName("filter serialization — every option lands in the URL") void filterSerialization() { diff --git a/src/test/java/com/getaxonflow/sdk/types/IndonesiaPiiAuditTest.java b/src/test/java/com/getaxonflow/sdk/types/IndonesiaPiiAuditTest.java index 2c48e0d..f8562a7 100644 --- a/src/test/java/com/getaxonflow/sdk/types/IndonesiaPiiAuditTest.java +++ b/src/test/java/com/getaxonflow/sdk/types/IndonesiaPiiAuditTest.java @@ -135,4 +135,42 @@ void toStringShouldIncludeCrossBorderFields() throws Exception { assertThat(str).contains("transferBasis='safeguards'"); } } + + @Nested + @DisplayName("transfer_basis Pasal 56(b) (v8.4.0)") + class TransferBasisPasal56b { + + @Test + @DisplayName("TRANSFER_BASIS_* constants carry the canonical wire values") + void constantsWireValues() { + assertThat(AuditLogEntry.TRANSFER_BASIS_ADEQUACY).isEqualTo("adequacy"); + assertThat(AuditLogEntry.TRANSFER_BASIS_SAFEGUARDS).isEqualTo("safeguards"); + assertThat(AuditLogEntry.TRANSFER_BASIS_PASAL_56B_DPA).isEqualTo("pasal_56b_dpa"); + assertThat(AuditLogEntry.TRANSFER_BASIS_CONSENT).isEqualTo("consent"); + } + + @Test + @DisplayName("pasal_56b_dpa round-trips verbatim (never translated to safeguards)") + void pasal56bDpaRoundTrips() throws Exception { + String json = + "{\"id\":\"aud-56b\",\"timestamp\":\"2026-05-30T10:00:00Z\"," + + "\"data_residency\":\"ID\",\"transfer_basis\":\"pasal_56b_dpa\"}"; + AuditLogEntry entry = MAPPER.readValue(json, AuditLogEntry.class); + assertThat(entry.getTransferBasis()).isEqualTo("pasal_56b_dpa"); + + String reserialized = MAPPER.writeValueAsString(entry); + AuditLogEntry back = MAPPER.readValue(reserialized, AuditLogEntry.class); + assertThat(back.getTransferBasis()).isEqualTo(AuditLogEntry.TRANSFER_BASIS_PASAL_56B_DPA); + } + + @Test + @DisplayName("existing safeguards value still parses after the widening (backward compat)") + void safeguardsBackwardCompat() throws Exception { + String json = + "{\"id\":\"aud-sg\",\"timestamp\":\"2026-05-26T10:00:00Z\"," + + "\"transfer_basis\":\"safeguards\"}"; + AuditLogEntry entry = MAPPER.readValue(json, AuditLogEntry.class); + assertThat(entry.getTransferBasis()).isEqualTo(AuditLogEntry.TRANSFER_BASIS_SAFEGUARDS); + } + } } diff --git a/tests/fixtures/wire-shape-baseline.json b/tests/fixtures/wire-shape-baseline.json index 3cfa678..65d9165 100644 --- a/tests/fixtures/wire-shape-baseline.json +++ b/tests/fixtures/wire-shape-baseline.json @@ -268,6 +268,9 @@ }, "DecisionExplanation": { "sdk_only": [ + "context", + "contextTruncated", + "context_truncated", "decisionId", "historicalHitCountSession", "matchedRules",