Verifiable workflows¶
sagacity-workflows gives Spring AI agents Atomic-style declarative workflows:
ordered stages, human approval gates, pre-flight checks, and automatic compensation
when anything fails — all in plain Java annotations.
Add the dependency¶
<dependency>
<groupId>io.github.sumitvairagar</groupId>
<artifactId>sagacity-workflows</artifactId>
<version>0.3.0</version>
</dependency>
Requires sagacity-spring-boot-starter (provides the SideEffectJournal).
Minimal example¶
@Workflow("refund-request")
@Component
public class RefundWorkflow {
@Stage(order = 1)
@Compensable(by = "cancelRefund")
public RefundConfirmation issueRefund(String orderId) {
return payments.refund(orderId);
}
@Stage(order = 2)
@Gate(approvalRequired = true, reason = "Email cannot be unsent")
public void notifyCustomer(RefundConfirmation refund) {
email.send(refund.customerId(), "Your refund is on its way");
}
@Compensation
public void cancelRefund(CompensationContext ctx) {
payments.reverse(ctx.result());
}
}
Run it:
@Service
public class OrderService {
private final WorkflowRuntime runtime;
private final RefundWorkflow refundWorkflow;
public void processRefund(String orderId) throws InterruptedException {
WorkflowHandle handle = runtime.runAsync(refundWorkflow, orderId);
// Workflow pauses at the @Gate until someone approves
handle.awaitCompletion(60, TimeUnit.MINUTES);
if (handle.status() == WorkflowStatus.FAILED) {
log.error("Refund workflow failed: {}", handle.failureReason().orElse("unknown"));
}
}
}
Stage output chaining¶
The return value of each stage is automatically injected as the first parameter of the next stage — provided the types are compatible:
@Stage(order = 1)
public OrderDetails lookupOrder(String orderId) {
return orders.find(orderId); // returns OrderDetails
}
@Stage(order = 2)
@Compensable(by = "cancelReservation")
public Reservation reserveInventory(OrderDetails order) {
// 'order' is the return value of stage 1 — injected automatically
return inventory.reserve(order.sku(), order.qty());
}
@Stage(order = 3)
@Compensable(by = "cancelCharge")
public ChargeReceipt chargeCard(Reservation reservation) {
// 'reservation' is the return value of stage 2
return payments.charge(reservation.customerId(), reservation.total());
}
If types do not match, or a stage returns void, the runtime passes null
for the next stage's parameter.
Human approval gates¶
@Gate(approvalRequired = true) pauses the workflow before the annotated stage.
The workflow transitions to PAUSED_AT_GATE and waits indefinitely (or until
timeoutSeconds).
Two ways to approve or reject:
REST endpoint (auto-registered by sagacity-workflows):
# Approve
curl -X POST http://localhost:8080/sagacity/workflows/{runId}/gates/notifyCustomer/approve
# Reject with reason
curl -X POST http://localhost:8080/sagacity/workflows/{runId}/gates/notifyCustomer/reject \
-H "Content-Type: application/json" \
-d '{"reason": "customer requested cancellation"}'
Programmatically (from another service, Slack bot, etc.):
workflowRuntime.approveGate(runId, "notifyCustomer");
workflowRuntime.rejectGate(runId, "notifyCustomer", "customer cancelled");
When rejected, the workflow fails and all completed @Compensable stages are
compensated in reverse order — exactly as if the stage itself had thrown an exception.
Timeout¶
@Gate(approvalRequired = true, timeoutSeconds = 3600) // 1 hour
public void sendWireTransfer(PaymentDetails payment) { ... }
When the timeout fires, the workflow transitions to FAILED and compensation runs.
Pre-flight checks¶
@Check runs one or more StageCheck implementations before the stage executes.
If any check fails, the stage never runs — the workflow fails and compensates.
This is where you put: budget enforcement, Jev risk scoring, precondition validation.
@Stage(order = 3)
@Check({BudgetCheck.class, JevRiskCheck.class})
@Compensable(by = "revertCharge")
public ChargeReceipt chargeCard(PaymentDetails payment) { ... }
Implement StageCheck:
@Component
public class BudgetCheck implements StageCheck {
@Override
public CheckResult check(StageCheckContext ctx) {
double estimated = parseAmount(ctx.stageInput());
if (estimated > dailyBudget.remaining()) {
return CheckResult.fail("daily budget exceeded: " + estimated);
}
return CheckResult.pass();
}
}
StageCheckContext provides: runId(), workflowName(), stageName(),
stageOrder(), stageInput() (the stage's input as a string).
Compensation¶
Pair any @Stage with @Compensable(by = "methodName"). If the workflow fails
at any point, all completed stages with declared compensations are run in reverse
order.
@Stage(order = 1)
@Compensable(by = "cancelReservation")
public Reservation reserveInventory(String sku) { ... }
@Stage(order = 2)
@Compensable(by = "cancelCharge")
public ChargeReceipt chargeCard(Reservation reservation) { ... }
@Stage(order = 3) // no @Compensable — nothing to undo
public void sendConfirmation(ChargeReceipt receipt) {
throw new RuntimeException("email service down");
}
@Compensation
public void cancelCharge(CompensationContext ctx) { ... } // runs first
@Compensation
public void cancelReservation(CompensationContext ctx) { ... } // runs second
Compensation failures are journaled and the run continues — partial cleanup beats stopping halfway.
Checking workflow status¶
// Synchronous — blocks until completion
WorkflowRun run = runtime.run(myWorkflow, input);
System.out.println(run.status()); // COMPLETED or FAILED
System.out.println(run.completedStages()); // ["stage1", "stage2"]
// Asynchronous
WorkflowHandle handle = runtime.runAsync(myWorkflow, input);
handle.awaitCompletion(30, TimeUnit.SECONDS);
// List all runs
List<WorkflowRun> runs = runtime.allRuns();
// Find a specific run
Optional<WorkflowRun> run = runtime.findRun(runId);
REST:
Response:
{
"runId": "a1b2c3d4-...",
"workflowName": "refund-request",
"status": "PAUSED_AT_GATE",
"currentStageOrder": 2,
"pendingGateStageName": "notifyCustomer",
"completedStages": ["issueRefund"],
"startedAt": "2026-09-22T11:00:00Z",
"completedAt": null
}
Startup topology validation¶
The runtime validates all @Workflow beans when the Spring context starts.
These problems crash the application at startup, not during a production run:
- No
@Stagemethods on a@Workflowclass - Duplicate
@Stageorders within one workflow @Compensable(by = "x")with no@Compensationmethod namedx
Workflow status transitions¶
RUNNING ──────────────────────────────────► COMPLETED
│ ▲
├──► PAUSED_AT_GATE ──(approved)──────────────┘
│ │
│ └──(rejected / timeout)──► FAILED
│ ▲
└──(stage fails / check fails)─────────┤
COMPENSATING
(before FAILED)
In-memory state (v0.3)¶
Workflow state is held in memory. Runs are lost on JVM restart. This is intentional for v0.3 — most agent workflows are short-lived.
JDBC-backed durable state is on the roadmap for v0.4. The store is accessed
only through WorkflowRuntime, so the upgrade will be a configuration change,
not a code change.