diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5706f90..125b1d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,23 @@ 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).
+## [5.0.0] - 2026-04-04
+
+### BREAKING CHANGES
+
+- **`X-Tenant-ID` header removed.** The SDK no longer sends `X-Tenant-ID`. The server derives tenant from OAuth2 Client Credentials (Basic auth). Requires platform v6.0.0+.
+- **`getMaterialityClassification()` method renamed.** MAS FEAT `getMateriality()` renamed to `getMaterialityClassification()` to match server JSON field `materiality_classification`.
+
+### Added
+
+- **`Status` field on `PlanResponse`.** The server returns plan status (pending, executing, completed, failed, cancelled) which was previously not parsed by the SDK.
+
+### Fixed
+
+- **MCP examples missing `client_id` and `user_token`** in request body for enterprise MCP handler authentication.
+
+---
+
## [4.3.0] - 2026-03-24
### Added
diff --git a/pom.xml b/pom.xml
index ae8bdc3..3250a22 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
com.getaxonflow
axonflow-sdk
- 4.3.0
+ 5.0.0
jar
AxonFlow Java SDK
diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java
index 6644f55..34a3ea9 100644
--- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java
+++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java
@@ -15,7 +15,16 @@
*/
package com.getaxonflow.sdk;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.getaxonflow.sdk.exceptions.*;
+import com.getaxonflow.sdk.masfeat.MASFEATTypes.*;
+import com.getaxonflow.sdk.simulation.*;
import com.getaxonflow.sdk.telemetry.TelemetryReporter;
import com.getaxonflow.sdk.types.*;
import com.getaxonflow.sdk.types.codegovernance.*;
@@ -23,26 +32,12 @@
import com.getaxonflow.sdk.types.executionreplay.ExecutionReplayTypes.*;
import com.getaxonflow.sdk.types.hitl.HITLTypes.*;
import com.getaxonflow.sdk.types.policies.PolicyTypes.*;
-import com.getaxonflow.sdk.masfeat.MASFEATTypes.*;
import com.getaxonflow.sdk.types.webhook.WebhookTypes.*;
-import com.getaxonflow.sdk.simulation.*;
import com.getaxonflow.sdk.util.*;
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.DeserializationFeature;
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.SerializationFeature;
-import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
-import okhttp3.*;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
import java.io.BufferedReader;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStreamReader;
-import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
@@ -55,19 +50,24 @@
import java.util.concurrent.Executor;
import java.util.concurrent.ForkJoinPool;
import java.util.function.Consumer;
+import okhttp3.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* Main client for interacting with the AxonFlow API.
*
*
The AxonFlow client provides methods for:
+ *
*
- * - Gateway Mode: Pre-check and audit for your own LLM calls
- * - Proxy Mode: Let AxonFlow handle policy and LLM routing
- * - Planning: Multi-agent planning (MAP) operations
- * - Connectors: MCP connector discovery and queries
+ * - Gateway Mode: Pre-check and audit for your own LLM calls
+ *
- Proxy Mode: Let AxonFlow handle policy and LLM routing
+ *
- Planning: Multi-agent planning (MAP) operations
+ *
- Connectors: MCP connector discovery and queries
*
*
* Gateway Mode Example
+ *
* {@code
* AxonFlow axonflow = AxonFlow.builder()
* .agentUrl("http://localhost:8080")
@@ -98,6 +98,7 @@
* }
*
* Proxy Mode Example
+ *
* {@code
* ClientResponse response = axonflow.proxyLLMCall(
* ClientRequest.builder()
@@ -118,6028 +119,6405 @@
*/
public final class AxonFlow implements Closeable {
- private static final Logger logger = LoggerFactory.getLogger(AxonFlow.class);
- private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
-
- private final AxonFlowConfig config;
- private final OkHttpClient httpClient;
- private final ObjectMapper objectMapper;
- private final RetryExecutor retryExecutor;
- private final ResponseCache cache;
- private final Executor asyncExecutor;
- private volatile String sessionCookie; // Session cookie for Customer Portal authentication
- private final MASFEATNamespace masfeatNamespace;
-
- private AxonFlow(AxonFlowConfig config) {
- this.config = Objects.requireNonNull(config, "config cannot be null");
- this.httpClient = HttpClientFactory.create(config);
- this.objectMapper = createObjectMapper();
- this.retryExecutor = new RetryExecutor(config.getRetryConfig());
- this.cache = new ResponseCache(config.getCacheConfig());
- this.asyncExecutor = ForkJoinPool.commonPool();
- this.masfeatNamespace = new MASFEATNamespace();
-
- logger.info("AxonFlow client initialized for {}", config.getEndpoint());
-
- // Send telemetry ping (fire-and-forget).
- boolean hasCredentials = config.getClientId() != null && !config.getClientId().isEmpty()
- && config.getClientSecret() != null && !config.getClientSecret().isEmpty();
- TelemetryReporter.sendPing(
- config.getMode() != null ? config.getMode().getValue() : "production",
- config.getEndpoint(),
- config.getTelemetry(),
- config.isDebug(),
- hasCredentials
- );
- }
+ private static final Logger logger = LoggerFactory.getLogger(AxonFlow.class);
+ private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
+
+ private final AxonFlowConfig config;
+ private final OkHttpClient httpClient;
+ private final ObjectMapper objectMapper;
+ private final RetryExecutor retryExecutor;
+ private final ResponseCache cache;
+ private final Executor asyncExecutor;
+ private volatile String sessionCookie; // Session cookie for Customer Portal authentication
+ private final MASFEATNamespace masfeatNamespace;
+
+ private AxonFlow(AxonFlowConfig config) {
+ this.config = Objects.requireNonNull(config, "config cannot be null");
+ this.httpClient = HttpClientFactory.create(config);
+ this.objectMapper = createObjectMapper();
+ this.retryExecutor = new RetryExecutor(config.getRetryConfig());
+ this.cache = new ResponseCache(config.getCacheConfig());
+ this.asyncExecutor = ForkJoinPool.commonPool();
+ this.masfeatNamespace = new MASFEATNamespace();
+
+ logger.info("AxonFlow client initialized for {}", config.getEndpoint());
+
+ // Send telemetry ping (fire-and-forget).
+ boolean hasCredentials =
+ config.getClientId() != null
+ && !config.getClientId().isEmpty()
+ && config.getClientSecret() != null
+ && !config.getClientSecret().isEmpty();
+ TelemetryReporter.sendPing(
+ config.getMode() != null ? config.getMode().getValue() : "production",
+ config.getEndpoint(),
+ config.getTelemetry(),
+ config.isDebug(),
+ hasCredentials);
+ }
+
+ private static ObjectMapper createObjectMapper() {
+ ObjectMapper mapper = new ObjectMapper();
+ mapper.registerModule(new JavaTimeModule());
+ mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
+ mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ mapper.configure(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false);
+ return mapper;
+ }
+
+ /**
+ * Compares two semantic version strings numerically (major.minor.patch). Returns negative if a <
+ * b, zero if equal, positive if a > b.
+ */
+ private static int compareSemver(String a, String b) {
+ String[] partsA = a.split("\\.");
+ String[] partsB = b.split("\\.");
+ int length = Math.max(partsA.length, partsB.length);
+ for (int i = 0; i < length; i++) {
+ int numA = 0;
+ int numB = 0;
+ if (i < partsA.length) {
+ try {
+ String cleanA =
+ partsA[i].contains("-") ? partsA[i].substring(0, partsA[i].indexOf("-")) : partsA[i];
+ numA = Integer.parseInt(cleanA);
+ } catch (NumberFormatException ignored) {
+ // default to 0
+ }
+ }
+ if (i < partsB.length) {
+ try {
+ String cleanB =
+ partsB[i].contains("-") ? partsB[i].substring(0, partsB[i].indexOf("-")) : partsB[i];
+ numB = Integer.parseInt(cleanB);
+ } catch (NumberFormatException ignored) {
+ // default to 0
+ }
+ }
+ if (numA != numB) {
+ return Integer.compare(numA, numB);
+ }
+ }
+ return 0;
+ }
+
+ // ========================================================================
+ // Factory Methods
+ // ========================================================================
+
+ /**
+ * Creates a new builder for AxonFlow configuration.
+ *
+ * @return a new builder
+ */
+ public static AxonFlowConfig.Builder builder() {
+ return AxonFlowConfig.builder();
+ }
+
+ /**
+ * Creates an AxonFlow client with the given configuration.
+ *
+ * @param config the configuration
+ * @return a new AxonFlow client
+ */
+ public static AxonFlow create(AxonFlowConfig config) {
+ return new AxonFlow(config);
+ }
+
+ /**
+ * Creates an AxonFlow client from environment variables.
+ *
+ * @return a new AxonFlow client
+ * @see AxonFlowConfig#fromEnvironment()
+ */
+ public static AxonFlow fromEnvironment() {
+ return new AxonFlow(AxonFlowConfig.fromEnvironment());
+ }
+
+ /**
+ * Creates an AxonFlow client in sandbox mode.
+ *
+ * @param agentUrl the Agent URL
+ * @return a new AxonFlow client in sandbox mode
+ */
+ public static AxonFlow sandbox(String agentUrl) {
+ return new AxonFlow(AxonFlowConfig.builder().agentUrl(agentUrl).mode(Mode.SANDBOX).build());
+ }
+
+ // ========================================================================
+ // Health Check
+ // ========================================================================
+
+ /**
+ * Checks if the AxonFlow Agent is healthy.
+ *
+ * @return the health status
+ * @throws ConnectionException if the Agent cannot be reached
+ */
+ public HealthStatus healthCheck() {
+ HealthStatus status =
+ retryExecutor.execute(
+ () -> {
+ Request request = buildRequest("GET", "/health", null);
+ try (Response response = httpClient.newCall(request).execute()) {
+ return parseResponse(response, HealthStatus.class);
+ }
+ },
+ "healthCheck");
+
+ if (status.getSdkCompatibility() != null
+ && status.getSdkCompatibility().getMinSdkVersion() != null
+ && !"unknown".equals(AxonFlowConfig.SDK_VERSION)
+ && compareSemver(
+ AxonFlowConfig.SDK_VERSION, status.getSdkCompatibility().getMinSdkVersion())
+ < 0) {
+ logger.warn(
+ "SDK version {} is below minimum supported version {}. Please upgrade.",
+ AxonFlowConfig.SDK_VERSION,
+ status.getSdkCompatibility().getMinSdkVersion());
+ }
+
+ return status;
+ }
+
+ /**
+ * Asynchronously checks if the AxonFlow Agent is healthy.
+ *
+ * @return a future containing the health status
+ */
+ public CompletableFuture healthCheckAsync() {
+ return CompletableFuture.supplyAsync(this::healthCheck, asyncExecutor);
+ }
+
+ // ========================================================================
+ // MAS FEAT Namespace Accessor
+ // ========================================================================
+
+ /**
+ * Returns the MAS FEAT (Monetary Authority of Singapore - Fairness, Ethics, Accountability,
+ * Transparency) compliance namespace.
+ *
+ * Enterprise Feature: Requires AxonFlow Enterprise license.
+ *
+ *
Example usage:
+ *
+ *
{@code
+ * AISystemRegistry system = client.masfeat().registerSystem(
+ * RegisterSystemRequest.builder()
+ * .systemId("credit-scoring-ai")
+ * .systemName("Credit Scoring AI")
+ * .useCase(AISystemUseCase.CREDIT_SCORING)
+ * .ownerTeam("Risk Management")
+ * .customerImpact(4)
+ * .modelComplexity(3)
+ * .humanReliance(5)
+ * .build()
+ * );
+ * }
+ *
+ * @return the MAS FEAT compliance namespace
+ */
+ public MASFEATNamespace masfeat() {
+ return masfeatNamespace;
+ }
+
+ /**
+ * Checks if the AxonFlow Orchestrator is healthy.
+ *
+ * @return the health status
+ * @throws ConnectionException if the Orchestrator cannot be reached
+ */
+ public HealthStatus orchestratorHealthCheck() {
+ return retryExecutor.execute(
+ () -> {
+ Request httpRequest = buildOrchestratorRequest("GET", "/health", null);
+ try (Response response = httpClient.newCall(httpRequest).execute()) {
+ if (!response.isSuccessful()) {
+ return new HealthStatus("unhealthy", null, null, null, null, null);
+ }
+ return parseResponse(response, HealthStatus.class);
+ }
+ },
+ "orchestratorHealthCheck");
+ }
+
+ /**
+ * Asynchronously checks if the AxonFlow Orchestrator is healthy.
+ *
+ * @return a future containing the health status
+ */
+ public CompletableFuture orchestratorHealthCheckAsync() {
+ return CompletableFuture.supplyAsync(this::orchestratorHealthCheck, asyncExecutor);
+ }
+
+ // ========================================================================
+ // Gateway Mode - Policy Pre-check and Audit
+ // ========================================================================
+
+ /**
+ * Pre-checks a request against policies (Gateway Mode - Step 1).
+ *
+ * This is the first step in Gateway Mode. If approved, make your LLM call directly, then call
+ * {@link #auditLLMCall(AuditOptions)} to complete the flow.
+ *
+ * @param request the policy approval request
+ * @return the approval result with context ID for auditing
+ * @throws PolicyViolationException if the request is blocked by policy
+ * @throws AuthenticationException if authentication fails
+ */
+ public PolicyApprovalResult getPolicyApprovedContext(PolicyApprovalRequest request) {
+ Objects.requireNonNull(request, "request cannot be null");
+
+ // Use smart default for clientId - enables zero-config community mode
+ String effectiveClientId =
+ (request.getClientId() != null && !request.getClientId().isEmpty())
+ ? request.getClientId()
+ : getEffectiveClientId();
- private static ObjectMapper createObjectMapper() {
- ObjectMapper mapper = new ObjectMapper();
- mapper.registerModule(new JavaTimeModule());
- mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
- mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
- mapper.configure(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false);
- return mapper;
- }
+ Map ctx = request.getContext();
+ PolicyApprovalRequest effectiveRequest =
+ PolicyApprovalRequest.builder()
+ .userToken(request.getUserToken())
+ .query(request.getQuery())
+ .dataSources(request.getDataSources())
+ .context(ctx == null || ctx.isEmpty() ? null : ctx)
+ .clientId(effectiveClientId)
+ .build();
- /**
- * Compares two semantic version strings numerically (major.minor.patch).
- * Returns negative if a < b, zero if equal, positive if a > b.
- */
- private static int compareSemver(String a, String b) {
- String[] partsA = a.split("\\.");
- String[] partsB = b.split("\\.");
- int length = Math.max(partsA.length, partsB.length);
- for (int i = 0; i < length; i++) {
- int numA = 0;
- int numB = 0;
- if (i < partsA.length) {
- try {
- String cleanA = partsA[i].contains("-") ? partsA[i].substring(0, partsA[i].indexOf("-")) : partsA[i];
- numA = Integer.parseInt(cleanA);
- } catch (NumberFormatException ignored) {
- // default to 0
+ final PolicyApprovalRequest finalRequest = effectiveRequest;
+ return retryExecutor.execute(
+ () -> {
+ Request httpRequest = buildRequest("POST", "/api/policy/pre-check", finalRequest);
+ try (Response response = httpClient.newCall(httpRequest).execute()) {
+ PolicyApprovalResult result = parseResponse(response, PolicyApprovalResult.class);
+
+ if (!result.isApproved()) {
+ throw new PolicyViolationException(
+ result.getBlockReason(), result.getBlockingPolicyName(), result.getPolicies());
+ }
+
+ return result;
+ }
+ },
+ "getPolicyApprovedContext");
+ }
+
+ /**
+ * Alias for {@link #getPolicyApprovedContext(PolicyApprovalRequest)}.
+ *
+ * @param request the policy approval request
+ * @return the approval result
+ */
+ public PolicyApprovalResult preCheck(PolicyApprovalRequest request) {
+ return getPolicyApprovedContext(request);
+ }
+
+ /**
+ * Asynchronously pre-checks a request against policies.
+ *
+ * @param request the policy approval request
+ * @return a future containing the approval result
+ */
+ public CompletableFuture getPolicyApprovedContextAsync(
+ PolicyApprovalRequest request) {
+ return CompletableFuture.supplyAsync(() -> getPolicyApprovedContext(request), asyncExecutor);
+ }
+
+ /**
+ * Audits an LLM call for compliance tracking (Gateway Mode - Step 3).
+ *
+ * Call this after making your direct LLM call to record it for compliance and observability.
+ *
+ * @param options the audit options including context ID from pre-check
+ * @return the audit result
+ * @throws AxonFlowException if the audit fails
+ */
+ public AuditResult auditLLMCall(AuditOptions options) {
+ Objects.requireNonNull(options, "options cannot be null");
+
+ // Use smart default for clientId - enables zero-config community mode
+ String effectiveClientId =
+ (options.getClientId() != null && !options.getClientId().isEmpty())
+ ? options.getClientId()
+ : getEffectiveClientId();
+
+ // Create effective options with the smart default clientId
+ AuditOptions.Builder builder =
+ AuditOptions.builder()
+ .contextId(options.getContextId())
+ .clientId(effectiveClientId)
+ .responseSummary(options.getResponseSummary())
+ .provider(options.getProvider())
+ .model(options.getModel())
+ .tokenUsage(options.getTokenUsage())
+ .metadata(options.getMetadata())
+ .success(options.getSuccess())
+ .errorMessage(options.getErrorMessage());
+
+ // Handle null latencyMs (builder takes primitive long)
+ if (options.getLatencyMs() != null) {
+ builder.latencyMs(options.getLatencyMs());
+ }
+
+ AuditOptions effectiveOptions = builder.build();
+
+ return retryExecutor.execute(
+ () -> {
+ Request httpRequest = buildRequest("POST", "/api/audit/llm-call", effectiveOptions);
+ try (Response response = httpClient.newCall(httpRequest).execute()) {
+ return parseResponse(response, AuditResult.class);
+ }
+ },
+ "auditLLMCall");
+ }
+
+ /**
+ * Asynchronously audits an LLM call.
+ *
+ * @param options the audit options
+ * @return a future containing the audit result
+ */
+ public CompletableFuture auditLLMCallAsync(AuditOptions options) {
+ return CompletableFuture.supplyAsync(() -> auditLLMCall(options), asyncExecutor);
+ }
+
+ // ========================================================================
+ // Audit Log Read Methods
+ // ========================================================================
+
+ /**
+ * Searches audit logs with flexible filtering options.
+ *
+ * Example usage:
+ *
+ *
{@code
+ * AuditSearchResponse response = axonflow.searchAuditLogs(
+ * AuditSearchRequest.builder()
+ * .userEmail("analyst@company.com")
+ * .startTime(Instant.now().minus(Duration.ofDays(7)))
+ * .requestType("llm_chat")
+ * .limit(100)
+ * .build());
+ *
+ * for (AuditLogEntry entry : response.getEntries()) {
+ * System.out.println(entry.getId() + ": " + entry.getQuerySummary());
+ * }
+ * }
+ *
+ * @param request the search request with optional filters
+ * @return the search response containing matching audit log entries
+ * @throws AxonFlowException if the search fails
+ */
+ public AuditSearchResponse searchAuditLogs(AuditSearchRequest request) {
+ return retryExecutor.execute(
+ () -> {
+ AuditSearchRequest req = request != null ? request : AuditSearchRequest.builder().build();
+ Request httpRequest = buildOrchestratorRequest("POST", "/api/v1/audit/search", req);
+ try (Response response = httpClient.newCall(httpRequest).execute()) {
+ JsonNode node = parseResponseNode(response);
+
+ // Handle both array and wrapped response formats
+ if (node.isArray()) {
+ List entries =
+ objectMapper.convertValue(node, new TypeReference>() {});
+ return AuditSearchResponse.fromArray(
+ entries,
+ req.getLimit() != null ? req.getLimit() : 100,
+ req.getOffset() != null ? req.getOffset() : 0);
+ }
+
+ return objectMapper.treeToValue(node, AuditSearchResponse.class);
+ }
+ },
+ "searchAuditLogs");
+ }
+
+ /**
+ * Searches audit logs with default options (last 100 entries).
+ *
+ * @return the search response
+ */
+ public AuditSearchResponse searchAuditLogs() {
+ return searchAuditLogs(null);
+ }
+
+ /**
+ * Asynchronously searches audit logs.
+ *
+ * @param request the search request
+ * @return a future containing the search response
+ */
+ public CompletableFuture searchAuditLogsAsync(AuditSearchRequest request) {
+ return CompletableFuture.supplyAsync(() -> searchAuditLogs(request), asyncExecutor);
+ }
+
+ /**
+ * Gets audit logs for a specific tenant.
+ *
+ * Example usage:
+ *
+ *
{@code
+ * AuditSearchResponse response = axonflow.getAuditLogsByTenant("tenant-abc",
+ * AuditQueryOptions.builder()
+ * .limit(100)
+ * .offset(50)
+ * .build());
+ *
+ * System.out.println("Total entries: " + response.getTotal());
+ * System.out.println("Has more: " + response.hasMore());
+ * }
+ *
+ * @param tenantId the tenant ID to query
+ * @param options optional pagination options
+ * @return the search response containing audit log entries for the tenant
+ * @throws IllegalArgumentException if tenantId is null or empty
+ * @throws AxonFlowException if the query fails
+ */
+ public AuditSearchResponse getAuditLogsByTenant(String tenantId, AuditQueryOptions options) {
+ if (tenantId == null || tenantId.isEmpty()) {
+ throw new IllegalArgumentException("tenantId is required");
+ }
+
+ return retryExecutor.execute(
+ () -> {
+ AuditQueryOptions opts = options != null ? options : AuditQueryOptions.defaults();
+ String encodedTenantId = java.net.URLEncoder.encode(tenantId, "UTF-8");
+ String path =
+ "/api/v1/audit/tenant/"
+ + encodedTenantId
+ + "?limit="
+ + opts.getLimit()
+ + "&offset="
+ + opts.getOffset();
+
+ Request httpRequest = buildOrchestratorRequest("GET", path, null);
+ try (Response response = httpClient.newCall(httpRequest).execute()) {
+ JsonNode node = parseResponseNode(response);
+
+ // Handle both array and wrapped response formats
+ if (node.isArray()) {
+ List entries =
+ objectMapper.convertValue(node, new TypeReference>() {});
+ return AuditSearchResponse.fromArray(entries, opts.getLimit(), opts.getOffset());
+ }
+
+ return objectMapper.treeToValue(node, AuditSearchResponse.class);
+ }
+ },
+ "getAuditLogsByTenant");
+ }
+
+ /**
+ * Gets audit logs for a specific tenant with default options.
+ *
+ * @param tenantId the tenant ID to query
+ * @return the search response
+ */
+ public AuditSearchResponse getAuditLogsByTenant(String tenantId) {
+ return getAuditLogsByTenant(tenantId, null);
+ }
+
+ /**
+ * Asynchronously gets audit logs for a specific tenant.
+ *
+ * @param tenantId the tenant ID to query
+ * @param options optional pagination options
+ * @return a future containing the search response
+ */
+ public CompletableFuture getAuditLogsByTenantAsync(
+ String tenantId, AuditQueryOptions options) {
+ return CompletableFuture.supplyAsync(
+ () -> getAuditLogsByTenant(tenantId, options), asyncExecutor);
+ }
+
+ // ========================================================================
+ // Audit Tool Call
+ // ========================================================================
+
+ /**
+ * Audits a non-LLM tool call for compliance and observability.
+ *
+ * Records tool invocations such as function calls, MCP operations, or API calls to the audit
+ * log.
+ *
+ *
Example usage:
+ *
+ *
{@code
+ * AuditToolCallResponse response = axonflow.auditToolCall(
+ * AuditToolCallRequest.builder()
+ * .toolName("web_search")
+ * .toolType("function")
+ * .input(Map.of("query", "latest news"))
+ * .output(Map.of("results", 5))
+ * .workflowId("wf_123")
+ * .durationMs(450L)
+ * .success(true)
+ * .build());
+ * }
+ *
+ * @param request the audit tool call request
+ * @return the audit tool call response with audit ID
+ * @throws NullPointerException if request is null
+ * @throws IllegalArgumentException if tool_name is null or empty
+ * @throws AxonFlowException if the audit fails
+ */
+ public AuditToolCallResponse auditToolCall(AuditToolCallRequest request) {
+ Objects.requireNonNull(request, "request cannot be null");
+
+ return retryExecutor.execute(
+ () -> {
+ Request httpRequest =
+ buildOrchestratorRequest("POST", "/api/v1/audit/tool-call", request);
+ try (Response response = httpClient.newCall(httpRequest).execute()) {
+ return parseResponse(response, AuditToolCallResponse.class);
+ }
+ },
+ "auditToolCall");
+ }
+
+ /**
+ * Asynchronously audits a non-LLM tool call.
+ *
+ * @param request the audit tool call request
+ * @return a future containing the audit tool call response
+ */
+ public CompletableFuture auditToolCallAsync(AuditToolCallRequest request) {
+ return CompletableFuture.supplyAsync(() -> auditToolCall(request), asyncExecutor);
+ }
+
+ // ========================================================================
+ // Circuit Breaker Observability
+ // ========================================================================
+
+ /**
+ * Gets the current circuit breaker status, including all active (tripped) circuits.
+ *
+ * Example usage:
+ *
+ *
{@code
+ * CircuitBreakerStatusResponse status = axonflow.getCircuitBreakerStatus();
+ * System.out.println("Active circuits: " + status.getCount());
+ * System.out.println("Emergency stop: " + status.isEmergencyStopActive());
+ * }
+ *
+ * @return the circuit breaker status
+ * @throws AxonFlowException if the request fails
+ */
+ public CircuitBreakerStatusResponse getCircuitBreakerStatus() {
+ return retryExecutor.execute(
+ () -> {
+ Request httpRequest =
+ buildOrchestratorRequest("GET", "/api/v1/circuit-breaker/status", null);
+ try (Response response = httpClient.newCall(httpRequest).execute()) {
+ JsonNode node = parseResponseNode(response);
+ if (node.has("data") && node.get("data").isObject()) {
+ return objectMapper.treeToValue(node.get("data"), CircuitBreakerStatusResponse.class);
+ }
+ return objectMapper.treeToValue(node, CircuitBreakerStatusResponse.class);
+ }
+ },
+ "getCircuitBreakerStatus");
+ }
+
+ /**
+ * Asynchronously gets the current circuit breaker status.
+ *
+ * @return a future containing the circuit breaker status
+ */
+ public CompletableFuture getCircuitBreakerStatusAsync() {
+ return CompletableFuture.supplyAsync(this::getCircuitBreakerStatus, asyncExecutor);
+ }
+
+ /**
+ * Gets the circuit breaker history, including past trips and resets.
+ *
+ * Example usage:
+ *
+ *
{@code
+ * CircuitBreakerHistoryResponse history = axonflow.getCircuitBreakerHistory(50);
+ * for (CircuitBreakerHistoryEntry entry : history.getHistory()) {
+ * System.out.println(entry.getScope() + "/" + entry.getScopeId() + " - " + entry.getState());
+ * }
+ * }
+ *
+ * @param limit the maximum number of history entries to return
+ * @return the circuit breaker history
+ * @throws IllegalArgumentException if limit is less than 1
+ * @throws AxonFlowException if the request fails
+ */
+ public CircuitBreakerHistoryResponse getCircuitBreakerHistory(int limit) {
+ if (limit < 1) {
+ throw new IllegalArgumentException("limit must be at least 1");
+ }
+
+ return retryExecutor.execute(
+ () -> {
+ String path = "/api/v1/circuit-breaker/history?limit=" + limit;
+ Request httpRequest = buildOrchestratorRequest("GET", path, null);
+ try (Response response = httpClient.newCall(httpRequest).execute()) {
+ JsonNode node = parseResponseNode(response);
+ if (node.has("data") && node.get("data").isObject()) {
+ return objectMapper.treeToValue(
+ node.get("data"), CircuitBreakerHistoryResponse.class);
+ }
+ return objectMapper.treeToValue(node, CircuitBreakerHistoryResponse.class);
+ }
+ },
+ "getCircuitBreakerHistory");
+ }
+
+ /**
+ * Asynchronously gets the circuit breaker history.
+ *
+ * @param limit the maximum number of history entries to return
+ * @return a future containing the circuit breaker history
+ */
+ public CompletableFuture getCircuitBreakerHistoryAsync(int limit) {
+ return CompletableFuture.supplyAsync(() -> getCircuitBreakerHistory(limit), asyncExecutor);
+ }
+
+ /**
+ * Gets the circuit breaker configuration for a specific tenant.
+ *
+ * Example usage:
+ *
+ *
{@code
+ * CircuitBreakerConfig config = axonflow.getCircuitBreakerConfig("tenant_123");
+ * System.out.println("Error threshold: " + config.getErrorThreshold());
+ * System.out.println("Auto recovery: " + config.isEnableAutoRecovery());
+ * }
+ *
+ * @param tenantId the tenant ID to get configuration for
+ * @return the circuit breaker configuration
+ * @throws NullPointerException if tenantId is null
+ * @throws IllegalArgumentException if tenantId is empty
+ * @throws AxonFlowException if the request fails
+ */
+ public CircuitBreakerConfig getCircuitBreakerConfig(String tenantId) {
+ Objects.requireNonNull(tenantId, "tenantId cannot be null");
+ if (tenantId.isEmpty()) {
+ throw new IllegalArgumentException("tenantId cannot be empty");
+ }
+
+ return retryExecutor.execute(
+ () -> {
+ String path =
+ "/api/v1/circuit-breaker/config?tenant_id="
+ + java.net.URLEncoder.encode(tenantId, java.nio.charset.StandardCharsets.UTF_8);
+ Request httpRequest = buildOrchestratorRequest("GET", path, null);
+ try (Response response = httpClient.newCall(httpRequest).execute()) {
+ JsonNode node = parseResponseNode(response);
+ if (node.has("data") && node.get("data").isObject()) {
+ return objectMapper.treeToValue(node.get("data"), CircuitBreakerConfig.class);
+ }
+ return objectMapper.treeToValue(node, CircuitBreakerConfig.class);
+ }
+ },
+ "getCircuitBreakerConfig");
+ }
+
+ /**
+ * Asynchronously gets the circuit breaker configuration for a specific tenant.
+ *
+ * @param tenantId the tenant ID to get configuration for
+ * @return a future containing the circuit breaker configuration
+ */
+ public CompletableFuture getCircuitBreakerConfigAsync(String tenantId) {
+ return CompletableFuture.supplyAsync(() -> getCircuitBreakerConfig(tenantId), asyncExecutor);
+ }
+
+ /**
+ * Updates the circuit breaker configuration for a tenant.
+ *
+ * Example usage:
+ *
+ *
{@code
+ * CircuitBreakerConfig updated = axonflow.updateCircuitBreakerConfig(
+ * CircuitBreakerConfigUpdate.builder()
+ * .tenantId("tenant_123")
+ * .errorThreshold(10)
+ * .violationThreshold(5)
+ * .enableAutoRecovery(true)
+ * .build());
+ * }
+ *
+ * @param config the configuration update
+ * @return confirmation with tenant_id and message
+ * @throws NullPointerException if config is null
+ * @throws AxonFlowException if the request fails
+ */
+ public CircuitBreakerConfigUpdateResponse updateCircuitBreakerConfig(
+ CircuitBreakerConfigUpdate config) {
+ Objects.requireNonNull(config, "config cannot be null");
+
+ return retryExecutor.execute(
+ () -> {
+ Request httpRequest =
+ buildOrchestratorRequest("PUT", "/api/v1/circuit-breaker/config", config);
+ try (Response response = httpClient.newCall(httpRequest).execute()) {
+ JsonNode node = parseResponseNode(response);
+ if (node.has("data") && node.get("data").isObject()) {
+ return objectMapper.treeToValue(
+ node.get("data"), CircuitBreakerConfigUpdateResponse.class);
+ }
+ return objectMapper.treeToValue(node, CircuitBreakerConfigUpdateResponse.class);
+ }
+ },
+ "updateCircuitBreakerConfig");
+ }
+
+ /**
+ * Asynchronously updates the circuit breaker configuration for a tenant.
+ *
+ * @param config the configuration update
+ * @return a future containing the update confirmation
+ */
+ public CompletableFuture updateCircuitBreakerConfigAsync(
+ CircuitBreakerConfigUpdate config) {
+ return CompletableFuture.supplyAsync(() -> updateCircuitBreakerConfig(config), asyncExecutor);
+ }
+
+ // ========================================================================
+ // Policy Simulation
+ // ========================================================================
+
+ /**
+ * Simulates policy evaluation against a query without actually enforcing policies.
+ *
+ * This is a dry-run mode that shows which policies would match and what actions would be
+ * taken, without blocking the request.
+ *
+ *
Example usage:
+ *
+ *
{@code
+ * SimulatePoliciesResponse result = axonflow.simulatePolicies(
+ * SimulatePoliciesRequest.builder()
+ * .query("Transfer $50,000 to external account")
+ * .requestType("execute")
+ * .build());
+ * System.out.println("Allowed: " + result.isAllowed());
+ * System.out.println("Applied policies: " + result.getAppliedPolicies());
+ * System.out.println("Risk score: " + result.getRiskScore());
+ * }
+ *
+ * Evaluation+ Feature: Requires AxonFlow Evaluation tier or higher.
+ *
+ * @param request the simulation request
+ * @return the simulation result
+ * @throws NullPointerException if request is null
+ * @throws AxonFlowException if the request fails
+ */
+ public SimulatePoliciesResponse simulatePolicies(SimulatePoliciesRequest request) {
+ Objects.requireNonNull(request, "request cannot be null");
+
+ return retryExecutor.execute(
+ () -> {
+ Request httpRequest =
+ buildOrchestratorRequest("POST", "/api/v1/policies/simulate", request);
+ try (Response response = httpClient.newCall(httpRequest).execute()) {
+ JsonNode node = parseResponseNode(response);
+ if (node.has("data") && node.get("data").isObject()) {
+ return objectMapper.treeToValue(node.get("data"), SimulatePoliciesResponse.class);
+ }
+ return objectMapper.treeToValue(node, SimulatePoliciesResponse.class);
+ }
+ },
+ "simulatePolicies");
+ }
+
+ /**
+ * Asynchronously simulates policy evaluation against a query.
+ *
+ * @param request the simulation request
+ * @return a future containing the simulation result
+ */
+ public CompletableFuture simulatePoliciesAsync(
+ SimulatePoliciesRequest request) {
+ return CompletableFuture.supplyAsync(() -> simulatePolicies(request), asyncExecutor);
+ }
+
+ /**
+ * Generates a policy impact report by testing a set of inputs against a specific policy.
+ *
+ * This helps you understand how a policy would affect real traffic before deploying it.
+ *
+ *
Example usage:
+ *
+ *
{@code
+ * ImpactReportResponse report = axonflow.getPolicyImpactReport(
+ * ImpactReportRequest.builder()
+ * .policyId("policy_block_pii")
+ * .inputs(List.of(
+ * ImpactReportInput.builder().query("My SSN is 123-45-6789").build(),
+ * ImpactReportInput.builder().query("What is the weather?").build()))
+ * .build());
+ * System.out.println("Match rate: " + report.getMatchRate());
+ * System.out.println("Block rate: " + report.getBlockRate());
+ * }
+ *
+ * Evaluation+ Feature: Requires AxonFlow Evaluation tier or higher.
+ *
+ * @param request the impact report request
+ * @return the impact report
+ * @throws NullPointerException if request is null
+ * @throws AxonFlowException if the request fails
+ */
+ public ImpactReportResponse getPolicyImpactReport(ImpactReportRequest request) {
+ Objects.requireNonNull(request, "request cannot be null");
+
+ return retryExecutor.execute(
+ () -> {
+ Request httpRequest =
+ buildOrchestratorRequest("POST", "/api/v1/policies/impact-report", request);
+ try (Response response = httpClient.newCall(httpRequest).execute()) {
+ JsonNode node = parseResponseNode(response);
+ if (node.has("data") && node.get("data").isObject()) {
+ return objectMapper.treeToValue(node.get("data"), ImpactReportResponse.class);
+ }
+ return objectMapper.treeToValue(node, ImpactReportResponse.class);
+ }
+ },
+ "getPolicyImpactReport");
+ }
+
+ /**
+ * Asynchronously generates a policy impact report.
+ *
+ * @param request the impact report request
+ * @return a future containing the impact report
+ */
+ public CompletableFuture getPolicyImpactReportAsync(
+ ImpactReportRequest request) {
+ return CompletableFuture.supplyAsync(() -> getPolicyImpactReport(request), asyncExecutor);
+ }
+
+ /**
+ * Scans all active policies for conflicts.
+ *
+ * Example usage:
+ *
+ *
{@code
+ * PolicyConflictResponse conflicts = axonflow.detectPolicyConflicts();
+ * System.out.println("Conflicts found: " + conflicts.getConflictCount());
+ * for (PolicyConflict conflict : conflicts.getConflicts()) {
+ * System.out.println(conflict.getConflictType() + ": " + conflict.getDescription());
+ * }
+ * }
+ *
+ * Evaluation+ Feature: Requires AxonFlow Evaluation tier or higher.
+ *
+ * @return the conflict detection result
+ * @throws AxonFlowException if the request fails
+ */
+ public PolicyConflictResponse detectPolicyConflicts() {
+ return detectPolicyConflicts(null);
+ }
+
+ /**
+ * Detects conflicts between a specific policy and other active policies, or scans all policies if
+ * policyId is null.
+ *
+ *
Example usage:
+ *
+ *
{@code
+ * PolicyConflictResponse conflicts = axonflow.detectPolicyConflicts("policy_block_pii");
+ * System.out.println("Conflicts found: " + conflicts.getConflictCount());
+ * for (PolicyConflict conflict : conflicts.getConflicts()) {
+ * System.out.println(conflict.getConflictType() + ": " + conflict.getDescription());
+ * }
+ * }
+ *
+ * Evaluation+ Feature: Requires AxonFlow Evaluation tier or higher.
+ *
+ * @param policyId the policy ID to check for conflicts, or null to scan all policies
+ * @return the conflict detection result
+ * @throws IllegalArgumentException if policyId is non-null and empty
+ * @throws AxonFlowException if the request fails
+ */
+ public PolicyConflictResponse detectPolicyConflicts(String policyId) {
+ if (policyId != null && policyId.isEmpty()) {
+ throw new IllegalArgumentException("policyId cannot be empty");
+ }
+
+ return retryExecutor.execute(
+ () -> {
+ Object body;
+ if (policyId != null) {
+ body = java.util.Map.of("policy_id", policyId);
+ } else {
+ body = java.util.Map.of();
+ }
+ Request httpRequest =
+ buildOrchestratorRequest("POST", "/api/v1/policies/conflicts", body);
+ try (Response response = httpClient.newCall(httpRequest).execute()) {
+ JsonNode node = parseResponseNode(response);
+ if (node.has("data") && node.get("data").isObject()) {
+ return objectMapper.treeToValue(node.get("data"), PolicyConflictResponse.class);
+ }
+ return objectMapper.treeToValue(node, PolicyConflictResponse.class);
+ }
+ },
+ "detectPolicyConflicts");
+ }
+
+ /**
+ * Asynchronously scans all active policies for conflicts.
+ *
+ * @return a future containing the conflict detection result
+ */
+ public CompletableFuture detectPolicyConflictsAsync() {
+ return CompletableFuture.supplyAsync(() -> detectPolicyConflicts(), asyncExecutor);
+ }
+
+ /**
+ * Asynchronously detects conflicts between a specific policy and other active policies.
+ *
+ * @param policyId the policy ID to check for conflicts, or null to scan all policies
+ * @return a future containing the conflict detection result
+ */
+ public CompletableFuture detectPolicyConflictsAsync(String policyId) {
+ return CompletableFuture.supplyAsync(() -> detectPolicyConflicts(policyId), asyncExecutor);
+ }
+
+ // ========================================================================
+ // Proxy Mode - Query Execution
+ // ========================================================================
+
+ /**
+ * Sends a query through AxonFlow with full policy enforcement (Proxy Mode).
+ *
+ * This is Proxy Mode - AxonFlow acts as an intermediary, making the LLM call on your behalf.
+ *
+ *
Use this when you want AxonFlow to:
+ *
+ *
+ * - Evaluate policies before the LLM call
+ *
- Make the LLM call to the configured provider
+ *
- Filter/redact sensitive data from responses
+ *
- Automatically track costs and audit the interaction
+ *
+ *
+ * For Gateway Mode (lower latency, you make the LLM call), use:
+ *
+ *
+ * - {@link #getPolicyApprovedContext} before your LLM call
+ *
- {@link #auditLLMCall} after your LLM call
+ *
+ *
+ * @param request the client request
+ * @return the response from AxonFlow
+ * @throws PolicyViolationException if the request is blocked by policy
+ * @throws AuthenticationException if authentication fails
+ */
+ public ClientResponse proxyLLMCall(ClientRequest request) {
+ Objects.requireNonNull(request, "request cannot be null");
+
+ // Auto-populate clientId from config if not set in request (matches Go/Python/TypeScript SDK
+ // behavior)
+ ClientRequest effectiveRequest = request;
+ if ((request.getClientId() == null || request.getClientId().isEmpty())
+ && config.getClientId() != null
+ && !config.getClientId().isEmpty()) {
+ effectiveRequest =
+ ClientRequest.builder()
+ .query(request.getQuery())
+ .userToken(request.getUserToken())
+ .clientId(config.getClientId())
+ .requestType(
+ request.getRequestType() != null
+ ? RequestType.fromValue(request.getRequestType())
+ : RequestType.CHAT)
+ .context(request.getContext())
+ .llmProvider(request.getLlmProvider())
+ .model(request.getModel())
+ .media(request.getMedia())
+ .build();
+ }
+
+ final ClientRequest finalRequest = effectiveRequest;
+
+ // Media requests must not be cached — binary content makes cache keys unreliable
+ boolean hasMedia = finalRequest.getMedia() != null && !finalRequest.getMedia().isEmpty();
+
+ // Check cache first (skip for media requests)
+ String cacheKey =
+ ResponseCache.generateKey(
+ finalRequest.getRequestType(), finalRequest.getQuery(), finalRequest.getUserToken());
+
+ if (!hasMedia) {
+ java.util.Optional cached = cache.get(cacheKey, ClientResponse.class);
+ if (cached.isPresent()) {
+ return cached.get();
+ }
+ }
+
+ ClientResponse response =
+ retryExecutor.execute(
+ () -> {
+ Request httpRequest = buildRequest("POST", "/api/request", finalRequest);
+ try (Response httpResponse = httpClient.newCall(httpRequest).execute()) {
+ ClientResponse result = parseResponse(httpResponse, ClientResponse.class);
+
+ if (result.isBlocked()) {
+ throw new PolicyViolationException(
+ result.getBlockReason(),
+ result.getBlockingPolicyName(),
+ result.getPolicyInfo() != null
+ ? result.getPolicyInfo().getPoliciesEvaluated()
+ : null);
}
+
+ return result;
+ }
+ },
+ "proxyLLMCall");
+
+ // Cache successful responses (skip for media requests)
+ if (!hasMedia && response.isSuccess() && !response.isBlocked()) {
+ cache.put(cacheKey, response);
+ }
+
+ return response;
+ }
+
+ /**
+ * Asynchronously sends a query through AxonFlow with full policy enforcement (Proxy Mode).
+ *
+ * @param request the client request
+ * @return a future containing the response
+ * @see #proxyLLMCall(ClientRequest)
+ */
+ public CompletableFuture proxyLLMCallAsync(ClientRequest request) {
+ return CompletableFuture.supplyAsync(() -> proxyLLMCall(request), asyncExecutor);
+ }
+
+ // ========================================================================
+ // Multi-Agent Planning (MAP)
+ // ========================================================================
+
+ /**
+ * Generates a multi-agent plan for a complex task.
+ *
+ * This method uses the Agent API with request_type "multi-agent-plan" to generate and execute
+ * plans through the governance layer.
+ *
+ * @param request the plan request
+ * @return the generated plan
+ * @throws PlanExecutionException if plan generation fails
+ */
+ public PlanResponse generatePlan(PlanRequest request) {
+ Objects.requireNonNull(request, "request cannot be null");
+
+ return retryExecutor.execute(
+ () -> {
+ // Build agent request format - use HashMap to allow null-safe values
+ String userToken = request.getUserToken();
+ if (userToken == null) {
+ userToken = config.getClientId() != null ? config.getClientId() : "default";
+ }
+ String clientId = config.getClientId() != null ? config.getClientId() : "default";
+ String domain = request.getDomain() != null ? request.getDomain() : "generic";
+
+ Map agentRequest = new java.util.HashMap<>();
+ agentRequest.put("query", request.getObjective());
+ agentRequest.put("user_token", userToken);
+ agentRequest.put("client_id", clientId);
+ agentRequest.put("request_type", "multi-agent-plan");
+ agentRequest.put("context", Map.of("domain", domain));
+
+ Request httpRequest = buildRequest("POST", "/api/request", agentRequest);
+ try (Response response = httpClient.newCall(httpRequest).execute()) {
+ return parsePlanResponse(response, request.getDomain());
+ }
+ },
+ "generatePlan");
+ }
+
+ /**
+ * Parses the Agent API response format into PlanResponse. The Agent API returns: {success,
+ * plan_id, data: {steps, domain, ...}, metadata, result}
+ */
+ @SuppressWarnings("unchecked")
+ private PlanResponse parsePlanResponse(Response response, String requestDomain)
+ throws IOException {
+ handleErrorResponse(response);
+
+ ResponseBody body = response.body();
+ if (body == null) {
+ throw new AxonFlowException("Empty response body", response.code(), null);
+ }
+
+ String json = body.string();
+ Map agentResponse =
+ objectMapper.readValue(json, new TypeReference