Approval Workflows
CDX approval flows are centered on a canonical template rather than each app hand-building a new graph.
Current Model
cdx_workflow_templates provides one shared workflow definition:
ApprovalWorkflow.code == 'approval'Every approval domain uses the same workflow graph. Domain-specific behavior is supplied by ApprovalDomain implementations registered through ApprovalTemplate.
final template = ApprovalTemplate(
domains: [
equipmentApprovalDomain,
materialApprovalDomain,
],
);What The Template Provides
| Piece | Purpose |
|---|---|
ApprovalWorkflow.buildDefinition() | Emits the canonical WorkflowDefinition JSON for seeding. |
ApprovalWorkflow.buildDescriptor(...) | Registers the service-task and user-task executors used by the canonical graph. |
ApprovalTemplate | Bundles the definition and descriptor behind the generic WorkflowTemplate contract. |
ApprovalDomain | Supplies typed item/config/decision deserialization, chain resolution, outcome effects, and optional item refresh. |
cdx_workflow_types | Supplies shared approval item/config/decision/action/outcome wire contracts. |
Runtime should load the canonical definition from storage. Treat buildDefinition() as the source used to produce a seed or migration payload, not as mutable runtime state.
Boot
final template = ApprovalTemplate(domains: [equipmentApprovalDomain]);
final context = RegistryTypeResolver(
descriptors: [
DefaultWorkflowDescriptor(),
template.buildDescriptor(),
],
);
final engine = WorkflowEngine(
context: context,
storage: storage,
executionMode: ExecutionMode.production,
);
await engine.initialize();
final definition = await engine.storage.workflows.getByCode(
template.workflowCode,
);
if (definition == null) {
throw StateError('Missing seeded approval workflow definition.');
}
engine.loadWorkflowDefinition(definition);For local tests:
final storage = InMemoryStorage(
workflowLoader: () async => [template.buildDefinition()],
);Starting An Approval
Use ApprovalWorkflowInput so the dispatcher receives the required top-level item_schema_type key.
final input = ApprovalWorkflowInput(
item: item,
config: approvalConfig,
submittedBy: userId,
submittedAt: DateTime.now().toUtc(),
correlationId: item.id,
);
final instance = await engine.startWorkflow(
workflowCode: ApprovalWorkflow.code,
input: input.toJson(),
correlationId: item.id,
userId: userId,
);item_schema_type selects the ApprovalDomain. The selected domain then deserializes the item/config/decision payloads and runs its typed chain/outcome logic.
Domain Contract
abstract base class ApprovalDomain<
TItem extends ApprovalItem,
TConfig extends ApprovalConfig,
TDecision extends ApprovalDecisionPayload
> {
String get itemSchemaType;
Set<ApprovalUserOutcome> get exposedOutcomes;
ApprovalChainResolver<TConfig> get chainResolver;
ApprovalOutcomeEffects<TItem, TConfig, TDecision> get outcomeEffects;
TItem itemFromJson(Map<String, Object?> json);
TConfig configFromJson(Map<String, Object?> json);
TDecision decisionFromJson(Map<String, Object?> json);
Future<TItem> refreshItem(TItem item) async => item;
}Use refreshItem when the approval item can change while a workflow is waiting, such as after a revision/resubmission cycle.
Outcomes And Ports
User-selectable approval outcomes:
| Enum | Port ID |
|---|---|
ApprovalUserOutcome.approved | approve |
ApprovalUserOutcome.rejected | reject |
ApprovalUserOutcome.revisionRequested | request_revision |
Revision-task outcomes:
| Enum | Port ID |
|---|---|
ApprovalRevisionUserOutcome.resubmitted | resubmit |
ApprovalRevisionUserOutcome.cancelled | cancel |
Service-task ports:
| Enum | Port ID |
|---|---|
ApprovalServicePort.proceed | continue |
ApprovalServicePort.finalize | finalize |
ApprovalServicePort.failure | failure |
The template uses enum-backed node and port identifiers from cdx_workflow_types to avoid parallel magic strings.
Revision Loop
The current approval definition includes an in-workflow revision loop:
approval_decision(request_revision)
-> on_revision_requested
-> revision_user_task
-> on_resubmitted
-> increment_levelIf the submitter cancels from the revision task, the workflow routes to on_cancelled.
Failure Routing
Version 4 of the canonical approval definition wires a failure output port from every service task and user task to on_failed.
When a task executor or user-task executor throws:
- The engine wraps the failure in
TaskFailureInfo. - If a failure port is wired, the token routes through that edge.
- The approval
on_faileddispatcher runs domain outcome effects. - If no failure edge is wired, the workflow fails directly.
This makes task-level failures visible to domain-specific audit and cleanup logic.
Feature Inbox
Approval user tasks surface through cdx_feature_workflows and cdx_workflow_types.
The workflow service should serialize typed user actions from UserTaskActionResolverRegistry. The Flutter app passes the server-provided actions to WorkflowActionBar:
WorkflowActionBar(
actions: task.availableActions,
onAction: (action) async {
await submitAction(action);
},
)The client renders action labels, icons, and severity from the sealed UserTaskAction subtype. It should not re-derive approval outcomes from workflow node IDs.
When To Use WorkflowBuilder
Use WorkflowBuilder for:
- Tests.
- Local examples.
- Lightweight app-specific workflows.
Use ApprovalTemplate for CDX approval governance. The canonical template already handles multi-level chains, revision, cancellation, failure routing, typed actions, and domain dispatch.