diff --git a/CHANGELOG.md b/CHANGELOG.md index 6780e4b..379dfc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Helper methods on `StepGateResponse`: `isAllowed()`, `isBlocked()`, `requiresApproval()` - Helper methods on `WorkflowStatus` and `WorkflowStatusResponse`: `isTerminal()` +- **Workflow Policy Enforcement** (Issues #1019, #1020, #1021): Policy transparency for workflow operations + - `StepGateResponse` now includes `getPoliciesEvaluated()` and `getPoliciesMatched()` methods with `PolicyMatch` type + - `PolicyMatch` class with `getPolicyId()`, `getPolicyName()`, `getAction()`, `getReason()` for policy transparency + - `PolicyEvaluationResult` class for MAP execution with `isAllowed()`, `getAppliedPolicies()`, `getRiskScore()` + - Workflow operations (`workflow_created`, `workflow_step_gate`, `workflow_completed`) logged to audit trail + --- ## [2.4.0] - 2026-01-14 diff --git a/src/main/java/com/getaxonflow/sdk/types/workflow/PlanExecutionResponse.java b/src/main/java/com/getaxonflow/sdk/types/workflow/PlanExecutionResponse.java new file mode 100644 index 0000000..37d7e0d --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/workflow/PlanExecutionResponse.java @@ -0,0 +1,366 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types.workflow; + +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; +import java.util.Map; +import java.util.Objects; + +/** + * Response from executing a plan in Multi-Agent Planning (MAP). + * + *
Contains the execution result, policy evaluation information,
+ * and metadata about the plan execution.
+ *
+ * @since 2.3.0
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public final class PlanExecutionResponse {
+
+ @JsonProperty("plan_id")
+ private final String planId;
+
+ @JsonProperty("status")
+ private final String status;
+
+ @JsonProperty("result")
+ private final String result;
+
+ @JsonProperty("steps_completed")
+ private final int stepsCompleted;
+
+ @JsonProperty("total_steps")
+ private final int totalSteps;
+
+ @JsonProperty("started_at")
+ private final Instant startedAt;
+
+ @JsonProperty("completed_at")
+ private final Instant completedAt;
+
+ @JsonProperty("step_results")
+ private final List Possible values: "pending", "in_progress", "completed", "failed", "blocked".
+ *
+ * @return the execution status
+ */
+ public String getStatus() {
+ return status;
+ }
+
+ /**
+ * Returns the execution result or error message.
+ *
+ * @return the result string, or null if not yet completed
+ */
+ public String getResult() {
+ return result;
+ }
+
+ /**
+ * Returns the number of steps that have been completed.
+ *
+ * @return the count of completed steps
+ */
+ public int getStepsCompleted() {
+ return stepsCompleted;
+ }
+
+ /**
+ * Returns the total number of steps in the plan.
+ *
+ * @return the total step count
+ */
+ public int getTotalSteps() {
+ return totalSteps;
+ }
+
+ /**
+ * Returns when the plan execution started.
+ *
+ * @return the start timestamp, or null if not yet started
+ */
+ public Instant getStartedAt() {
+ return startedAt;
+ }
+
+ /**
+ * Returns when the plan execution completed.
+ *
+ * @return the completion timestamp, or null if not yet completed
+ */
+ public Instant getCompletedAt() {
+ return completedAt;
+ }
+
+ /**
+ * Returns the results of individual steps.
+ *
+ * @return immutable list of step results
+ */
+ public List Contains details about which policies were applied, the risk score,
+ * and any required actions.
+ *
+ * @return the policy evaluation result, or null if no policy evaluation was performed
+ * @since 2.3.0
+ */
+ public PolicyEvaluationResult getPolicyInfo() {
+ return policyInfo;
+ }
+
+ /**
+ * Returns additional metadata about the execution.
+ *
+ * @return immutable map of metadata
+ */
+ public Map Contains detailed information about whether a step or plan execution
+ * was allowed based on policy checks, including risk assessment and
+ * any required actions.
+ *
+ * @since 2.3.0
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public final class PolicyEvaluationResult {
+
+ @JsonProperty("allowed")
+ private final boolean allowed;
+
+ @JsonProperty("applied_policies")
+ private final List Risk scores typically range from 0.0 (no risk) to 1.0 (high risk).
+ *
+ * @return the risk score
+ */
+ public double getRiskScore() {
+ return riskScore;
+ }
+
+ /**
+ * Returns the list of actions required before the operation can proceed.
+ *
+ * Examples include "approval_required", "audit_required", "rate_limit_exceeded".
+ *
+ * @return immutable list of required action identifiers
+ */
+ public List This is useful for tracking whether dynamic policy lookups were performed.
+ *
+ * @return true if database was accessed, false otherwise, null if unknown
+ */
+ public Boolean getDatabaseAccessed() {
+ return databaseAccessed;
+ }
+
+ /**
+ * Convenience method to check if database was accessed.
+ *
+ * @return true if database was definitely accessed, false otherwise
+ */
+ public boolean wasDatabaseAccessed() {
+ return Boolean.TRUE.equals(databaseAccessed);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ PolicyEvaluationResult that = (PolicyEvaluationResult) o;
+ return allowed == that.allowed &&
+ Double.compare(that.riskScore, riskScore) == 0 &&
+ processingTimeMs == that.processingTimeMs &&
+ Objects.equals(appliedPolicies, that.appliedPolicies) &&
+ Objects.equals(requiredActions, that.requiredActions) &&
+ Objects.equals(databaseAccessed, that.databaseAccessed);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(allowed, appliedPolicies, riskScore, requiredActions, processingTimeMs, databaseAccessed);
+ }
+
+ @Override
+ public String toString() {
+ return "PolicyEvaluationResult{" +
+ "allowed=" + allowed +
+ ", appliedPolicies=" + appliedPolicies +
+ ", riskScore=" + riskScore +
+ ", requiredActions=" + requiredActions +
+ ", processingTimeMs=" + processingTimeMs +
+ ", databaseAccessed=" + databaseAccessed +
+ '}';
+ }
+
+ /**
+ * Creates a new builder for PolicyEvaluationResult.
+ *
+ * @return a new builder instance
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Builder for PolicyEvaluationResult.
+ */
+ public static final class Builder {
+ private boolean allowed;
+ private List Contains information about which policy matched, the action taken,
+ * and the reason for the match.
+ *
+ * @since 2.3.0
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public final class PolicyMatch {
+
+ @JsonProperty("policy_id")
+ private final String policyId;
+
+ @JsonProperty("policy_name")
+ private final String policyName;
+
+ @JsonProperty("action")
+ private final String action;
+
+ @JsonProperty("reason")
+ private final String reason;
+
+ @JsonCreator
+ public PolicyMatch(
+ @JsonProperty("policy_id") String policyId,
+ @JsonProperty("policy_name") String policyName,
+ @JsonProperty("action") String action,
+ @JsonProperty("reason") String reason) {
+ this.policyId = policyId;
+ this.policyName = policyName;
+ this.action = action;
+ this.reason = reason;
+ }
+
+ /**
+ * Returns the unique identifier of the matched policy.
+ *
+ * @return the policy ID
+ */
+ public String getPolicyId() {
+ return policyId;
+ }
+
+ /**
+ * Returns the human-readable name of the matched policy.
+ *
+ * @return the policy name
+ */
+ public String getPolicyName() {
+ return policyName;
+ }
+
+ /**
+ * Returns the action taken as a result of this policy match.
+ *
+ * Common actions include "allow", "block", "require_approval", "redact".
+ *
+ * @return the action taken
+ */
+ public String getAction() {
+ return action;
+ }
+
+ /**
+ * Returns the reason why this policy was matched.
+ *
+ * Provides context about what triggered the policy match,
+ * useful for debugging and audit purposes.
+ *
+ * @return the reason for the match
+ */
+ public String getReason() {
+ return reason;
+ }
+
+ /**
+ * Checks if this policy match resulted in a blocking action.
+ *
+ * @return true if the action is "block"
+ */
+ public boolean isBlocking() {
+ return "block".equalsIgnoreCase(action);
+ }
+
+ /**
+ * Checks if this policy match requires approval.
+ *
+ * @return true if the action is "require_approval"
+ */
+ public boolean requiresApproval() {
+ return "require_approval".equalsIgnoreCase(action);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ PolicyMatch that = (PolicyMatch) o;
+ return Objects.equals(policyId, that.policyId) &&
+ Objects.equals(policyName, that.policyName) &&
+ Objects.equals(action, that.action) &&
+ Objects.equals(reason, that.reason);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(policyId, policyName, action, reason);
+ }
+
+ @Override
+ public String toString() {
+ return "PolicyMatch{" +
+ "policyId='" + policyId + '\'' +
+ ", policyName='" + policyName + '\'' +
+ ", action='" + action + '\'' +
+ ", reason='" + reason + '\'' +
+ '}';
+ }
+
+ /**
+ * Creates a new builder for PolicyMatch.
+ *
+ * @return a new builder instance
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Builder for PolicyMatch.
+ */
+ public static final class Builder {
+ private String policyId;
+ private String policyName;
+ private String action;
+ private String reason;
+
+ public Builder policyId(String policyId) {
+ this.policyId = policyId;
+ return this;
+ }
+
+ public Builder policyName(String policyName) {
+ this.policyName = policyName;
+ return this;
+ }
+
+ public Builder action(String action) {
+ this.action = action;
+ return this;
+ }
+
+ public Builder reason(String reason) {
+ this.reason = reason;
+ return this;
+ }
+
+ public PolicyMatch build() {
+ return new PolicyMatch(policyId, policyName, action, reason);
+ }
+ }
+}
diff --git a/src/main/java/com/getaxonflow/sdk/types/workflow/WorkflowTypes.java b/src/main/java/com/getaxonflow/sdk/types/workflow/WorkflowTypes.java
index 9b35dfb..0eae02a 100644
--- a/src/main/java/com/getaxonflow/sdk/types/workflow/WorkflowTypes.java
+++ b/src/main/java/com/getaxonflow/sdk/types/workflow/WorkflowTypes.java
@@ -467,18 +467,28 @@ public static final class StepGateResponse {
@JsonProperty("approval_url")
private final String approvalUrl;
+ @JsonProperty("policies_evaluated")
+ private final List "LangChain runs the workflow. AxonFlow decides when it's allowed to move forward."
*
+ * Policy Enforcement Types (v2.3.0)
+ *
+ *
+ *
* Example Usage
* {@code
* // Create a workflow
@@ -35,7 +42,7 @@
* .build()
* );
*
- * // Check step gate
+ * // Check step gate with policy evaluation
* StepGateResponse gate = axonflow.stepGate(
* workflow.getWorkflowId(),
* "step-1",
@@ -47,6 +54,10 @@
* );
*
* if (gate.isBlocked()) {
+ * // Check which policies blocked the step
+ * for (PolicyMatch match : gate.getPoliciesMatched()) {
+ * System.out.println("Blocked by: " + match.getPolicyName() + " - " + match.getReason());
+ * }
* throw new RuntimeException("Step blocked: " + gate.getReason());
* }
*
@@ -55,6 +66,9 @@
* }
*
* @see com.getaxonflow.sdk.types.workflow.WorkflowTypes
+ * @see com.getaxonflow.sdk.types.workflow.PolicyEvaluationResult
+ * @see com.getaxonflow.sdk.types.workflow.PolicyMatch
+ * @see com.getaxonflow.sdk.types.workflow.PlanExecutionResponse
* @see com.getaxonflow.sdk.AxonFlow#createWorkflow
* @see com.getaxonflow.sdk.AxonFlow#stepGate
*/
diff --git a/src/test/java/com/getaxonflow/sdk/types/workflow/WorkflowPolicyTypesTest.java b/src/test/java/com/getaxonflow/sdk/types/workflow/WorkflowPolicyTypesTest.java
new file mode 100644
index 0000000..a47047e
--- /dev/null
+++ b/src/test/java/com/getaxonflow/sdk/types/workflow/WorkflowPolicyTypesTest.java
@@ -0,0 +1,419 @@
+/*
+ * Copyright 2026 AxonFlow
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.getaxonflow.sdk.types.workflow;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.DisplayName;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.*;
+
+/**
+ * Tests for workflow policy types (Issues #1019, #1020, #1021).
+ */
+@DisplayName("Workflow Policy Types")
+class WorkflowPolicyTypesTest {
+
+ private ObjectMapper objectMapper;
+
+ @BeforeEach
+ void setUp() {
+ objectMapper = new ObjectMapper();
+ }
+
+ // PolicyMatch tests
+
+ @Test
+ @DisplayName("PolicyMatch - should build with all fields")
+ void policyMatchShouldBuildWithAllFields() {
+ PolicyMatch match = PolicyMatch.builder()
+ .policyId("policy-123")
+ .policyName("block-gpt4")
+ .action("block")
+ .reason("GPT-4 not allowed in production")
+ .build();
+
+ assertThat(match.getPolicyId()).isEqualTo("policy-123");
+ assertThat(match.getPolicyName()).isEqualTo("block-gpt4");
+ assertThat(match.getAction()).isEqualTo("block");
+ assertThat(match.getReason()).isEqualTo("GPT-4 not allowed in production");
+ }
+
+ @Test
+ @DisplayName("PolicyMatch - isBlocking returns true for block action")
+ void policyMatchIsBlockingShouldReturnTrueForBlockAction() {
+ PolicyMatch match = PolicyMatch.builder()
+ .policyId("policy-123")
+ .action("block")
+ .build();
+
+ assertThat(match.isBlocking()).isTrue();
+ }
+
+ @Test
+ @DisplayName("PolicyMatch - isBlocking returns false for allow action")
+ void policyMatchIsBlockingShouldReturnFalseForAllowAction() {
+ PolicyMatch match = PolicyMatch.builder()
+ .policyId("policy-123")
+ .action("allow")
+ .build();
+
+ assertThat(match.isBlocking()).isFalse();
+ }
+
+ @Test
+ @DisplayName("PolicyMatch - requiresApproval returns true for require_approval action")
+ void policyMatchRequiresApprovalShouldReturnTrue() {
+ PolicyMatch match = PolicyMatch.builder()
+ .policyId("policy-123")
+ .action("require_approval")
+ .build();
+
+ assertThat(match.requiresApproval()).isTrue();
+ }
+
+ @Test
+ @DisplayName("PolicyMatch - should deserialize from JSON")
+ void policyMatchShouldDeserialize() throws Exception {
+ String json = "{"
+ + "\"policy_id\": \"policy-456\","
+ + "\"policy_name\": \"pii-detection\","
+ + "\"action\": \"redact\","
+ + "\"reason\": \"PII detected in input\""
+ + "}";
+
+ PolicyMatch match = objectMapper.readValue(json, PolicyMatch.class);
+
+ assertThat(match.getPolicyId()).isEqualTo("policy-456");
+ assertThat(match.getPolicyName()).isEqualTo("pii-detection");
+ assertThat(match.getAction()).isEqualTo("redact");
+ assertThat(match.getReason()).isEqualTo("PII detected in input");
+ }
+
+ @Test
+ @DisplayName("PolicyMatch - should serialize to JSON")
+ void policyMatchShouldSerialize() throws Exception {
+ PolicyMatch match = PolicyMatch.builder()
+ .policyId("policy-789")
+ .policyName("cost-limit")
+ .action("allow")
+ .reason("Within budget")
+ .build();
+
+ String json = objectMapper.writeValueAsString(match);
+
+ assertThat(json).contains("\"policy_id\":\"policy-789\"");
+ assertThat(json).contains("\"policy_name\":\"cost-limit\"");
+ assertThat(json).contains("\"action\":\"allow\"");
+ }
+
+ @Test
+ @DisplayName("PolicyMatch - equals and hashCode")
+ void policyMatchEqualsAndHashCode() {
+ PolicyMatch match1 = PolicyMatch.builder()
+ .policyId("policy-123")
+ .policyName("test")
+ .action("allow")
+ .build();
+
+ PolicyMatch match2 = PolicyMatch.builder()
+ .policyId("policy-123")
+ .policyName("test")
+ .action("allow")
+ .build();
+
+ PolicyMatch match3 = PolicyMatch.builder()
+ .policyId("policy-456")
+ .policyName("other")
+ .action("block")
+ .build();
+
+ assertThat(match1).isEqualTo(match2);
+ assertThat(match1.hashCode()).isEqualTo(match2.hashCode());
+ assertThat(match1).isNotEqualTo(match3);
+ }
+
+ @Test
+ @DisplayName("PolicyMatch - toString contains all fields")
+ void policyMatchToStringShouldContainAllFields() {
+ PolicyMatch match = PolicyMatch.builder()
+ .policyId("policy-123")
+ .policyName("test-policy")
+ .action("block")
+ .reason("test reason")
+ .build();
+
+ String str = match.toString();
+
+ assertThat(str).contains("policy-123");
+ assertThat(str).contains("test-policy");
+ assertThat(str).contains("block");
+ assertThat(str).contains("test reason");
+ }
+
+ // PolicyEvaluationResult tests
+
+ @Test
+ @DisplayName("PolicyEvaluationResult - should build with all fields")
+ void policyEvaluationResultShouldBuildWithAllFields() {
+ List