Skip to content

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:

dart
ApprovalWorkflow.code == 'approval'

Every approval domain uses the same workflow graph. Domain-specific behavior is supplied by ApprovalDomain implementations registered through ApprovalTemplate.

dart
final template = ApprovalTemplate(
  domains: [
    equipmentApprovalDomain,
    materialApprovalDomain,
  ],
);

What The Template Provides

PiecePurpose
ApprovalWorkflow.buildDefinition()Emits the canonical WorkflowDefinition JSON for seeding.
ApprovalWorkflow.buildDescriptor(...)Registers the service-task and user-task executors used by the canonical graph.
ApprovalTemplateBundles the definition and descriptor behind the generic WorkflowTemplate contract.
ApprovalDomainSupplies typed item/config/decision deserialization, chain resolution, outcome effects, and optional item refresh.
cdx_workflow_typesSupplies 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

dart
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:

dart
final storage = InMemoryStorage(
  workflowLoader: () async => [template.buildDefinition()],
);

Starting An Approval

Use ApprovalWorkflowInput so the dispatcher receives the required top-level item_schema_type key.

dart
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

dart
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:

EnumPort ID
ApprovalUserOutcome.approvedapprove
ApprovalUserOutcome.rejectedreject
ApprovalUserOutcome.revisionRequestedrequest_revision

Revision-task outcomes:

EnumPort ID
ApprovalRevisionUserOutcome.resubmittedresubmit
ApprovalRevisionUserOutcome.cancelledcancel

Service-task ports:

EnumPort ID
ApprovalServicePort.proceedcontinue
ApprovalServicePort.finalizefinalize
ApprovalServicePort.failurefailure

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:

text
approval_decision(request_revision)
  -> on_revision_requested
  -> revision_user_task
  -> on_resubmitted
  -> increment_level

If 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:

  1. The engine wraps the failure in TaskFailureInfo.
  2. If a failure port is wired, the token routes through that edge.
  3. The approval on_failed dispatcher runs domain outcome effects.
  4. 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:

dart
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.

See Also