Skip to content

Commit 2bf07e6

Browse files
feat(decisions): add list_decisions client method (#1982)
Java SDK piece of the 5-SDK γ fanout for axonflow-enterprise#1982. Companion to explainDecision; both implement the ADR-043 decision record contract with the same parity discipline. ## Added - AxonFlow.listDecisions(ListDecisionsOptions opts) -> List<DecisionSummary> hitting GET /api/v1/decisions, plus listDecisionsAsync() variant. - DecisionSummary — slim 5-field type, @JsonIgnoreProperties(ignoreUnknown=true) for forward-compat. policyId + toolSignature nullable because pre-α1 audit rows + dynamic-only blocks may not populate them. - ListDecisionsOptions — builder pattern, every field nullable; null values omitted from URL so the platform applies tier defaults. - Extended RateLimitException with new constructor + getLimitType() / getTier() / getUpgrade() methods + nested UpgradeInfo class. Backwards compatible — legacy daily-quota callers keep their 4-arg constructor. - buildListDecisionsQuery — package-private helper, unit-testable. - examples/list-decisions/ — operator example mirroring examples/basic; surfaces RateLimitException.upgrade. ## Tests 11 new contract tests in ListDecisionsTest.java (WireMock-based): - happy path (3-row payload) - filter serialization (every field lands in URL) - omit-unset-filters - 429 → typed RateLimitException with parsed envelope - 429 with malformed body falls back to AxonFlowException(429); never silently succeed - 401 surfaces as AxonFlowException - forward-compat — additive unknown fields ignored - minimal-shape parse (policy_id + tool_signature absent) - buildListDecisionsQuery null + empty + partial - listDecisionsAsync delegates correctly mvn test: 1241 passed across all suites (11 new + 1230 existing). ## Runtime-e2e proof Against setup-e2e-testing.sh enterprise on axonflow-enterprise@feat/decisions-list-endpoint: $ source /tmp/axonflow-e2e-env.sh $ cd examples/list-decisions $ AXONFLOW_LIST_LIMIT=5 mvn -q compile exec:java === Recent decisions (2) === 2026-05-07T14:13:38.464545Z deny f6029a9b-... policy=- tool=- 2026-05-07T14:13:38.453107Z deny 2575a3fe-... policy=- tool=- ⛔ Do not merge — V1.1 release window. Signed-off-by: Saurabh Jain <saurabhjain1592@gmail.com>
1 parent 25dd596 commit 2bf07e6

7 files changed

Lines changed: 838 additions & 1 deletion

File tree

examples/list-decisions/pom.xml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0">
3+
<modelVersion>4.0.0</modelVersion>
4+
5+
<groupId>com.getaxonflow.examples</groupId>
6+
<artifactId>list-decisions</artifactId>
7+
<version>1.0-SNAPSHOT</version>
8+
<packaging>jar</packaging>
9+
10+
<name>AxonFlow Java SDK — listDecisions Example</name>
11+
<description>
12+
Operator example for AxonFlow.listDecisions (Session γ / #1982).
13+
Surfaces the V1 upgrade envelope on RateLimitException so callers
14+
can branch on tier-cap upgrade context.
15+
</description>
16+
17+
<properties>
18+
<maven.compiler.source>11</maven.compiler.source>
19+
<maven.compiler.target>11</maven.compiler.target>
20+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
21+
</properties>
22+
23+
<dependencies>
24+
<dependency>
25+
<groupId>com.getaxonflow</groupId>
26+
<artifactId>axonflow-sdk</artifactId>
27+
<version>7.1.0</version>
28+
</dependency>
29+
</dependencies>
30+
31+
<build>
32+
<plugins>
33+
<plugin>
34+
<groupId>org.codehaus.mojo</groupId>
35+
<artifactId>exec-maven-plugin</artifactId>
36+
<version>3.1.0</version>
37+
<configuration>
38+
<mainClass>com.getaxonflow.examples.ListDecisions</mainClass>
39+
</configuration>
40+
</plugin>
41+
</plugins>
42+
</build>
43+
</project>
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright 2026 AxonFlow
2+
// SPDX-License-Identifier: Apache-2.0
3+
//
4+
// Operator example for AxonFlow.listDecisions (Session γ / #1982).
5+
// Implements the GET /api/v1/decisions contract — companion to
6+
// explainDecision. Surfaces the V1 upgrade envelope on RateLimitException
7+
// so callers can branch on tier-cap upgrade context.
8+
//
9+
// Run from this directory after `mvn install -DskipTests` at the SDK root:
10+
//
11+
// export AXONFLOW_AGENT_URL=http://localhost:8080
12+
// export AXONFLOW_CLIENT_ID=...
13+
// export AXONFLOW_CLIENT_SECRET=...
14+
// mvn -q compile exec:java
15+
//
16+
// Optional filters via env:
17+
// AXONFLOW_LIST_DECISION allow|deny|require_approval
18+
// AXONFLOW_LIST_POLICY_ID e.g. sys_sqli_stacked_drop
19+
// AXONFLOW_LIST_LIMIT integer (server-capped per tier)
20+
package com.getaxonflow.examples;
21+
22+
import com.getaxonflow.sdk.AxonFlow;
23+
import com.getaxonflow.sdk.AxonFlowConfig;
24+
import com.getaxonflow.sdk.exceptions.RateLimitException;
25+
import com.getaxonflow.sdk.types.DecisionSummary;
26+
import com.getaxonflow.sdk.types.ListDecisionsOptions;
27+
28+
import java.util.List;
29+
30+
public class ListDecisions {
31+
32+
public static void main(String[] args) {
33+
String endpoint = envOrDefault("AXONFLOW_AGENT_URL", "http://localhost:8080");
34+
String clientId = System.getenv("AXONFLOW_CLIENT_ID");
35+
String clientSecret = System.getenv("AXONFLOW_CLIENT_SECRET");
36+
if (clientId == null || clientSecret == null) {
37+
System.err.println("AXONFLOW_CLIENT_ID and AXONFLOW_CLIENT_SECRET must be set");
38+
System.exit(1);
39+
return;
40+
}
41+
42+
AxonFlow client =
43+
AxonFlow.create(
44+
AxonFlowConfig.builder()
45+
.endpoint(endpoint)
46+
.clientId(clientId)
47+
.clientSecret(clientSecret)
48+
.build());
49+
50+
ListDecisionsOptions.Builder b = ListDecisionsOptions.builder();
51+
String d = System.getenv("AXONFLOW_LIST_DECISION");
52+
if (d != null) b.decision(d);
53+
String p = System.getenv("AXONFLOW_LIST_POLICY_ID");
54+
if (p != null) b.policyId(p);
55+
String l = System.getenv("AXONFLOW_LIST_LIMIT");
56+
if (l != null) {
57+
try {
58+
b.limit(Integer.parseInt(l));
59+
} catch (NumberFormatException ignored) {
60+
// ignore — leave unset
61+
}
62+
}
63+
64+
try {
65+
List<DecisionSummary> decisions = client.listDecisions(b.build());
66+
System.out.printf("=== Recent decisions (%d) ===%n", decisions.size());
67+
for (DecisionSummary ds : decisions) {
68+
String policy = ds.getPolicyId() != null ? ds.getPolicyId() : "-";
69+
String tool = ds.getToolSignature() != null ? ds.getToolSignature() : "-";
70+
System.out.printf(
71+
" %s %-18s %s policy=%s tool=%s%n",
72+
ds.getTimestamp(), ds.getDecision(), ds.getDecisionId(), policy, tool);
73+
}
74+
} catch (RateLimitException rle) {
75+
System.err.printf("=== Tier limit reached (%s) ===%n", rle.getLimitType());
76+
System.err.printf(" current tier: %s%n", rle.getTier());
77+
System.err.printf(" limit: %d%n", rle.getLimit());
78+
System.err.printf(" reason: %s%n", rle.getMessage());
79+
if (rle.getUpgrade() != null) {
80+
System.err.println();
81+
System.err.printf(
82+
" upgrade to %s: %s%n", rle.getUpgrade().getTier(), rle.getUpgrade().getWording());
83+
System.err.printf(" compare: %s%n", rle.getUpgrade().getCompareUrl());
84+
System.err.printf(" buy: %s%n", rle.getUpgrade().getBuyUrl());
85+
}
86+
System.exit(2);
87+
}
88+
}
89+
90+
private static String envOrDefault(String name, String fallback) {
91+
String v = System.getenv(name);
92+
return v != null ? v : fallback;
93+
}
94+
}

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

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,149 @@ public CompletableFuture<DecisionExplanation> explainDecisionAsync(String decisi
749749
return CompletableFuture.supplyAsync(() -> explainDecision(decisionId), asyncExecutor);
750750
}
751751

752+
// ============================================================================
753+
// listDecisions — Session γ (#1982)
754+
// ============================================================================
755+
756+
/**
757+
* Lists recent policy decisions for the caller's tenant (Session γ / #1982).
758+
*
759+
* <p>Returns the slim 5-field {@link DecisionSummary} page; the platform applies
760+
* a tier-gated cap (5/24h Free + Community, 100/30d Pro + Evaluation, 1000/full
761+
* retention Enterprise). Over-cap requests yield a 429 with the V1 upgrade
762+
* envelope, surfaced as {@link RateLimitException} carrying
763+
* {@code limitType}, {@code tier}, and {@code upgrade.{tier,compareUrl,buyUrl}}.
764+
*
765+
* <p>Filters compose; null fields are omitted from the URL so the platform applies
766+
* tier defaults.
767+
*
768+
* <p>Example:
769+
*
770+
* <pre>{@code
771+
* try {
772+
* List<DecisionSummary> decisions = axonflow.listDecisions(
773+
* ListDecisionsOptions.builder().decision("deny").limit(10).build());
774+
* for (DecisionSummary d : decisions) {
775+
* System.out.println(d.getDecisionId() + " " + d.getDecision());
776+
* }
777+
* } catch (RateLimitException rle) {
778+
* if (rle.getUpgrade() != null) {
779+
* System.out.println("Upgrade at: " + rle.getUpgrade().getBuyUrl());
780+
* }
781+
* }
782+
* }</pre>
783+
*
784+
* @param opts filter and page-size options; null returns the tier-default page
785+
* @return list of {@code DecisionSummary} rows ordered newest-first
786+
* @throws RateLimitException 429 tier-cap; {@code rle.getUpgrade()} exposes
787+
* tier/compareUrl/buyUrl
788+
* @throws AxonFlowException other HTTP errors (401, 5xx, etc.)
789+
*/
790+
public List<DecisionSummary> listDecisions(ListDecisionsOptions opts) {
791+
return retryExecutor.execute(
792+
() -> {
793+
String path = "/api/v1/decisions" + buildListDecisionsQuery(opts);
794+
Request httpRequest = buildOrchestratorRequest("GET", path, null);
795+
796+
try (Response response = executeHttp(httpClient, httpRequest)) {
797+
if (response.code() == 429) {
798+
// Try to parse the V1 upgrade envelope. If the body changed
799+
// shape we still surface the 429 — never silently succeed.
800+
String body = response.body() != null ? response.body().string() : "";
801+
try {
802+
JsonNode envelope = objectMapper.readTree(body);
803+
JsonNode limitTypeNode = envelope.get("limit_type");
804+
if (limitTypeNode != null && !limitTypeNode.isNull()) {
805+
RateLimitException.UpgradeInfo upgrade = null;
806+
JsonNode upgradeNode = envelope.get("upgrade");
807+
if (upgradeNode != null && upgradeNode.isObject()) {
808+
upgrade =
809+
new RateLimitException.UpgradeInfo(
810+
optString(upgradeNode, "tier"),
811+
optString(upgradeNode, "wording"),
812+
optString(upgradeNode, "compare_url"),
813+
optString(upgradeNode, "buy_url"));
814+
}
815+
throw new RateLimitException(
816+
optString(envelope, "error"),
817+
envelope.has("limit") ? envelope.get("limit").asInt() : 0,
818+
envelope.has("remaining") ? envelope.get("remaining").asInt() : 0,
819+
null,
820+
limitTypeNode.asText(),
821+
optString(envelope, "tier"),
822+
upgrade);
823+
}
824+
} catch (com.fasterxml.jackson.core.JsonProcessingException ignored) {
825+
// fall through — never silently succeed on 429
826+
}
827+
throw new AxonFlowException("Too Many Requests: " + body, 429, null);
828+
}
829+
JsonNode node = parseResponseNode(response);
830+
JsonNode decisionsNode = node.get("decisions");
831+
java.util.List<DecisionSummary> result = new java.util.ArrayList<>();
832+
if (decisionsNode != null && decisionsNode.isArray()) {
833+
for (JsonNode row : decisionsNode) {
834+
result.add(objectMapper.treeToValue(row, DecisionSummary.class));
835+
}
836+
}
837+
return result;
838+
}
839+
},
840+
"listDecisions");
841+
}
842+
843+
/**
844+
* Asynchronously lists recent decisions for the caller's tenant.
845+
*
846+
* @param opts filter + page-size options (may be null)
847+
* @return a future resolving to the list of summaries
848+
*/
849+
public CompletableFuture<List<DecisionSummary>> listDecisionsAsync(ListDecisionsOptions opts) {
850+
return CompletableFuture.supplyAsync(() -> listDecisions(opts), asyncExecutor);
851+
}
852+
853+
/** Reads a string field, returning null when absent or not textual. */
854+
private static String optString(JsonNode node, String field) {
855+
JsonNode v = node.get(field);
856+
return (v == null || v.isNull()) ? null : v.asText();
857+
}
858+
859+
/**
860+
* Serialize {@link ListDecisionsOptions} into a "?k=v&k=v" query string.
861+
* Empty when opts or all fields are null. Stable field order so test
862+
* mocks can match the URL exactly.
863+
*/
864+
static String buildListDecisionsQuery(ListDecisionsOptions opts) {
865+
if (opts == null) {
866+
return "";
867+
}
868+
java.util.List<String> pairs = new java.util.ArrayList<>(5);
869+
if (opts.getSince() != null) {
870+
// Instant.toString() already emits RFC 3339 with the "Z" UTC marker.
871+
pairs.add("since=" + urlEncode(opts.getSince().toString()));
872+
}
873+
if (opts.getDecision() != null) {
874+
pairs.add("decision=" + urlEncode(opts.getDecision()));
875+
}
876+
if (opts.getPolicyId() != null) {
877+
pairs.add("policy_id=" + urlEncode(opts.getPolicyId()));
878+
}
879+
if (opts.getToolSignature() != null) {
880+
pairs.add("tool_signature=" + urlEncode(opts.getToolSignature()));
881+
}
882+
if (opts.getLimit() != null) {
883+
pairs.add("limit=" + opts.getLimit());
884+
}
885+
if (pairs.isEmpty()) {
886+
return "";
887+
}
888+
return "?" + String.join("&", pairs);
889+
}
890+
891+
private static String urlEncode(String s) {
892+
return java.net.URLEncoder.encode(s, java.nio.charset.StandardCharsets.UTF_8);
893+
}
894+
752895
/**
753896
* Gets audit logs for a specific tenant.
754897
*

0 commit comments

Comments
 (0)