Skip to content

Commit 08b18cb

Browse files
feat: explainDecision + audit search filter parity (#128)
* feat: explainDecision + audit search filter parity (Plugin Batch 1) Adds Java SDK half of ADR-043 + ADR-042. New: - DecisionExplanation, ExplainPolicy, ExplainRule DTOs in com.getaxonflow.sdk.types. Jackson @JsonIgnoreProperties(ignoreUnknown= true) for ADR-043 forward-compat with future platform fields. - AxonFlow.explainDecision(decisionId) + explainDecisionAsync calling GET /api/v1/decisions/:id/explain. URL-encodes the decision ID. Rejects null/empty with IllegalArgumentException. - AuditSearchRequest builder gains decisionId/policyName/overrideId; new JSON properties serialize only when set via @JsonInclude(NON_NULL). Version: 5.3.0 -> 5.4.0 (pom.xml). Tests (8 new, all passing via WireMock): - explainDecision: null + empty rejection, full payload parse with URL assertion, forward-compat with unknown fields. - searchAuditLogs: new filters serialize when set, absent when unset. - DTO null-safety: DecisionExplanation policyMatches defaults to empty when constructor receives null; ExplainPolicy defaults. Companion to platform v7.1.0 (axonflow-enterprise PR #1605), Go SDK v5.4.0 (PR #122), Python v6.4.0 (PR #141), TS v5.4.0 (PR #175). * fix: use path-segment encoding for explainDecision decision ID URLEncoder.encode() is application/x-www-form-urlencoded — spaces become '+' rather than '%20'. That's wrong for a path segment: the server parses the URL path segment-by-segment and '+' is a literal character there. Go / Python / TypeScript all path-encode; Java was the outlier. Fix by replacing '+' with '%20' in the URLEncoder output. That converts the form-encoded string to a valid percent-encoded path segment, with every other character (including literal '+' in the input -> '%2B') handled correctly. Two regression tests lock in the semantics.
1 parent ce509e4 commit 08b18cb

7 files changed

Lines changed: 584 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
resumes from a specific checkpoint (Enterprise).
2222
- **Checkpoint types**`Checkpoint`, `CheckpointListResponse`, and
2323
`ResumeFromCheckpointResponse` with Jackson deserialization.
24+
- **`AxonFlow.explainDecision(decisionId)`** (+ `explainDecisionAsync`) — fetches
25+
the full explanation for a previously-made policy decision via
26+
`GET /api/v1/decisions/:id/explain`. Returns a `DecisionExplanation` with
27+
matched policies, risk level, reason, override availability, existing
28+
override ID (if any), and a rolling-24h session hit count for the matched
29+
rule. Shape is frozen (future extra fields ignored via Jackson's
30+
`@JsonIgnoreProperties(ignoreUnknown = true)`); additive-only fields ensure
31+
forward compatibility.
32+
- **`DecisionExplanation`, `ExplainPolicy`, `ExplainRule`** — new immutable
33+
DTOs in `com.getaxonflow.sdk.types`.
34+
- **`AuditSearchRequest.Builder.decisionId`, `policyName`, `overrideId`**
35+
three new optional filter fields on `searchAuditLogs`. Use `decisionId`
36+
to gather every record tied to one decision; `policyName` to find
37+
everything matched by a specific policy; `overrideId` to reconstruct an
38+
override's full lifecycle.
39+
40+
### Compatibility
41+
42+
Companion to platform v7.1.0. Works against plugin releases (OpenClaw v1.3.0+,
43+
Claude Code v0.5.0+, Cursor v0.5.0+, Codex v0.4.0+) that surface the
44+
`DecisionExplanation` shape. Audit filter fields pass through when unset;
45+
server-side filtering activates on v7.1.0+ platforms.
2446

2547
## [5.3.0] - 2026-04-09
2648

src/main/java/com/getaxonflow/sdk/AxonFlow.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,66 @@ public CompletableFuture<AuditSearchResponse> searchAuditLogsAsync(AuditSearchRe
563563
return CompletableFuture.supplyAsync(() -> searchAuditLogs(request), asyncExecutor);
564564
}
565565

566+
/**
567+
* Fetches the full explanation for a previously-made policy decision.
568+
*
569+
* <p>Implements ADR-043 (Explainability Data Contract). Calls {@code GET
570+
* /api/v1/decisions/:id/explain} and returns a {@link DecisionExplanation} including
571+
* matched policies, risk level, reason, override availability, existing override ID (if
572+
* any), and a rolling-24h session hit count for the matched rule.
573+
*
574+
* <p>The caller must either own the decision (user_email match) or belong to the same
575+
* tenant as the decision's originator.
576+
*
577+
* <p>Example usage:
578+
*
579+
* <pre>{@code
580+
* DecisionExplanation exp = axonflow.explainDecision("dec_wf123_step4");
581+
* if (exp.isOverrideAvailable()) {
582+
* // offer the user a governed override action
583+
* }
584+
* }</pre>
585+
*
586+
* @param decisionId the global decision identifier returned in the original step gate or
587+
* policy evaluation response
588+
* @return the decision explanation (frozen shape per ADR-043)
589+
* @throws IllegalArgumentException if decisionId is null or empty
590+
* @throws AxonFlowException if the request fails or the decision is past retention
591+
*/
592+
public DecisionExplanation explainDecision(String decisionId) {
593+
if (decisionId == null || decisionId.isEmpty()) {
594+
throw new IllegalArgumentException("decisionId is required");
595+
}
596+
return retryExecutor.execute(
597+
() -> {
598+
// Path-segment encoding: URLEncoder is application/x-www-form-urlencoded
599+
// (space -> '+'), which is wrong for path segments. Replacing '+' with
600+
// '%20' converts the form-encoded output into a valid percent-encoded
601+
// path segment, matching how Go / Python / TypeScript escape the
602+
// decision_id in this path.
603+
String encoded =
604+
java.net.URLEncoder.encode(decisionId, java.nio.charset.StandardCharsets.UTF_8)
605+
.replace("+", "%20");
606+
String path = "/api/v1/decisions/" + encoded + "/explain";
607+
Request httpRequest = buildOrchestratorRequest("GET", path, null);
608+
try (Response response = httpClient.newCall(httpRequest).execute()) {
609+
JsonNode node = parseResponseNode(response);
610+
return objectMapper.treeToValue(node, DecisionExplanation.class);
611+
}
612+
},
613+
"explainDecision");
614+
}
615+
616+
/**
617+
* Asynchronously fetches a decision explanation.
618+
*
619+
* @param decisionId the global decision identifier
620+
* @return a future containing the decision explanation
621+
*/
622+
public CompletableFuture<DecisionExplanation> explainDecisionAsync(String decisionId) {
623+
return CompletableFuture.supplyAsync(() -> explainDecision(decisionId), asyncExecutor);
624+
}
625+
566626
/**
567627
* Gets audit logs for a specific tenant.
568628
*

src/main/java/com/getaxonflow/sdk/types/AuditSearchRequest.java

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,21 @@ public final class AuditSearchRequest {
5555
@JsonProperty("request_type")
5656
private final String requestType;
5757

58+
/** Filter by decision ID (ADR-043). Gathers every audit record tied to one decision. */
59+
@JsonProperty("decision_id")
60+
private final String decisionId;
61+
62+
/** Filter by matched policy name (ADR-043). */
63+
@JsonProperty("policy_name")
64+
private final String policyName;
65+
66+
/**
67+
* Filter by session override ID (ADR-042). Reconstructs an override's full lifecycle:
68+
* override_created → override_used → override_expired | override_revoked.
69+
*/
70+
@JsonProperty("override_id")
71+
private final String overrideId;
72+
5873
@JsonProperty("limit")
5974
private final Integer limit;
6075

@@ -67,6 +82,9 @@ private AuditSearchRequest(Builder builder) {
6782
this.startTime = builder.startTime != null ? builder.startTime.toString() : null;
6883
this.endTime = builder.endTime != null ? builder.endTime.toString() : null;
6984
this.requestType = builder.requestType;
85+
this.decisionId = builder.decisionId;
86+
this.policyName = builder.policyName;
87+
this.overrideId = builder.overrideId;
7088
this.limit = builder.limit != null ? Math.min(builder.limit, 1000) : 100;
7189
this.offset = builder.offset;
7290
}
@@ -91,6 +109,18 @@ public String getRequestType() {
91109
return requestType;
92110
}
93111

112+
public String getDecisionId() {
113+
return decisionId;
114+
}
115+
116+
public String getPolicyName() {
117+
return policyName;
118+
}
119+
120+
public String getOverrideId() {
121+
return overrideId;
122+
}
123+
94124
public Integer getLimit() {
95125
return limit;
96126
}
@@ -148,6 +178,9 @@ public static final class Builder {
148178
private Instant startTime;
149179
private Instant endTime;
150180
private String requestType;
181+
private String decisionId;
182+
private String policyName;
183+
private String overrideId;
151184
private Integer limit;
152185
private Integer offset;
153186

@@ -183,6 +216,30 @@ public Builder requestType(String requestType) {
183216
return this;
184217
}
185218

219+
/**
220+
* Filter by decision ID (ADR-043). Use to gather every audit record tied to a single
221+
* decision — the explain-flow cross-reference pivot.
222+
*/
223+
public Builder decisionId(String decisionId) {
224+
this.decisionId = decisionId;
225+
return this;
226+
}
227+
228+
/** Filter by matched policy name (ADR-043). */
229+
public Builder policyName(String policyName) {
230+
this.policyName = policyName;
231+
return this;
232+
}
233+
234+
/**
235+
* Filter by session override ID (ADR-042). Use to reconstruct an override's full
236+
* lifecycle (override_created → override_used → override_expired | override_revoked).
237+
*/
238+
public Builder overrideId(String overrideId) {
239+
this.overrideId = overrideId;
240+
return this;
241+
}
242+
186243
/** Maximum results to return (default: 100, max: 1000). */
187244
public Builder limit(int limit) {
188245
this.limit = limit;
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright 2025 AxonFlow
3+
*
4+
* Licensed under the Apache License, Version 2.0.
5+
*/
6+
package com.getaxonflow.sdk.types;
7+
8+
import com.fasterxml.jackson.annotation.JsonCreator;
9+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
10+
import com.fasterxml.jackson.annotation.JsonProperty;
11+
import java.time.Instant;
12+
import java.util.Collections;
13+
import java.util.List;
14+
15+
/**
16+
* Canonical payload returned by {@code AxonFlow.explainDecision}.
17+
*
18+
* <p>Shape frozen per ADR-043 (Explainability Data Contract). Additive-only changes are
19+
* non-breaking; renames or removals require a major version bump.
20+
*
21+
* <p>Unknown fields from future platform versions are ignored to preserve forward
22+
* compatibility — see the {@code @JsonIgnoreProperties(ignoreUnknown = true)} annotation.
23+
*/
24+
@JsonIgnoreProperties(ignoreUnknown = true)
25+
public final class DecisionExplanation {
26+
27+
private final String decisionId;
28+
private final Instant timestamp;
29+
private final List<ExplainPolicy> policyMatches;
30+
private final List<ExplainRule> matchedRules;
31+
private final String decision;
32+
private final String reason;
33+
private final String riskLevel;
34+
private final boolean overrideAvailable;
35+
private final String overrideExistingId;
36+
private final int historicalHitCountSession;
37+
private final String policySourceLink;
38+
private final String toolSignature;
39+
40+
@JsonCreator
41+
public DecisionExplanation(
42+
@JsonProperty("decision_id") String decisionId,
43+
@JsonProperty("timestamp") Instant timestamp,
44+
@JsonProperty("policy_matches") List<ExplainPolicy> policyMatches,
45+
@JsonProperty("matched_rules") List<ExplainRule> matchedRules,
46+
@JsonProperty("decision") String decision,
47+
@JsonProperty("reason") String reason,
48+
@JsonProperty("risk_level") String riskLevel,
49+
@JsonProperty("override_available") boolean overrideAvailable,
50+
@JsonProperty("override_existing_id") String overrideExistingId,
51+
@JsonProperty("historical_hit_count_session") int historicalHitCountSession,
52+
@JsonProperty("policy_source_link") String policySourceLink,
53+
@JsonProperty("tool_signature") String toolSignature) {
54+
this.decisionId = decisionId;
55+
this.timestamp = timestamp;
56+
this.policyMatches = policyMatches != null ? policyMatches : Collections.emptyList();
57+
this.matchedRules = matchedRules;
58+
this.decision = decision;
59+
this.reason = reason;
60+
this.riskLevel = riskLevel;
61+
this.overrideAvailable = overrideAvailable;
62+
this.overrideExistingId = overrideExistingId;
63+
this.historicalHitCountSession = historicalHitCountSession;
64+
this.policySourceLink = policySourceLink;
65+
this.toolSignature = toolSignature;
66+
}
67+
68+
public String getDecisionId() {
69+
return decisionId;
70+
}
71+
72+
public Instant getTimestamp() {
73+
return timestamp;
74+
}
75+
76+
public List<ExplainPolicy> getPolicyMatches() {
77+
return policyMatches;
78+
}
79+
80+
public List<ExplainRule> getMatchedRules() {
81+
return matchedRules;
82+
}
83+
84+
public String getDecision() {
85+
return decision;
86+
}
87+
88+
public String getReason() {
89+
return reason;
90+
}
91+
92+
public String getRiskLevel() {
93+
return riskLevel;
94+
}
95+
96+
public boolean isOverrideAvailable() {
97+
return overrideAvailable;
98+
}
99+
100+
public String getOverrideExistingId() {
101+
return overrideExistingId;
102+
}
103+
104+
public int getHistoricalHitCountSession() {
105+
return historicalHitCountSession;
106+
}
107+
108+
public String getPolicySourceLink() {
109+
return policySourceLink;
110+
}
111+
112+
public String getToolSignature() {
113+
return toolSignature;
114+
}
115+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright 2025 AxonFlow
3+
*
4+
* Licensed under the Apache License, Version 2.0.
5+
*/
6+
package com.getaxonflow.sdk.types;
7+
8+
import com.fasterxml.jackson.annotation.JsonCreator;
9+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
10+
import com.fasterxml.jackson.annotation.JsonProperty;
11+
12+
/** A policy reference inside a decision explanation (ADR-043). */
13+
@JsonIgnoreProperties(ignoreUnknown = true)
14+
public final class ExplainPolicy {
15+
16+
private final String policyId;
17+
private final String policyName;
18+
private final String action;
19+
private final String riskLevel; // low | medium | high | critical
20+
private final boolean allowOverride;
21+
private final String policyDescription;
22+
23+
@JsonCreator
24+
public ExplainPolicy(
25+
@JsonProperty("policy_id") String policyId,
26+
@JsonProperty("policy_name") String policyName,
27+
@JsonProperty("action") String action,
28+
@JsonProperty("risk_level") String riskLevel,
29+
@JsonProperty("allow_override") boolean allowOverride,
30+
@JsonProperty("policy_description") String policyDescription) {
31+
this.policyId = policyId;
32+
this.policyName = policyName;
33+
this.action = action;
34+
this.riskLevel = riskLevel;
35+
this.allowOverride = allowOverride;
36+
this.policyDescription = policyDescription;
37+
}
38+
39+
public String getPolicyId() {
40+
return policyId;
41+
}
42+
43+
public String getPolicyName() {
44+
return policyName;
45+
}
46+
47+
public String getAction() {
48+
return action;
49+
}
50+
51+
public String getRiskLevel() {
52+
return riskLevel;
53+
}
54+
55+
public boolean isAllowOverride() {
56+
return allowOverride;
57+
}
58+
59+
public String getPolicyDescription() {
60+
return policyDescription;
61+
}
62+
}

0 commit comments

Comments
 (0)