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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
60 changes: 60 additions & 0 deletions src/main/java/com/getaxonflow/sdk/AxonFlow.java
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,66 @@ public CompletableFuture<AuditSearchResponse> searchAuditLogsAsync(AuditSearchRe
return CompletableFuture.supplyAsync(() -> searchAuditLogs(request), asyncExecutor);
}

/**
* Fetches the full explanation for a previously-made policy decision.
*
* <p>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.
*
* <p>The caller must either own the decision (user_email match) or belong to the same
* tenant as the decision's originator.
*
* <p>Example usage:
*
* <pre>{@code
* DecisionExplanation exp = axonflow.explainDecision("dec_wf123_step4");
* if (exp.isOverrideAvailable()) {
* // offer the user a governed override action
* }
* }</pre>
*
* @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<DecisionExplanation> explainDecisionAsync(String decisionId) {
return CompletableFuture.supplyAsync(() -> explainDecision(decisionId), asyncExecutor);
}

/**
* Gets audit logs for a specific tenant.
*
Expand Down
57 changes: 57 additions & 0 deletions src/main/java/com/getaxonflow/sdk/types/AuditSearchRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
115 changes: 115 additions & 0 deletions src/main/java/com/getaxonflow/sdk/types/DecisionExplanation.java
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>Shape frozen per ADR-043 (Explainability Data Contract). Additive-only changes are
* non-breaking; renames or removals require a major version bump.
*
* <p>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<ExplainPolicy> policyMatches;
private final List<ExplainRule> 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<ExplainPolicy> policyMatches,
@JsonProperty("matched_rules") List<ExplainRule> 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<ExplainPolicy> getPolicyMatches() {
return policyMatches;
}

public List<ExplainRule> 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;
}
}
62 changes: 62 additions & 0 deletions src/main/java/com/getaxonflow/sdk/types/ExplainPolicy.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading