State-Based Workflows (with Parallel Branches)
A second workflow mode for
enezaworkflowengine: states and transitions defined entirely by the developer and drawn by an administrator in the designer, including parallel Fork/Join branches. The existing approval workflow is unchanged.
Table of Contents
- What It Is
- How It Differs From Approval Workflows
- Core Concepts
- State Vocabulary (Developer-Facing)
- Quick Start: Wiring a Document
- Runtime Summary
- Versioning, Activation and Assignees
- See Also
- A Note on Testing
What It Is
A state-based workflow models a document as a set of developer-defined states (for example
reported, triage, assessment, containment, closed, cancelled) and the transitions
between them. No state is hard-coded in the engine: an administrator draws the flow in the workflow
designer and the engine executes the saved graph.
A state can branch to several possible next states, at which point an assignee chooses the transition to take. A dedicated Fork node splits the document into several parallel branches at once (for example a hospital patient sent to the pharmacy and the laboratory), and a dedicated Join node waits until every parallel branch has been performed before continuing automatically.
A template is state-based when its WorkflowTemplate.workflow_kind = 'state_based'
(enezaworkflowengine/models/choices.py:14). 'approval' is the default and keeps the original
behaviour.
How It Differs From Approval Workflows
| Approval workflow | State-based workflow | |
|---|---|---|
| Node types | start, approval, condition, end | start, state, condition, fork, join, end |
| Routing | Computed (condition steps and next_step) |
Actor chooses an allowed transition from a state |
| Parallel paths | Not supported | Fork / Join branches |
| Document field | Approvalstatus |
workflowstate |
| Runtime unit | One current step | One or more active branches (tokens) |
| Terminal | An END step approves the document | Each branch ends at END; the instance completes when no branch remains |
| Validator | validate_workflow_template (rules R1–R21) |
validate_state_workflow_template (rules S1–S20) |
Core Concepts
- START — exactly one per template (
step_type='start'), no incoming transitions and exactly one outgoing transition into a state (S2,S4). - STATE — a
StepTemplatewithstep_type='state'and astate_codedrawn from the template's configured state vocabulary. States carry the assignees and the approval threshold. - CONDITION — a structural
SavedQueryrouter (step_type='condition'). It has no assignees and no state code. When a branch enters it the query is evaluated and the matchingtrueorfalseoutgoing transition is followed automatically. - END — at least one sink (
step_type='end') with no outgoing transitions. A branch that is finished moves into END. - Transition — a
StateTransitionrow (source_state,target_state,label,order). Outgoing transitions of a state are alternatives the actor chooses from; outgoing transitions of a FORK all fire together. - FORK — a
StepTemplatewithstep_type='fork'. On entry it fires all of its outgoing transitions, creating one child branch per target. It has no assignees and no state code, and must point at its matching join throughmatched_join. - JOIN — a
StepTemplatewithstep_type='join'. It is a synchronisation point with no assignees and nostate_code: it waits until every branch of its matching fork has been performed and then continues automatically along its exactly one outgoing transition. It never waits for a user and is never shown by name in the UI. - Branch (token) — a
WorkflowBranchrow representing one live path through the workflow. Sibling branches created by the same fork share afork_scopeUUID so the join can tell when all of them have arrived.
flowchart LR
S[START] --> A[Assessment]
A --> F{Fork}
F --> P[Investigation]
F --> L[Containment]
P --> J{Join}
L --> J
J --> N[Notification]
N --> E[END]State Vocabulary (Developer-Facing)
The set of legal states is defined in code by the developer, never by users in the UI. Two parts:
- The document model declares a
workflowstatefield. It is an ordinaryCharField(commonly declaredwith choices) that the engine writes a single state code into:
class IncidentStatus(models.TextChoices):
REPORTED = "reported", "Reported"
TRIAGE = "triage", "Triage"
# ...
class IncidentReport(EnezaGeneral):
workflowstate = models.CharField(
max_length=100,
blank=True,
choices=IncidentStatus.choices,
verbose_name="Workflow State",
)
- The developer maps each workflow name to its
TextChoicesclass in the project workflow config (framework/workflowconfig/state_choices.py, loaded throughsettings.MY_PROJECT_WORKFLOWS_CONFIG):
# framework/workflowconfig/state_choices.py
WORKFLOW_STATE_CHOICES = {
'IncidentWorkflow': 'frameworkapp.models.IncidentStatus',
}
The designer renders a dropdown of these choices for every state node and activation rejects any
state code that is not in the list (rule S7). Resolution precedence is: the registry for the
workflowname → the per-template WorkflowTemplate.state_choices_path override → auto-discovery
from any document workflowstate field that declares choices
(enezaworkflowengine/services/state_choices.py:164). If no vocabulary can be resolved, the
designer reports that it must be registered. See the
reference guide for the full
algorithm and the diagnostic command.
The authoritative active set at runtime is the WorkflowBranch rows, not the field: with parallel
branches workflowstate holds the code of the oldest active state branch only
(services/state_workflow.py:700-744).
Quick Start: Wiring a Document
- Add the document state field. Declare a
workflowstateCharFieldon the document model. Do not rely onApprovalstatus; the presence ofworkflowstateis what submission checks for (services/workflow_submission.py:86-90). Seeframeworkapp/models.py:170. - Register the state choices. Add the workflow name →
TextChoicespath mapping toWorkflowTypeConfig.WORKFLOW_STATE_CHOICES(framework/workflowconfig/state_choices.py). Verify withpython manage.py check_workflow_state_choices --workflowname IncidentWorkflow. - Create the template. Create a
WorkflowTemplatein the workflow template admin with Workflow Kind: State Based, then open its designer (enezaworkflowengine/views/worflow_design.py:470). The palette offers State, Condition, Fork and Join. - Design and activate. Draw labelled transitions, set
State Codeon states, choose assignees for states, and press Save to Database (this creates a new inactive version). Then activate the version from the admin; activation runs the S1–S20 validator and refuses an invalid graph (enezaadminstrator/views/workflowtemplates.py:394-433). - Set
workflow_template_nameon the view. MixWorkflowMixininto the document detail view and name the workflow:
@auto_url_register
class IncidentreportDetailView(BaseSourceDocumentDetailView):
model = IncidentReport
workflow_template_name = Workflownames.INCIDENTWORKFLOW
(frameworkapp/views/testviews.py:752-754).
6. Submit and move. The Submit to Workflow action enters the initial state; Move to State
appears once per branch the signed-in user is assigned to. Set the assignees in the designer or
with python manage.py assign_state_users (see the
reference guide).
frameworkapp.IncidentReport + IncidentreportDetailView is the shipped worked example
(create_sample_state_workflow builds its IncidentWorkflow template, including the fork/join).
Runtime Summary
- Submit —
submit_workflowlooks up the active template, verifies the document has aworkflowstatefield, creates theWorkflowInstanceand callsstart_state_workflow(services/workflow_submission.py:84-120). - Start — the engine follows START's single outgoing transition, creates the first
WorkflowBranchin that state, records an assignment history entry and writes the document state (services/state_workflow.py:208-256). - Move — an assignee of the branch's current state chooses one of that state's allowed
transitions. The vote is recorded; a single-approver state moves immediately, while
majority/all states wait until one target reaches the threshold. Once satisfied, the step
instance completes, the target is applied and the document state is synchronised
(
services/state_workflow.py). - Fork / Join — a FORK spawns one branch per outgoing transition sharing a
fork_scope; the matching JOIN creates a merged branch and auto-continues once no sibling branch is active (services/state_workflow.py:469-602). - Complete — every branch that reaches END is closed; when no active branch remains the
instance is finalised and
workflowstatekeeps the last business state reached (services/state_workflow.py:758-791).
Full lifecycle, transaction boundaries and the designer save pipeline are in the architecture guide.
Versioning, Activation and Assignees
Editing a template in the designer is versioned:
- Save to Database always clones the loaded version into a new version and marks it latest;
the new version is inactive until explicitly activated
(
services/workflow_versioning.py:11-38). - A state's assignees (users and groups) are only effective on the active version. Saving assignees on an inactive version changes nothing at runtime.
- A state must have at least one effective assignee — an active user, or a group containing at
least one active user. Listed-but-inactive users and empty groups do not count and block
activation (
S16,services/state_template_validator.py:273-297). - A submitted document is pinned to the template version that was active when it started. Activating a newer version does not migrate documents already in flight.
If a document's state shows no assignees the workflow cannot advance: no one sees Move to State
and no assignment notification is sent. Recover by activating the version that carries the
assignees and then creating a new document, or by populating the active version directly with
python manage.py assign_state_users. See
troubleshooting for the
full diagnostic path.
Conditional routing, async execution and recovery
State transitions may carry an optional SavedQuery condition and a condition_mode, and a
dedicated Condition node can route the graph explicitly:
- Manual transitions remain user-selected; their condition is a server-side guard. A false condition is rejected, and an evaluation error places the workflow in a recoverable error state.
- Automatic transitions are evaluated in
orderwhen a state is entered. The first true edge routes immediately; manual edges remain available as a fallback. If no automatic edge matches and no manual edge exists, the workflow stops in a recoverable error instead of choosing an arbitrary edge. - Condition nodes are structural: a
SavedQueryis evaluated on entry, and thetrueorfalseoutgoing transition is followed immediately. They may follow START, STATE, another CONDITION, or a FORK branch, and may target STATE, FORK, CONDITION, JOIN, or END. Evaluation failures follow the node'scondition_failure_behavior(fail_closed,fail_open, orfail_escalate). - Fork and join outgoing edges must remain unconditional. Conditions are allowed only on edges
leaving a
STATE; a CONDITION node owns its query and its edges carry only thetrue/falseresult.
State approval thresholds
A STATE may require more than one approver before its branch moves:
single_approver(or blank): the first assigned user's vote moves the branch.majority_approver:(eligible_approver_count // 2) + 1approvals for one target.all_approver: every eligible approver must approve the same target.
Each assignee votes by choosing a transition. A target fires when its approved-vote count reaches the threshold; remaining pending votes are then closed. Votes are counted per target, so split votes can stall a majority/all state. Group assignment expands to active members, each of whom is an individual approver. The eligible denominator is frozen when the state is entered; deactivating a user or group member does not silently lower it.
Every state entry also queues a state assignment notification to its effective assignees. A
state node can select its own Email Studio template and optionally also notify the workflow
originator. Only templates linked to the workflow template are offered in the designer, and the
template must be active with a published version to send email. When the per-state template is
blank, the workflow-level state_assignment template is used. In-flight instances pinned to an
older version fall back to the active version's state-assignment template for the same state code.
{{ workflow.link_to_web }} and action_url resolve to the document detail URL
(/frameworkapp/incidentreport/<id>/ for Incident Reports) on the current domain. The domain is
captured from the request that submitted or advanced the workflow; existing instances fall back to
WORKFLOW_NOTIFICATION_BASE_URL, the configured Site, or ALLOWED_HOSTS. The same resolver is
used by approval-workflow notifications.
State submission and manual transitions are now asynchronous. The request creates a
WorkflowExecutionRequest outbox row and enqueues process_workflow_execution_request; the request
thread never starts the engine. run_periodic sweeps pending requests and expired delegations.
Notifications, document-state writes and hooks use the WorkflowSideEffect idempotency ledger.
Failures are recorded in WorkflowError with the failed operation, stage, attempt, task/correlation
id and retry payload. The workflow is marked is_in_error; only an administrator with
action.enezaworkflowengine.workflow_operations can resume it with a reason. Resume retries only the
failed operation and records a WorkflowRecoveryAttempt plus RESUME history.
Assignment resolution, HR hierarchy levels, delegation, group approval policies, reassignment and the complete recovery procedure are documented in Workflow Assignments and Recovery.
See Also
- State-Based Workflow Architecture — components, data model, runtime lifecycle, fork/join internals and the designer pipeline.
- State-Based Workflow Reference — choices, model fields,
service signatures, rules S1–S20,
design_dataschema, URLs,WorkflowMixinAPI and management commands. - State-Based Workflow Troubleshooting — symptom → cause → diagnostic → fix.
- Workflow Assignments and Recovery — HR hierarchy, delegation, groups, comments, reassignment and resume.
- Workflow — Developer Index
A Note on Testing
Per project rules, the developer or agent does not run or claim test results. The user is solely responsible for all testing and validation of the output described in this guide.