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 stepResults; + + @JsonProperty("policy_info") + private final PolicyEvaluationResult policyInfo; + + @JsonProperty("metadata") + private final Map metadata; + + @JsonCreator + public PlanExecutionResponse( + @JsonProperty("plan_id") String planId, + @JsonProperty("status") String status, + @JsonProperty("result") String result, + @JsonProperty("steps_completed") int stepsCompleted, + @JsonProperty("total_steps") int totalSteps, + @JsonProperty("started_at") Instant startedAt, + @JsonProperty("completed_at") Instant completedAt, + @JsonProperty("step_results") List stepResults, + @JsonProperty("policy_info") PolicyEvaluationResult policyInfo, + @JsonProperty("metadata") Map metadata) { + this.planId = planId; + this.status = status; + this.result = result; + this.stepsCompleted = stepsCompleted; + this.totalSteps = totalSteps; + this.startedAt = startedAt; + this.completedAt = completedAt; + this.stepResults = stepResults != null ? Collections.unmodifiableList(stepResults) : Collections.emptyList(); + this.policyInfo = policyInfo; + this.metadata = metadata != null ? Collections.unmodifiableMap(metadata) : Collections.emptyMap(); + } + + /** + * Returns the unique identifier of the executed plan. + * + * @return the plan ID + */ + public String getPlanId() { + return planId; + } + + /** + * Returns the current execution status. + * + *

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 getStepResults() { + return stepResults; + } + + /** + * Returns the policy evaluation information for this execution. + * + *

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 getMetadata() { + return metadata; + } + + /** + * Checks if the plan execution completed successfully. + * + * @return true if status is "completed" + */ + public boolean isCompleted() { + return "completed".equalsIgnoreCase(status); + } + + /** + * Checks if the plan execution failed. + * + * @return true if status is "failed" + */ + public boolean isFailed() { + return "failed".equalsIgnoreCase(status); + } + + /** + * Checks if the plan execution was blocked by policy. + * + * @return true if status is "blocked" + */ + public boolean isBlocked() { + return "blocked".equalsIgnoreCase(status); + } + + /** + * Checks if the plan execution is still in progress. + * + * @return true if status is "in_progress" or "pending" + */ + public boolean isInProgress() { + return "in_progress".equalsIgnoreCase(status) || "pending".equalsIgnoreCase(status); + } + + /** + * Calculates the progress percentage. + * + * @return progress as a value between 0.0 and 1.0 + */ + public double getProgress() { + if (totalSteps == 0) { + return 0.0; + } + return (double) stepsCompleted / totalSteps; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PlanExecutionResponse that = (PlanExecutionResponse) o; + return stepsCompleted == that.stepsCompleted && + totalSteps == that.totalSteps && + Objects.equals(planId, that.planId) && + Objects.equals(status, that.status) && + Objects.equals(result, that.result) && + Objects.equals(startedAt, that.startedAt) && + Objects.equals(completedAt, that.completedAt) && + Objects.equals(stepResults, that.stepResults) && + Objects.equals(policyInfo, that.policyInfo) && + Objects.equals(metadata, that.metadata); + } + + @Override + public int hashCode() { + return Objects.hash(planId, status, result, stepsCompleted, totalSteps, + startedAt, completedAt, stepResults, policyInfo, metadata); + } + + @Override + public String toString() { + return "PlanExecutionResponse{" + + "planId='" + planId + '\'' + + ", status='" + status + '\'' + + ", stepsCompleted=" + stepsCompleted + + ", totalSteps=" + totalSteps + + ", policyInfo=" + policyInfo + + '}'; + } + + /** + * Result of an individual step execution. + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class StepResult { + + @JsonProperty("step_index") + private final int stepIndex; + + @JsonProperty("step_name") + private final String stepName; + + @JsonProperty("status") + private final String status; + + @JsonProperty("output") + private final String output; + + @JsonProperty("error") + private final String error; + + @JsonProperty("duration_ms") + private final long durationMs; + + @JsonCreator + public StepResult( + @JsonProperty("step_index") int stepIndex, + @JsonProperty("step_name") String stepName, + @JsonProperty("status") String status, + @JsonProperty("output") String output, + @JsonProperty("error") String error, + @JsonProperty("duration_ms") long durationMs) { + this.stepIndex = stepIndex; + this.stepName = stepName; + this.status = status; + this.output = output; + this.error = error; + this.durationMs = durationMs; + } + + public int getStepIndex() { + return stepIndex; + } + + public String getStepName() { + return stepName; + } + + public String getStatus() { + return status; + } + + public String getOutput() { + return output; + } + + public String getError() { + return error; + } + + public long getDurationMs() { + return durationMs; + } + + public boolean isSuccess() { + return "completed".equalsIgnoreCase(status) || "success".equalsIgnoreCase(status); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + StepResult that = (StepResult) o; + return stepIndex == that.stepIndex && + durationMs == that.durationMs && + Objects.equals(stepName, that.stepName) && + Objects.equals(status, that.status) && + Objects.equals(output, that.output) && + Objects.equals(error, that.error); + } + + @Override + public int hashCode() { + return Objects.hash(stepIndex, stepName, status, output, error, durationMs); + } + + @Override + public String toString() { + return "StepResult{" + + "stepIndex=" + stepIndex + + ", stepName='" + stepName + '\'' + + ", status='" + status + '\'' + + '}'; + } + } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/workflow/PolicyEvaluationResult.java b/src/main/java/com/getaxonflow/sdk/types/workflow/PolicyEvaluationResult.java new file mode 100644 index 0000000..4484da9 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/workflow/PolicyEvaluationResult.java @@ -0,0 +1,225 @@ +/* + * 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.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Result of a policy evaluation during workflow execution. + * + *

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 appliedPolicies; + + @JsonProperty("risk_score") + private final double riskScore; + + @JsonProperty("required_actions") + private final List requiredActions; + + @JsonProperty("processing_time_ms") + private final long processingTimeMs; + + @JsonProperty("database_accessed") + private final Boolean databaseAccessed; + + @JsonCreator + public PolicyEvaluationResult( + @JsonProperty("allowed") boolean allowed, + @JsonProperty("applied_policies") List appliedPolicies, + @JsonProperty("risk_score") double riskScore, + @JsonProperty("required_actions") List requiredActions, + @JsonProperty("processing_time_ms") long processingTimeMs, + @JsonProperty("database_accessed") Boolean databaseAccessed) { + this.allowed = allowed; + this.appliedPolicies = appliedPolicies != null ? Collections.unmodifiableList(appliedPolicies) : Collections.emptyList(); + this.riskScore = riskScore; + this.requiredActions = requiredActions != null ? Collections.unmodifiableList(requiredActions) : Collections.emptyList(); + this.processingTimeMs = processingTimeMs; + this.databaseAccessed = databaseAccessed; + } + + /** + * Returns whether the operation was allowed by policy evaluation. + * + * @return true if the operation is allowed, false if blocked + */ + public boolean isAllowed() { + return allowed; + } + + /** + * Returns the list of policies that were applied during evaluation. + * + * @return immutable list of applied policy identifiers + */ + public List getAppliedPolicies() { + return appliedPolicies; + } + + /** + * Returns the calculated risk score for this operation. + * + *

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 getRequiredActions() { + return requiredActions; + } + + /** + * Returns the time taken to evaluate policies in milliseconds. + * + * @return processing time in milliseconds + */ + public long getProcessingTimeMs() { + return processingTimeMs; + } + + /** + * Returns whether a database was accessed during policy evaluation. + * + *

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 appliedPolicies; + private double riskScore; + private List requiredActions; + private long processingTimeMs; + private Boolean databaseAccessed; + + public Builder allowed(boolean allowed) { + this.allowed = allowed; + return this; + } + + public Builder appliedPolicies(List appliedPolicies) { + this.appliedPolicies = appliedPolicies; + return this; + } + + public Builder riskScore(double riskScore) { + this.riskScore = riskScore; + return this; + } + + public Builder requiredActions(List requiredActions) { + this.requiredActions = requiredActions; + return this; + } + + public Builder processingTimeMs(long processingTimeMs) { + this.processingTimeMs = processingTimeMs; + return this; + } + + public Builder databaseAccessed(Boolean databaseAccessed) { + this.databaseAccessed = databaseAccessed; + return this; + } + + public PolicyEvaluationResult build() { + return new PolicyEvaluationResult(allowed, appliedPolicies, riskScore, requiredActions, processingTimeMs, databaseAccessed); + } + } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/workflow/PolicyMatch.java b/src/main/java/com/getaxonflow/sdk/types/workflow/PolicyMatch.java new file mode 100644 index 0000000..eba5859 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/workflow/PolicyMatch.java @@ -0,0 +1,186 @@ +/* + * 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.util.Objects; + +/** + * Represents a policy that was matched during workflow step gate evaluation. + * + *

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 policiesEvaluated; + + @JsonProperty("policies_matched") + private final List policiesMatched; + @JsonCreator public StepGateResponse( @JsonProperty("decision") GateDecision decision, @JsonProperty("step_id") String stepId, @JsonProperty("reason") String reason, @JsonProperty("policy_ids") List policyIds, - @JsonProperty("approval_url") String approvalUrl) { + @JsonProperty("approval_url") String approvalUrl, + @JsonProperty("policies_evaluated") List policiesEvaluated, + @JsonProperty("policies_matched") List policiesMatched) { this.decision = decision; this.stepId = stepId; this.reason = reason; this.policyIds = policyIds != null ? Collections.unmodifiableList(policyIds) : Collections.emptyList(); this.approvalUrl = approvalUrl; + this.policiesEvaluated = policiesEvaluated != null ? Collections.unmodifiableList(policiesEvaluated) : Collections.emptyList(); + this.policiesMatched = policiesMatched != null ? Collections.unmodifiableList(policiesMatched) : Collections.emptyList(); } public GateDecision getDecision() { @@ -501,6 +511,26 @@ public String getApprovalUrl() { return approvalUrl; } + /** + * Returns all policies that were evaluated during the gate check. + * + * @return immutable list of evaluated policies + * @since 2.3.0 + */ + public List getPoliciesEvaluated() { + return policiesEvaluated; + } + + /** + * Returns policies that matched and influenced the decision. + * + * @return immutable list of matched policies + * @since 2.3.0 + */ + public List getPoliciesMatched() { + return policiesMatched; + } + public boolean isAllowed() { return decision == GateDecision.ALLOW; } diff --git a/src/main/java/com/getaxonflow/sdk/types/workflow/package-info.java b/src/main/java/com/getaxonflow/sdk/types/workflow/package-info.java index 3ae98d7..4a839fd 100644 --- a/src/main/java/com/getaxonflow/sdk/types/workflow/package-info.java +++ b/src/main/java/com/getaxonflow/sdk/types/workflow/package-info.java @@ -24,6 +24,13 @@ * *

"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 policies = Arrays.asList("cost-limit", "model-restriction"); + PolicyEvaluationResult result = PolicyEvaluationResult.builder() + .allowed(true) + .appliedPolicies(policies) + .riskScore(0.2) + .build(); + + assertThat(result.isAllowed()).isTrue(); + assertThat(result.getAppliedPolicies()).containsExactly("cost-limit", "model-restriction"); + assertThat(result.getRiskScore()).isEqualTo(0.2); + } + + @Test + @DisplayName("PolicyEvaluationResult - should deserialize from JSON") + void policyEvaluationResultShouldDeserialize() throws Exception { + String json = "{" + + "\"allowed\": false," + + "\"applied_policies\": [\"high-risk-block\"]," + + "\"risk_score\": 0.85" + + "}"; + + PolicyEvaluationResult result = objectMapper.readValue(json, PolicyEvaluationResult.class); + + assertThat(result.isAllowed()).isFalse(); + assertThat(result.getAppliedPolicies()).containsExactly("high-risk-block"); + assertThat(result.getRiskScore()).isEqualTo(0.85); + } + + @Test + @DisplayName("PolicyEvaluationResult - should serialize to JSON") + void policyEvaluationResultShouldSerialize() throws Exception { + PolicyEvaluationResult result = PolicyEvaluationResult.builder() + .allowed(true) + .appliedPolicies(Arrays.asList("policy-1", "policy-2")) + .riskScore(0.1) + .build(); + + String json = objectMapper.writeValueAsString(result); + + assertThat(json).contains("\"allowed\":true"); + assertThat(json).contains("\"applied_policies\""); + assertThat(json).contains("\"risk_score\":0.1"); + } + + @Test + @DisplayName("PolicyEvaluationResult - equals and hashCode") + void policyEvaluationResultEqualsAndHashCode() { + List policies = Arrays.asList("policy-1"); + PolicyEvaluationResult result1 = PolicyEvaluationResult.builder() + .allowed(true) + .appliedPolicies(policies) + .riskScore(0.5) + .build(); + + PolicyEvaluationResult result2 = PolicyEvaluationResult.builder() + .allowed(true) + .appliedPolicies(policies) + .riskScore(0.5) + .build(); + + assertThat(result1).isEqualTo(result2); + assertThat(result1.hashCode()).isEqualTo(result2.hashCode()); + } + + @Test + @DisplayName("PolicyEvaluationResult - toString contains all fields") + void policyEvaluationResultToStringShouldContainAllFields() { + PolicyEvaluationResult result = PolicyEvaluationResult.builder() + .allowed(false) + .appliedPolicies(Arrays.asList("test-policy")) + .riskScore(0.75) + .build(); + + String str = result.toString(); + + assertThat(str).contains("allowed=false"); + assertThat(str).contains("test-policy"); + assertThat(str).contains("0.75"); + } + + // PlanExecutionResponse tests + + @Test + @DisplayName("PlanExecutionResponse - should deserialize from JSON") + void planExecutionResponseShouldDeserialize() throws Exception { + String json = "{" + + "\"plan_id\": \"plan-123\"," + + "\"status\": \"completed\"," + + "\"result\": \"Plan executed successfully\"," + + "\"steps_completed\": 3," + + "\"total_steps\": 3," + + "\"policy_info\": {" + + " \"allowed\": true," + + " \"applied_policies\": [\"cost-limit\"]," + + " \"risk_score\": 0.2" + + "}" + + "}"; + + PlanExecutionResponse response = objectMapper.readValue(json, PlanExecutionResponse.class); + + assertThat(response.isCompleted()).isTrue(); + assertThat(response.getPlanId()).isEqualTo("plan-123"); + assertThat(response.getResult()).isEqualTo("Plan executed successfully"); + assertThat(response.getPolicyInfo()).isNotNull(); + assertThat(response.getPolicyInfo().isAllowed()).isTrue(); + assertThat(response.getPolicyInfo().getAppliedPolicies()).containsExactly("cost-limit"); + } + + @Test + @DisplayName("PlanExecutionResponse - should handle blocked response") + void planExecutionResponseShouldHandleBlockedResponse() throws Exception { + String json = "{" + + "\"plan_id\": \"plan-456\"," + + "\"status\": \"blocked\"," + + "\"result\": \"Plan execution blocked by policy\"," + + "\"steps_completed\": 0," + + "\"total_steps\": 3," + + "\"policy_info\": {" + + " \"allowed\": false," + + " \"applied_policies\": [\"high-risk-block\"]," + + " \"risk_score\": 0.9" + + "}" + + "}"; + + PlanExecutionResponse response = objectMapper.readValue(json, PlanExecutionResponse.class); + + assertThat(response.isBlocked()).isTrue(); + assertThat(response.isCompleted()).isFalse(); + assertThat(response.getResult()).isEqualTo("Plan execution blocked by policy"); + assertThat(response.getPolicyInfo().isAllowed()).isFalse(); + } + + @Test + @DisplayName("PlanExecutionResponse - should construct with all fields") + void planExecutionResponseShouldConstructWithAllFields() { + PolicyEvaluationResult policyInfo = PolicyEvaluationResult.builder() + .allowed(true) + .riskScore(0.1) + .build(); + + PlanExecutionResponse response = new PlanExecutionResponse( + "plan-789", + "completed", + "done", + 3, + 3, + null, + null, + null, + policyInfo, + null + ); + + assertThat(response.isCompleted()).isTrue(); + assertThat(response.getPlanId()).isEqualTo("plan-789"); + assertThat(response.getResult()).isEqualTo("done"); + assertThat(response.getPolicyInfo()).isEqualTo(policyInfo); + } + + @Test + @DisplayName("PlanExecutionResponse - equals and hashCode") + void planExecutionResponseEqualsAndHashCode() { + PolicyEvaluationResult policyInfo = PolicyEvaluationResult.builder() + .allowed(true) + .riskScore(0.5) + .build(); + + PlanExecutionResponse response1 = new PlanExecutionResponse( + "plan-123", "completed", "done", 2, 2, null, null, null, policyInfo, null + ); + + PlanExecutionResponse response2 = new PlanExecutionResponse( + "plan-123", "completed", "done", 2, 2, null, null, null, policyInfo, null + ); + + assertThat(response1).isEqualTo(response2); + assertThat(response1.hashCode()).isEqualTo(response2.hashCode()); + } + + @Test + @DisplayName("PlanExecutionResponse - toString contains all fields") + void planExecutionResponseToStringShouldContainAllFields() { + PlanExecutionResponse response = new PlanExecutionResponse( + "plan-test", "completed", "test-result", 1, 2, null, null, null, null, null + ); + + String str = response.toString(); + + assertThat(str).contains("plan-test"); + assertThat(str).contains("completed"); + } + + @Test + @DisplayName("PlanExecutionResponse - status helper methods") + void planExecutionResponseStatusHelperMethods() { + PlanExecutionResponse completed = new PlanExecutionResponse( + "p1", "completed", null, 3, 3, null, null, null, null, null + ); + PlanExecutionResponse failed = new PlanExecutionResponse( + "p2", "failed", null, 1, 3, null, null, null, null, null + ); + PlanExecutionResponse blocked = new PlanExecutionResponse( + "p3", "blocked", null, 0, 3, null, null, null, null, null + ); + PlanExecutionResponse inProgress = new PlanExecutionResponse( + "p4", "in_progress", null, 1, 3, null, null, null, null, null + ); + + assertThat(completed.isCompleted()).isTrue(); + assertThat(completed.isFailed()).isFalse(); + assertThat(completed.isBlocked()).isFalse(); + + assertThat(failed.isFailed()).isTrue(); + assertThat(failed.isCompleted()).isFalse(); + + assertThat(blocked.isBlocked()).isTrue(); + assertThat(blocked.isCompleted()).isFalse(); + + assertThat(inProgress.isInProgress()).isTrue(); + assertThat(inProgress.isCompleted()).isFalse(); + } + + @Test + @DisplayName("PlanExecutionResponse - progress calculation") + void planExecutionResponseProgressCalculation() { + PlanExecutionResponse halfDone = new PlanExecutionResponse( + "p1", "in_progress", null, 2, 4, null, null, null, null, null + ); + PlanExecutionResponse allDone = new PlanExecutionResponse( + "p2", "completed", null, 3, 3, null, null, null, null, null + ); + PlanExecutionResponse notStarted = new PlanExecutionResponse( + "p3", "pending", null, 0, 5, null, null, null, null, null + ); + PlanExecutionResponse zeroSteps = new PlanExecutionResponse( + "p4", "pending", null, 0, 0, null, null, null, null, null + ); + + assertThat(halfDone.getProgress()).isEqualTo(0.5); + assertThat(allDone.getProgress()).isEqualTo(1.0); + assertThat(notStarted.getProgress()).isEqualTo(0.0); + assertThat(zeroSteps.getProgress()).isEqualTo(0.0); + } +}