diff --git a/CHANGELOG.md b/CHANGELOG.md index 51e8eea..d730968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 resumes from a specific checkpoint (Enterprise). - **Checkpoint types** — `Checkpoint`, `CheckpointListResponse`, and `ResumeFromCheckpointResponse` with Jackson deserialization. +- **`AxonFlow.explainDecision(decisionId)`** (+ `explainDecisionAsync`) — fetches + the full explanation for a previously-made policy decision via + `GET /api/v1/decisions/:id/explain`. Returns a `DecisionExplanation` with + matched policies, risk level, reason, override availability, existing + override ID (if any), and a rolling-24h session hit count for the matched + rule. Shape is frozen (future extra fields ignored via Jackson's + `@JsonIgnoreProperties(ignoreUnknown = true)`); additive-only fields ensure + forward compatibility. +- **`DecisionExplanation`, `ExplainPolicy`, `ExplainRule`** — new immutable + DTOs in `com.getaxonflow.sdk.types`. +- **`AuditSearchRequest.Builder.decisionId`, `policyName`, `overrideId`** — + three new optional filter fields on `searchAuditLogs`. Use `decisionId` + to gather every record tied to one decision; `policyName` to find + everything matched by a specific policy; `overrideId` to reconstruct an + override's full lifecycle. + +### Compatibility + +Companion to platform v7.1.0. Works against plugin releases (OpenClaw v1.3.0+, +Claude Code v0.5.0+, Cursor v0.5.0+, Codex v0.4.0+) that surface the +`DecisionExplanation` shape. Audit filter fields pass through when unset; +server-side filtering activates on v7.1.0+ platforms. ## [5.3.0] - 2026-04-09 diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index 2e6b12b..890e3e2 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -563,6 +563,66 @@ public CompletableFuture searchAuditLogsAsync(AuditSearchRe return CompletableFuture.supplyAsync(() -> searchAuditLogs(request), asyncExecutor); } + /** + * 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. + * + *

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

Example usage: + * + *

{@code
+   * DecisionExplanation exp = axonflow.explainDecision("dec_wf123_step4");
+   * if (exp.isOverrideAvailable()) {
+   *     // offer the user a governed override action
+   * }
+   * }
+ * + * @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 + */ + public DecisionExplanation explainDecision(String decisionId) { + if (decisionId == null || decisionId.isEmpty()) { + throw new IllegalArgumentException("decisionId is required"); + } + return retryExecutor.execute( + () -> { + // Path-segment encoding: URLEncoder is application/x-www-form-urlencoded + // (space -> '+'), which is wrong for path segments. Replacing '+' with + // '%20' converts the form-encoded output into a valid percent-encoded + // path segment, matching how Go / Python / TypeScript escape the + // decision_id in this path. + String encoded = + java.net.URLEncoder.encode(decisionId, java.nio.charset.StandardCharsets.UTF_8) + .replace("+", "%20"); + String path = "/api/v1/decisions/" + encoded + "/explain"; + Request httpRequest = buildOrchestratorRequest("GET", path, null); + try (Response response = httpClient.newCall(httpRequest).execute()) { + JsonNode node = parseResponseNode(response); + return objectMapper.treeToValue(node, DecisionExplanation.class); + } + }, + "explainDecision"); + } + + /** + * Asynchronously fetches a decision explanation. + * + * @param decisionId the global decision identifier + * @return a future containing the decision explanation + */ + public CompletableFuture explainDecisionAsync(String decisionId) { + return CompletableFuture.supplyAsync(() -> explainDecision(decisionId), asyncExecutor); + } + /** * Gets audit logs for a specific tenant. * diff --git a/src/main/java/com/getaxonflow/sdk/types/AuditSearchRequest.java b/src/main/java/com/getaxonflow/sdk/types/AuditSearchRequest.java index a74ed11..d03689f 100644 --- a/src/main/java/com/getaxonflow/sdk/types/AuditSearchRequest.java +++ b/src/main/java/com/getaxonflow/sdk/types/AuditSearchRequest.java @@ -55,6 +55,21 @@ public final class AuditSearchRequest { @JsonProperty("request_type") private final String requestType; + /** Filter by decision ID (ADR-043). Gathers every audit record tied to one decision. */ + @JsonProperty("decision_id") + private final String decisionId; + + /** Filter by matched policy name (ADR-043). */ + @JsonProperty("policy_name") + private final String policyName; + + /** + * Filter by session override ID (ADR-042). Reconstructs an override's full lifecycle: + * override_created → override_used → override_expired | override_revoked. + */ + @JsonProperty("override_id") + private final String overrideId; + @JsonProperty("limit") private final Integer limit; @@ -67,6 +82,9 @@ private AuditSearchRequest(Builder builder) { this.startTime = builder.startTime != null ? builder.startTime.toString() : null; this.endTime = builder.endTime != null ? builder.endTime.toString() : null; this.requestType = builder.requestType; + this.decisionId = builder.decisionId; + this.policyName = builder.policyName; + this.overrideId = builder.overrideId; this.limit = builder.limit != null ? Math.min(builder.limit, 1000) : 100; this.offset = builder.offset; } @@ -91,6 +109,18 @@ public String getRequestType() { return requestType; } + public String getDecisionId() { + return decisionId; + } + + public String getPolicyName() { + return policyName; + } + + public String getOverrideId() { + return overrideId; + } + public Integer getLimit() { return limit; } @@ -148,6 +178,9 @@ public static final class Builder { private Instant startTime; private Instant endTime; private String requestType; + private String decisionId; + private String policyName; + private String overrideId; private Integer limit; private Integer offset; @@ -183,6 +216,30 @@ public Builder requestType(String requestType) { return this; } + /** + * Filter by decision ID (ADR-043). Use to gather every audit record tied to a single + * decision — the explain-flow cross-reference pivot. + */ + public Builder decisionId(String decisionId) { + this.decisionId = decisionId; + return this; + } + + /** Filter by matched policy name (ADR-043). */ + public Builder policyName(String policyName) { + this.policyName = policyName; + return this; + } + + /** + * Filter by session override ID (ADR-042). Use to reconstruct an override's full + * lifecycle (override_created → override_used → override_expired | override_revoked). + */ + public Builder overrideId(String overrideId) { + this.overrideId = overrideId; + return this; + } + /** Maximum results to return (default: 100, max: 1000). */ public Builder limit(int limit) { this.limit = limit; diff --git a/src/main/java/com/getaxonflow/sdk/types/DecisionExplanation.java b/src/main/java/com/getaxonflow/sdk/types/DecisionExplanation.java new file mode 100644 index 0000000..3293cef --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/DecisionExplanation.java @@ -0,0 +1,115 @@ +/* + * Copyright 2025 AxonFlow + * + * Licensed under the Apache License, Version 2.0. + */ +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.Collections; +import java.util.List; + +/** + * Canonical payload returned by {@code AxonFlow.explainDecision}. + * + *

Shape frozen per ADR-043 (Explainability Data Contract). Additive-only changes are + * non-breaking; renames or removals require a major version bump. + * + *

Unknown fields from future platform versions are ignored to preserve forward + * compatibility — see the {@code @JsonIgnoreProperties(ignoreUnknown = true)} annotation. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class DecisionExplanation { + + private final String decisionId; + private final Instant timestamp; + private final List policyMatches; + private final List matchedRules; + private final String decision; + private final String reason; + private final String riskLevel; + private final boolean overrideAvailable; + private final String overrideExistingId; + private final int historicalHitCountSession; + private final String policySourceLink; + private final String toolSignature; + + @JsonCreator + public DecisionExplanation( + @JsonProperty("decision_id") String decisionId, + @JsonProperty("timestamp") Instant timestamp, + @JsonProperty("policy_matches") List policyMatches, + @JsonProperty("matched_rules") List matchedRules, + @JsonProperty("decision") String decision, + @JsonProperty("reason") String reason, + @JsonProperty("risk_level") String riskLevel, + @JsonProperty("override_available") boolean overrideAvailable, + @JsonProperty("override_existing_id") String overrideExistingId, + @JsonProperty("historical_hit_count_session") int historicalHitCountSession, + @JsonProperty("policy_source_link") String policySourceLink, + @JsonProperty("tool_signature") String toolSignature) { + this.decisionId = decisionId; + this.timestamp = timestamp; + this.policyMatches = policyMatches != null ? policyMatches : Collections.emptyList(); + this.matchedRules = matchedRules; + this.decision = decision; + this.reason = reason; + this.riskLevel = riskLevel; + this.overrideAvailable = overrideAvailable; + this.overrideExistingId = overrideExistingId; + this.historicalHitCountSession = historicalHitCountSession; + this.policySourceLink = policySourceLink; + this.toolSignature = toolSignature; + } + + public String getDecisionId() { + return decisionId; + } + + public Instant getTimestamp() { + return timestamp; + } + + public List getPolicyMatches() { + return policyMatches; + } + + public List getMatchedRules() { + return matchedRules; + } + + public String getDecision() { + return decision; + } + + public String getReason() { + return reason; + } + + public String getRiskLevel() { + return riskLevel; + } + + public boolean isOverrideAvailable() { + return overrideAvailable; + } + + public String getOverrideExistingId() { + return overrideExistingId; + } + + public int getHistoricalHitCountSession() { + return historicalHitCountSession; + } + + public String getPolicySourceLink() { + return policySourceLink; + } + + public String getToolSignature() { + return toolSignature; + } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/ExplainPolicy.java b/src/main/java/com/getaxonflow/sdk/types/ExplainPolicy.java new file mode 100644 index 0000000..bfd0913 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/ExplainPolicy.java @@ -0,0 +1,62 @@ +/* + * Copyright 2025 AxonFlow + * + * Licensed under the Apache License, Version 2.0. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** A policy reference inside a decision explanation (ADR-043). */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ExplainPolicy { + + private final String policyId; + private final String policyName; + private final String action; + private final String riskLevel; // low | medium | high | critical + private final boolean allowOverride; + private final String policyDescription; + + @JsonCreator + public ExplainPolicy( + @JsonProperty("policy_id") String policyId, + @JsonProperty("policy_name") String policyName, + @JsonProperty("action") String action, + @JsonProperty("risk_level") String riskLevel, + @JsonProperty("allow_override") boolean allowOverride, + @JsonProperty("policy_description") String policyDescription) { + this.policyId = policyId; + this.policyName = policyName; + this.action = action; + this.riskLevel = riskLevel; + this.allowOverride = allowOverride; + this.policyDescription = policyDescription; + } + + public String getPolicyId() { + return policyId; + } + + public String getPolicyName() { + return policyName; + } + + public String getAction() { + return action; + } + + public String getRiskLevel() { + return riskLevel; + } + + public boolean isAllowOverride() { + return allowOverride; + } + + public String getPolicyDescription() { + return policyDescription; + } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/ExplainRule.java b/src/main/java/com/getaxonflow/sdk/types/ExplainRule.java new file mode 100644 index 0000000..3d0d0ab --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/ExplainRule.java @@ -0,0 +1,48 @@ +/* + * Copyright 2025 AxonFlow + * + * Licensed under the Apache License, Version 2.0. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Rule-level detail inside a decision explanation (ADR-043). */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ExplainRule { + + private final String policyId; + private final String ruleId; + private final String ruleText; + private final String matchedOn; + + @JsonCreator + public ExplainRule( + @JsonProperty("policy_id") String policyId, + @JsonProperty("rule_id") String ruleId, + @JsonProperty("rule_text") String ruleText, + @JsonProperty("matched_on") String matchedOn) { + this.policyId = policyId; + this.ruleId = ruleId; + this.ruleText = ruleText; + this.matchedOn = matchedOn; + } + + public String getPolicyId() { + return policyId; + } + + public String getRuleId() { + return ruleId; + } + + public String getRuleText() { + return ruleText; + } + + public String getMatchedOn() { + return matchedOn; + } +} diff --git a/src/test/java/com/getaxonflow/sdk/DecisionExplainTest.java b/src/test/java/com/getaxonflow/sdk/DecisionExplainTest.java new file mode 100644 index 0000000..78e8d57 --- /dev/null +++ b/src/test/java/com/getaxonflow/sdk/DecisionExplainTest.java @@ -0,0 +1,220 @@ +/* + * Copyright 2025 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.types.*; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Tests for AxonFlow.explainDecision + audit search filter parity (ADR-043 / ADR-042). */ +@WireMockTest +@DisplayName("Decision Explainability (ADR-043)") +class DecisionExplainTest { + + private AxonFlow axonflow; + + private static final String EXPLAIN_BODY = + "{" + + "\"decision_id\": \"dec_wf1_step2\"," + + "\"timestamp\": \"2026-04-17T12:00:00Z\"," + + "\"decision\": \"deny\"," + + "\"reason\": \"SQL injection detected\"," + + "\"risk_level\": \"high\"," + + "\"policy_matches\": [{" + + " \"policy_id\": \"pol-sqli\"," + + " \"policy_name\": \"SQL Injection Detector\"," + + " \"action\": \"deny\"," + + " \"risk_level\": \"high\"," + + " \"allow_override\": true," + + " \"policy_description\": \"Blocks SQL injection\"" + + "}]," + + "\"matched_rules\": [{" + + " \"policy_id\": \"pol-sqli\"," + + " \"rule_id\": \"r-1\"," + + " \"rule_text\": \"UNION SELECT\"," + + " \"matched_on\": \"query.sql\"" + + "}]," + + "\"override_available\": true," + + "\"override_existing_id\": \"ov-abc\"," + + "\"historical_hit_count_session\": 3," + + "\"policy_source_link\": \"https://policies.axonflow/sqli\"," + + "\"tool_signature\": \"Bash\"," + + "\"future_field_unknown\": \"ignored\"" // forward-compat check + + "}"; + + @BeforeEach + void setUp(WireMockRuntimeInfo wmRuntimeInfo) { + axonflow = + AxonFlow.create( + AxonFlowConfig.builder().endpoint(wmRuntimeInfo.getHttpBaseUrl()).build()); + } + + @Test + @DisplayName("rejects empty decision ID") + void rejectsEmptyDecisionId() { + assertThatThrownBy(() -> axonflow.explainDecision("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("required"); + } + + @Test + @DisplayName("rejects null decision ID") + void rejectsNullDecisionId() { + assertThatThrownBy(() -> axonflow.explainDecision(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("parses full payload and calls correct endpoint") + void parsesFullPayload() { + stubFor( + get(urlEqualTo("/api/v1/decisions/dec_wf1_step2/explain")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(EXPLAIN_BODY))); + + DecisionExplanation exp = axonflow.explainDecision("dec_wf1_step2"); + + assertThat(exp.getDecisionId()).isEqualTo("dec_wf1_step2"); + assertThat(exp.getDecision()).isEqualTo("deny"); + assertThat(exp.getReason()).isEqualTo("SQL injection detected"); + assertThat(exp.getRiskLevel()).isEqualTo("high"); + assertThat(exp.getPolicyMatches()).hasSize(1); + assertThat(exp.getPolicyMatches().get(0).getPolicyId()).isEqualTo("pol-sqli"); + assertThat(exp.getPolicyMatches().get(0).isAllowOverride()).isTrue(); + assertThat(exp.getMatchedRules()).hasSize(1); + assertThat(exp.getMatchedRules().get(0).getRuleText()).isEqualTo("UNION SELECT"); + assertThat(exp.isOverrideAvailable()).isTrue(); + assertThat(exp.getOverrideExistingId()).isEqualTo("ov-abc"); + assertThat(exp.getHistoricalHitCountSession()).isEqualTo(3); + assertThat(exp.getToolSignature()).isEqualTo("Bash"); + } + + @Test + @DisplayName("ignores unknown fields for forward compatibility (ADR-043)") + void forwardCompat() { + // EXPLAIN_BODY contains future_field_unknown; parsing must succeed regardless. + stubFor( + get(urlEqualTo("/api/v1/decisions/dec_wf1_step2/explain")) + .willReturn(aResponse().withStatus(200).withBody(EXPLAIN_BODY))); + + DecisionExplanation exp = axonflow.explainDecision("dec_wf1_step2"); + assertThat(exp.getDecisionId()).isEqualTo("dec_wf1_step2"); + } + + @Test + @DisplayName("searchAuditLogs sends decision_id, policy_name, override_id when set") + void auditSearchNewFilters() { + stubFor( + post(urlEqualTo("/api/v1/audit/search")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"entries\":[],\"total\":0,\"limit\":100,\"offset\":0}"))); + + axonflow.searchAuditLogs( + AuditSearchRequest.builder() + .decisionId("dec-abc") + .policyName("SQL Injection Detector") + .overrideId("ov-xyz") + .build()); + + verify( + postRequestedFor(urlEqualTo("/api/v1/audit/search")) + .withRequestBody(containing("\"decision_id\":\"dec-abc\"")) + .withRequestBody(containing("\"policy_name\":\"SQL Injection Detector\"")) + .withRequestBody(containing("\"override_id\":\"ov-xyz\""))); + } + + @Test + @DisplayName("searchAuditLogs omits new filter fields when unset") + void auditSearchFiltersAbsent() { + stubFor( + post(urlEqualTo("/api/v1/audit/search")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"entries\":[],\"total\":0,\"limit\":100,\"offset\":0}"))); + + axonflow.searchAuditLogs(AuditSearchRequest.builder().build()); + + verify( + postRequestedFor(urlEqualTo("/api/v1/audit/search")) + .withRequestBody(notMatching(".*decision_id.*")) + .withRequestBody(notMatching(".*policy_name.*")) + .withRequestBody(notMatching(".*override_id.*"))); + } + + @Test + @DisplayName("path-encodes decision ID — space becomes %20 not + (path vs form encoding)") + void pathEncodesDecisionIdSpaceAsPercent20() { + // Decision IDs should never realistically contain spaces, but Go/Python/ + // TypeScript all use path-segment escaping — Java must match so the + // request hits the same path in the unlikely event it does. Before the + // fix this was URLEncoder.encode() alone, which produced '+' and would + // have routed to a different audit_logs key. + stubFor( + get(urlEqualTo("/api/v1/decisions/dec%20with%20space/explain")) + .willReturn(aResponse().withStatus(200).withBody(EXPLAIN_BODY))); + + axonflow.explainDecision("dec with space"); + + verify(getRequestedFor(urlEqualTo("/api/v1/decisions/dec%20with%20space/explain"))); + } + + @Test + @DisplayName("path-encodes decision ID — '+' in id is preserved as %2B") + void pathEncodesPlusAsPercent2B() { + // A literal '+' in the id must NOT be parsed as a space on the server. + stubFor( + get(urlEqualTo("/api/v1/decisions/dec%2Bplus/explain")) + .willReturn(aResponse().withStatus(200).withBody(EXPLAIN_BODY))); + + axonflow.explainDecision("dec+plus"); + + verify(getRequestedFor(urlEqualTo("/api/v1/decisions/dec%2Bplus/explain"))); + } + + @Test + @DisplayName("DecisionExplanation getters return null-safe values") + void decisionExplanationGetters() { + DecisionExplanation exp = + new DecisionExplanation( + "d-1", + java.time.Instant.now(), + null, // policyMatches null should default to empty + null, + "allow", + "", + null, + false, + null, + 0, + null, + null); + assertThat(exp.getPolicyMatches()).isEmpty(); + assertThat(exp.getMatchedRules()).isNull(); + } + + @Test + @DisplayName("ExplainPolicy defaults correctly") + void explainPolicyDefaults() { + ExplainPolicy p = new ExplainPolicy("p-1", null, null, null, false, null); + assertThat(p.getPolicyId()).isEqualTo("p-1"); + assertThat(p.isAllowOverride()).isFalse(); + assertThat(p.getPolicyName()).isNull(); + } +}