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

  1. What It Is
  2. How It Differs From Approval Workflows
  3. Core Concepts
  4. State Vocabulary (Developer-Facing)
  5. Quick Start: Wiring a Document
  6. Runtime Summary
  7. Versioning, Activation and Assignees
  8. See Also
  9. 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 StepTemplate with step_type='state' and a state_code drawn from the template's configured state vocabulary. States carry the assignees and the approval threshold.
  • CONDITION — a structural SavedQuery router (step_type='condition'). It has no assignees and no state code. When a branch enters it the query is evaluated and the matching true or false outgoing 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 StateTransition row (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 StepTemplate with step_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 through matched_join.
  • JOIN — a StepTemplate with step_type='join'. It is a synchronisation point with no assignees and no state_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 WorkflowBranch row representing one live path through the workflow. Sibling branches created by the same fork share a fork_scope UUID 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:

  1. The document model declares a workflowstate field. It is an ordinary CharField (commonly declared with choices) that the engine writes a single state code into:
python
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",
    )
  1. The developer maps each workflow name to its TextChoices class in the project workflow config (framework/workflowconfig/state_choices.py, loaded through settings.MY_PROJECT_WORKFLOWS_CONFIG):
python
# 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

  1. Add the document state field. Declare a workflowstate CharField on the document model. Do not rely on Approvalstatus; the presence of workflowstate is what submission checks for (services/workflow_submission.py:86-90). See frameworkapp/models.py:170.
  2. Register the state choices. Add the workflow name → TextChoices path mapping to WorkflowTypeConfig.WORKFLOW_STATE_CHOICES (framework/workflowconfig/state_choices.py). Verify with python manage.py check_workflow_state_choices --workflowname IncidentWorkflow.
  3. Create the template. Create a WorkflowTemplate in 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.
  4. Design and activate. Draw labelled transitions, set State Code on 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).
  5. Set workflow_template_name on the view. Mix WorkflowMixin into the document detail view and name the workflow:
python
@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

  1. Submit — submit_workflow looks up the active template, verifies the document has a workflowstate field, creates the WorkflowInstance and calls start_state_workflow (services/workflow_submission.py:84-120).
  2. Start — the engine follows START's single outgoing transition, creates the first WorkflowBranch in that state, records an assignment history entry and writes the document state (services/state_workflow.py:208-256).
  3. 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).
  4. 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).
  5. Complete — every branch that reaches END is closed; when no active branch remains the instance is finalised and workflowstate keeps 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 order when 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 SavedQuery is evaluated on entry, and the true or false outgoing 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's condition_failure_behavior (fail_closed, fail_open, or fail_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 the true/false result.

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) + 1 approvals 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

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.