Workflow tasks
View MarkdownStatus: Stable Package:
dev.vertique.workflow.tasksArtifact:vertique-workflow-tasksDepends on: workflow-core API reference: javadoc.io
vertique-workflow-tasks provides the application-facing API for human tasks created by workflows. When the engine reaches a human-task step, it creates a task row and suspends the workflow instance. This module lets application code list, read, complete, and reassign those tasks.
The module is intentionally thin. It contains no SQL implementation and no workflow mutation logic. Storage and engine callbacks come from the installed persistence module.
What It Provides
Section titled “What It Provides”| API | Purpose |
|---|---|
TaskService |
Application-facing service that opens its own transaction |
TransactionalTaskService<TX> |
Same operations inside a caller-provided transaction |
WorkflowTasksModule |
Dagger bindings for the task services |
| Compose validator | Optional startup check that required task bindings exist |
The engine still owns state transitions. Completing or reassigning a task delegates to engine callbacks so workflow state, history, timers, and idempotency are updated consistently. Completing a task is a two-step operation: complete validates and records the completion and reserves the task (COMPLETING), and the workflow executor applies the decision afterwards (see Recorded completion).
Task API
Section titled “Task API”public interface TaskService { Future<PagedResult<TaskRecord>> list(TaskFilter filter, PageCursor cursor); Future<Optional<TaskRecord>> get(UUID taskId); Future<Void> complete(TaskCompletionCommand cmd); Future<Void> reassign(TaskReassignmentCommand cmd);}Use TransactionalTaskService<TX> when task work must participate in a larger transaction.
Task commands require an idempotency key. A retry with the same key and same logical payload succeeds as an idempotent retry. Reusing the same key with a different logical payload fails with an idempotency conflict.
Recorded completion
Section titled “Recorded completion”complete(...) returns once the completion is recorded, not once it is applied:
- A committed duplicate (same key and same fingerprint) returns its earlier result at once, before any other check; it still succeeds after
workflow.recording.maxPayloadBytesor the task’s stored schema has changed. - Without taking a lock, the payload’s size and depth bounds are checked and, when the task’s decision snapshot stores a JSON Schema for the requested decision, the payload is validated against it. A violation fails with
WorkflowInvalidPayloadException; nothing is written. - Under the instance’s execution lock the task must still be
OPEN(otherwiseWorkflowTaskNotWaitingException), a suppliedexpectedAssignmentmust still match (WorkflowStaleAssignmentException), thereviewedSubjectVersionmust match when the task requires version stability (WorkflowStaleSubjectVersionException), and the decision must be one the task offers. - The task becomes
COMPLETING, owned by the recordedCOMPLETIONcommand, and the call returns.
The executor then applies the decision in its own transaction: the task becomes COMPLETED and the instance advances. If the application of the decision fails deterministically (a throwing decision applicator, a payload that does not bind to the declared type on a task created before schemas were stored, a step-limit overrun), the command is rejected with a sanitized reason, the instance is unchanged and the task is OPEN again; complete it again with a new idempotency key. A COMPLETING task refuses a second completion and a reassignment with WorkflowTaskNotWaitingException. To observe the outcome, wait with the workflow execution probe (awaitIdle) and read the task back with get(...).
Defining Human Tasks
Section titled “Defining Human Tasks”Human tasks are declared in workflow definitions through workflow-core:
wf.task("review") .assignToRole("editors") .requireVersionStability() .decision("approve", ApprovalPayload.class) .onDecision((state, payload) -> state.approve(payload.approvedBy())) .toStep("publish") .decision("reject", RejectionPayload.class) .onDecision((state, payload) -> state.reject(payload.reason())) .toStep("rejected") .dueIn(Duration.ofDays(3)) .onDue(state -> state.markReviewOverdue()) .toStepOnDue("escalate") .build();Assignment can be literal or resolved from workflow state. Decisions carry typed payloads and update workflow state before moving to their next step. Use void.class as the payload type to declare a decision that carries no payload. Due dates and reminders require the delayed workflow module.
Version-stable tasks compare the reviewed subject version supplied by the caller with the version snapshotted when the task was created. This is useful for approval flows over mutable domain objects.
Required App Wiring
Section titled “Required App Wiring”@Singleton@Component(modules = { WorkflowCoreModule.class, WorkflowPostgresqlModule.class, WorkflowTasksModule.class, WorkflowServicesModule.class, TransactionalMessagingPostgresqlModule.class, TransactionalMessagingServiceModule.class, DbPostgresqlModule.class, DbFlywayModule.class, AppModule.class})public interface AppComponent { TaskService taskService(); WorkflowTasksComposeValidator workflowTasksComposeValidator();}Human tasks with due dates or reminders need no further module: their timers are commands that the workflow executor of vertique-workflow-postgresql fires.
Expose and call workflowTasksComposeValidator() during startup if you want fail-fast validation before serving requests.
Extension Points
Section titled “Extension Points”| Extension point | Purpose |
|---|---|
TaskStore<TX> |
Persists task rows; supplied by workflow-postgresql |
TransactionalTaskCallbacks<TX> |
Engine mutation callbacks used by task completion and reassignment |
TaskService |
Application API for task inboxes and task actions |
TransactionalTaskService<TX> |
Transaction-aware task API |
Application code should use TaskService or TransactionalTaskService<TX>. It should not call TransactionalTaskCallbacks<TX> directly.
Operational Notes
Section titled “Operational Notes”- Human task completion and reassignment are idempotent when callers reuse the same idempotency key for the same logical command.
- A task with a due date depends on workflow timer infrastructure.
- Transactions that touch timers, tasks, and workflow instances must follow the lock order enforced by the engine and persistence module.
- Authorization is application-defined. The workflow engine records the actor supplied on the task command but does not decide whether that actor may act.
Invariants & Gotchas
Section titled “Invariants & Gotchas”- All-or-nothing due-date triplet. A task step’s optional due-date sub-chain (
.dueDate(...),.onDueMutator(...),.dueNextStepId(...)) must specify all three or none. Partial triplets are rejected at construction by theHumanTaskNodecompact constructor. - Non-empty unique decisions. Each task step must declare at least one decision, and decision names within one step must be unique.
TaskFiltersingle-assignee-kind guard. At most one ofassigneeUser/assigneeRole/assigneeQueuemay be set. UseTaskFilter.empty()plus thewith*copies to compose filters.withScopeKey(...)adds an exact-match start-scope filter (nullmatches every scope; it is a filter, not an authorization boundary: see the core module reference).- Required actor.
TaskCompletionCommandandTaskReassignmentCommandreject null / blankWorkflowActorvia their compact constructors. Authorization is the application’s job; the actor recorded here is for audit and idempotency. - Blank
reasonnormalization. Blankreasonstrings on commands are normalized tonullbefore idempotency fingerprinting, so callers who omit a reason do not get treated as different commands. - Return contract.
complete/reassignreturnFuture<Void>. There is no result enum on the application-facing API: a succeeded future means the command’s effect is persisted (forcomplete, that the completion is recorded and the task reserved; the executor applies it afterwards), and every other outcome is a failed future carrying a typed exception —WorkflowTaskNotWaitingException,WorkflowConflictException,WorkflowIdempotencyConflictException,WorkflowTaskNotFoundException,WorkflowStaleSubjectVersionException,WorkflowStaleAssignmentException, orWorkflowInvalidPayloadException(400 semantics: unknown decision, payload missing or unexpected for the decision, or a payload that does not bind to the declared type). These operations never report a problem by returning a “nothing happened” value. - Replays succeed silently. A retry with the same idempotency key and the same logical payload succeeds without re-applying the effect, and is indistinguishable from the original call — the future carries no marker saying which call did the work. A replayed completion returns before any payload check. Callers that must know whether this call changed anything have to read the task back with
get(...). - A completed call is not a completed task. After
completesucceeds the task isCOMPLETING; it isCOMPLETEDonly once the executor has applied the decision, andOPENagain if the decision was rejected. Code that reads the task or the instance right aftercompletemust wait for the executor (awaitIdle) first. A due-date or reminder timer that fires while the task isCOMPLETINGfinds it notOPENand settles itself as stale. STALE_NOOPis not an outcome of these operations. It belongs to the executor’s due-date timer path, where a workflow has legitimately moved past the wait before the timer fires. If a customTransactionalTaskCallbacks<TX>implementation returns it fromtaskCompleted/taskReassigned, the service fails the future withIllegalStateExceptionrather than silently reporting success. Treat that as a bug in the SPI implementation, not as a case to handle.- Version-stable approvals. When a step calls
.requireVersionStability(), completion must supply thereviewedSubjectVersionthe reviewer saw. A mismatch fails withWorkflowStaleSubjectVersionException; creation without a snapshotablesubject_versionfails withWorkflowSubjectVersionUnavailableException. - Eligibility without a race. To authorize on the current assignment, read the task with
get(...), decide eligibility fromtask.assignment(), and pass that same assignment asexpectedAssignmenton thecomplete/reassigncommand. If a concurrent reassignment moves the task first, the command fails withWorkflowStaleAssignmentException(409 semantics) instead of applying under a stale eligibility decision; re-read the task and decide again.expectedAssignmentis not part of the idempotency fingerprint, andnullskips the check. OnTransactionalTaskService<TX>the caller owns the transaction and must roll it back when this exception is raised: the dedup claim and timer cancellations written before the check would otherwise be committed with the task still open. The same holds whencompleteis rejected withWorkflowInvalidPayloadException: roll the transaction back so the idempotency claim is released and a retry with a corrected payload can complete the task.
Dependencies
Section titled “Dependencies”- workflow-core - task records, filters, commands, service interfaces, and engine SPIs.