State-Based Workflow Architecture

Developer guide for the state-based (workflow_kind='state_based') workflow engine. Part of the Workflow series. Entry point: State-Based Workflows.

Table of Contents

  1. Component Map
  2. Data Model
  3. Runtime Lifecycle
  4. Branches, Forks and Joins
  5. Document State Synchronisation
  6. History and Notifications
  7. Designer Load/Save Pipeline
  8. Versioning
  9. Runtime UI Wiring
  10. See Also
  11. A Note on Testing

Component Map

flowchart TD
    subgraph Designer["Designer + Admin"]
        D1["WorkflowDesignerSaveView<br/>views/worflow_design.py:536"]
        D2["_save_state_workflow_template<br/>worflow_design.py:365"]
        D3["WorkflowTemplateActivateView<br/>enezaadminstrator/views/workflowtemplates.py:394"]
    end

    subgraph Persisted["Persisted graph"]
        T["WorkflowTemplate<br/>workflow_kind, state_choices_path, design_data"]
        S["StepTemplate<br/>step_type, state_code, matched_join, assignees"]
        E["StateTransition<br/>source_state -> target_state"]
    end

    subgraph Engine["Runtime engine"]
        SUB["submit_workflow<br/>services/workflow_submission.py:38"]
        START["start_state_workflow<br/>services/state_workflow.py:208"]
        MOVE["transition_branch<br/>state_workflow.py:263"]
        DISP["_dispatch_target<br/>state_workflow.py:377"]
        FKJ["_fire_fork / _arrive_at_join<br/>state_workflow.py:469, 526"]
        FIN["_finalize_if_done<br/>state_workflow.py:758"]
    end

    subgraph Runtime["Runtime state"]
        I["WorkflowInstance<br/>state_status, current_step"]
        B["WorkflowBranch<br/>fork_scope, parent_branch"]
        SI["WorkflowStepInstance<br/>branch"]
        AA["WorkflowApproverAction"]
        TR["WorkflowTransaction<br/>audit + metadata"]
    end

    DOC["Document.workflowstate"]
    TASK["apply_document_state<br/>tasks/workflow_tasks.py:648"]

    D1 --> D2 --> S
    D2 --> E
    D3 --> T
    S --> SUB
    E --> SUB
    SUB --> I
    SUB --> START
    START --> B
    B --> MOVE
    MOVE --> SI
    MOVE --> DISP
    DISP --> FKJ
    DISP --> FIN
    FKJ --> B
    MOVE --> AA
    MOVE --> TR
    FIN --> TR
    B --> TASK
    TASK --> DOC

The engine is split so the persisted graph is read-only at runtime: StateTransition and StepTemplate describe what may happen, while WorkflowBranch and WorkflowStepInstance describe what did happen. Nothing in services/state_workflow.py mutates the template graph.

Data Model

Fields marked new were added for state-based workflows; the rest are shared with the approval engine.

WorkflowTemplate — enezaworkflowengine/models/workflowtemplate.py:15

Field Type Default Purpose
workflow_kind new CharField(20), WorkflowKind.choices approval Selects the validator and the runtime engine.
state_choices_path new CharField(255) '' Optional per-template override for the state vocabulary path.
design_data JSONField {} Last designer layout snapshot (nodes + connections).
version / is_latest / cloned_from int / bool / self-FK 1 / True / None Versioning; see Versioning.

StepTemplate — workflowtemplate.py:83

Field Type Purpose
step_type CharField(50), WorkflowExecutionType.choices One of start, approval, condition, end, state, fork, join (models/choices.py:5).
state_code new CharField(100), blank The state code a state node represents; must belong to the resolved choices (S7).
approval_threshold CharField(50), ApprovalThreshold.choices Single/majority/all voting policy for a state; blank means single.
state_assignment_template_code CharField(100), blank Per-state assignment Email Studio template.
notify_originator_on_assignment BooleanField, False Also notify the workflow originator.
matched_join new self-FK, on_delete=SET_NULL, related_name='matched_fork' On a fork, points at the join that terminates its scope.
assignee_users / assignee_groups M2M User / Group Who may move the state. Conditions/forks/joins/starts/ends must not have any (S9, S16, S18).

StateTransition — workflowtemplate.py:127

Field Type Purpose
workflowtemplate FK, CASCADE, related_name='state_transitions' Owning template.
source_state / target_state FK StepTemplate, CASCADE Edge endpoints.
label CharField(150), blank Optional override for the transition display name.
order PositiveIntegerField, 0 Display/iteration order; the picker lists transitions in order, id.
condition_expression / condition_mode FK SavedQuery, blank / choice Manual guard or automatic state route.
condition_result CharField(10), blank true/false on edges leaving a CONDITION node.
comment_required BooleanField, False Whether a manual transition requires a comment.

Unique constraint: (workflowtemplate, source_state, target_state).

WorkflowInstance — workflowtemplate.py:165

Field Type Purpose
state_status new CharField(20), StateLifecycle.choices, nullable Authoritative lifecycle for state-based instances: active / completed / cancelled.
status CharField(30), ApprovalStatus.choices Mirrored document status (in_review → approved/rejected).
current_step FK StepTemplate, nullable The primary active state (oldest active state branch), kept in sync by sync_document_state.
is_active / is_completed / submitted_at / completed_at flags / datetimes Completion bookkeeping.

WorkflowBranch — enezaworkflowengine/models/workflowinstance.py:70 (new)

Field Type Purpose
workflow_instance FK, CASCADE, related_name='branches' Owning instance.
current_state FK StepTemplate, PROTECT, nullable Node this token sits on (a state, fork, join or end).
parent_branch self-FK, SET_NULL, related_name='child_branches' The branch a fork consumed when spawning this one.
fork_scope UUIDField, indexed, nullable Shared id of all sibling branches created by one fork firing.
fork_node / join_node FK StepTemplate, SET_NULL The fork/join that bounds this branch's scope.
status CharField(20), StateLifecycle.choices active / completed / cancelled.
started_at / completed_at datetimes Ordered by started_at, id, which is what makes "oldest active branch" deterministic.

WorkflowStepInstance — workflowinstance.py:16

Field Type Purpose
branch new FK WorkflowBranch, SET_NULL, related_name='step_instances' Links the audit step instance to its token.
step_template FK StepTemplate, PROTECT The node entered.
status / is_current / iteration ApprovalStatus / bool / int An iteration counter is incremented per (instance, step_template) visit.

WorkflowApproverAction — workflowinstance.py:141

State-based states pre-create one pending row per effective assignee when the state is entered. State-threshold votes set target_state on the row; the unique constraint remains (step_instance, approver).

WorkflowTransaction — workflowinstance.py:197

Immutable audit trail. Fields: workflow_instance, step_instance (nullable), action_type (WorkflowActionType, models/choices.py:23), actor, step_name, message, metadata. Ordered by created_at, id.

Runtime Lifecycle

sequenceDiagram
    actor U as Submitter
    participant SUB as submit_workflow
    participant START as start_state_workflow
    participant MOVE as transition_branch
    participant DISP as _dispatch_target
    participant DOC as apply_document_state

    U->>SUB: Submit to Workflow
    SUB->>SUB: check workflowstate field
    SUB->>SUB: create WorkflowInstance(state_status=active)
    SUB->>START: start_state_workflow(instance)
    START->>START: create WorkflowBranch(initial state)
    START->>DOC: sync_document_state()
    START-->>U: instance running

    U->>MOVE: Move to State (target)
    MOVE->>MOVE: SELECT FOR UPDATE instance + branch
    MOVE->>MOVE: validate state_status, allowed edge, assignee
    MOVE->>MOVE: complete current step + close approver actions
    MOVE->>DISP: dispatch target
    alt target is STATE
        DISP->>DISP: _move_branch_to_state
    else target is FORK
        DISP->>DISP: _fire_fork
    else target is JOIN
        DISP->>DISP: _arrive_at_join (auto-continues)
    else target is END
        DISP->>DISP: _complete_branch_at_end
    end
    MOVE->>DOC: sync_document_state(final_code)
    MOVE->>MOVE: _finalize_if_done()

1. Submit — services/workflow_submission.py:38-146

submit_workflow(document, user, template=None, workflow_name=None):

  • Rejects a document that already has an is_active=True instance (:58-67).
  • Resolves the active template when none is passed (:69-82).
  • For workflow_kind == 'state_based' requires document.workflowstate (:84-90).
  • Creates WorkflowInstance with state_status=StateLifecycle.ACTIVE (:98-109).
  • Writes a CREATION transaction, then delegates to start_state_workflow and returns early — the approval-engine path (start_workflow_instance, Celery step processing) is not used (:111-120).

WorkflowSubmissionError is defined at workflow_submission.py:33.

2. Start — services/state_workflow.py:208-256

  • get_initial_node(template) reads the START node's single outgoing transition (with get_initial_state kept as an alias).
  • Raises StateWorkflowError when START is missing or points nowhere, or when a state/condition initial target lacks assignees/query.
  • Creates the first WorkflowBranch. A state target gets its WorkflowStepInstance, pending approver actions, ASSIGNMENT history and assignment notification. A condition target is routed immediately; the eventual state performs its own assignment and notification.

3. Main transition — services/state_workflow.py:263-327

transition_branch(workflow_instance, branch, target_state, user, comment='') runs inside a single transaction.atomic() block:

  1. Re-reads the instance and the branch with SELECT FOR UPDATE via _for_update — skipped on backends without has_select_for_update (SQLite) (:45-49, :265-271).
  2. Rejects a non-active instance with WorkflowAlreadyCompletedError (:273-274).
  3. Verifies the branch belongs to the instance and is still active (:276-280).
  4. Verifies the (template, source_state, target_state) StateTransition exists; otherwise raises StateWorkflowError (:286-294).
  5. Re-checks user_can_transition(source_state, user, document) inside the lock.
  6. Records the user's WorkflowApproverAction vote with its target_state, plus APPROVAL history and a comment.
  7. If the target has fewer than state_required_approvals votes, records threshold-progress history and returns without moving. Otherwise completes the current step instance, closes pending votes, writes STATUS_CHANGE with threshold metadata, dispatches the target, calls sync_document_state(...) and _finalize_if_done(...).

StateWorkflowError (state_workflow.py:40) subclasses WorkflowTransitionError (services/workflow_errors.py:1) and carries code="STATE_WORKFLOW_ERROR", http_status=400; WorkflowAlreadyCompletedError carries code="WORKFLOW_COMPLETED", http_status=409 (workflow_errors.py:57).

4. Target dispatch — services/state_workflow.py:377-400

_dispatch_target branches on target_state.step_type:

Target Handler Effect
state _move_branch_to_state (:403) Repoint the branch, create a step instance, warn if unassigned, notify assignees.
condition _route_condition_node Evaluate the node's SavedQuery and dispatch its true/false target automatically.
fork _fire_fork (:469) Complete the parent branch and spawn children.
join _arrive_at_join (:526) Complete this branch; if it is the last sibling, create the merged branch and auto-continue.
end _complete_branch_at_end (:451) Mark the branch completed and return its last business state code.

Any other step type raises StateWorkflowError (:398-400).

5. Completion — services/state_workflow.py:700-791

After every dispatch the engine calls sync_document_state then _finalize_if_done. _finalize_if_done (:758-791) returns False while any WorkflowBranch with status=active exists. When none remain it sets state_status=completed, status=approved, is_active=False, is_completed=True, completed_at, clears current_step, writes a COMPLETION transaction and queues a state_completion notification to the initiator.

Cancel — services/state_workflow.py:330-370

cancel_state_workflow(workflow_instance, user=None, reason='') cancels every active branch (status=cancelled), completes their step instances as rejected, sets the instance to state_status=cancelled, status=rejected, is_completed=True, records a STATUS_CHANGE transaction and clears the document state (_queue_document_state(instance, '')).

Note: cancel_state_workflow is a service API only. It is not wired to a URL, the action menu or WorkflowMixin; there is no cancel UI.

Branches, Forks and Joins

The runtime unit is the WorkflowBranch token. A plain transition keeps one active branch; a fork spawns children; a join consumes them.

Firing a fork — state_workflow.py:469-523

  1. join_node = fork_node.matched_join (may be None).
  2. Requires at least two outgoing transitions; otherwise raises StateWorkflowError (:472-475).
  3. Generates a fresh scope = uuid.uuid4().
  4. Completes the parent branch and points it at the fork node (:479-482).
  5. For each outgoing transition creates a child WorkflowBranch carrying parent_branch, fork_scope, fork_node and join_node (:485-495).
  6. A child targeting a state gets a step instance, an unassigned warning and a notification; a child targeting a nested fork recurses through _fire_fork (:497-502).
  7. Writes an ASSIGNMENT transaction listing the spawned states with their assignees and metadata fork_scope, fork_node, join_node, targets (:504-523).

Arriving at a join — state_workflow.py:526-602

  1. Records scope = branch.fork_scope and parent = branch.parent_branch.
  2. Completes the arriving branch, pointing it at the join node (:530-533).
  3. Counts remaining active branches with the same fork_scope. If any exist the join is not yet ready and the call returns None (:535-554).
  4. When the scope is clear, creates a merged WorkflowBranch that inherits the parent's scope chain (parent.parent_branch, parent.fork_scope, parent.fork_node, parent.join_node) so a nested join restores the outer scope (:558-566).
  5. Requires exactly one outgoing transition. If not, it logs an error, creates a step instance on the merged branch and returns without continuing (:577-586).
  6. Otherwise it writes the "continued automatically" transaction and calls _dispatch_target for the join's target, returning whatever that yields (:588-602).

Key properties:

  • Siblings, not children, of the outer flow. A merged branch points at the parent's parent_branch, so the join's continuation re-joins the flow that entered the fork.
  • Terminal escape hatch. A branch may end at an END node instead of reaching the join; the join still activates because the check is "no active branch remains in this scope", not "every branch arrived here".
  • Nesting. An inner fork/join pair inside one branch of an outer fork runs with its own fork_scope; when it joins, the merged branch restores the outer fork_scope (property 4).
  • Join detection is by UUID, never by node identity, so two different fork scopes that happen to meet at the same join cannot cross-activate each other.

The validator enforces the matching statically: fork.matched_join must reference a join of the same template (S9/S11), a join must be matched by exactly one fork (S11), scopes must nest or be disjoint (S12), and every branch must be able to reach its join or an END (S13, S14). The designer auto-resolves matched_join from the drawn graph with _resolve_matched_joins (views/worflow_design.py:339-362).

Document State Synchronisation

workflowstate is a single CharField. It is not the source of truth — WorkflowBranch rows are. sync_document_state (state_workflow.py:700-744) resolves which code to write:

  1. Walk active branches in started_at, id order and pick the first whose current_state is a state node with a non-empty state_code — the oldest active business state.
  2. If none, fall back to the first active branch's current_state (a join or fork node).
  3. A code is only emitted when the primary is a state node; an active join leaves the last value in place. final_code (the last business state before END, or '') is used when no active branch carries a state code.
  4. Updates WorkflowInstance.current_step to the primary when it changed.
  5. Calls _queue_document_state(instance, code), which swallows and logs queueing errors (:747-755).

The write itself is apply_document_state (tasks/workflow_tasks.py:648-713):

  • Looks up the instance, resolves workflow_instance.document, and skips when the document has no workflowstate field (:685-691).
  • Writes document.workflowstate = state_code or '' and saves only that field (:693-695).
  • On failure retries up to three times with exponential backoff (60 * 2**retry seconds) via apply_document_state.using(run_after=...).enqueue(...) (:696-711).

queue_document_state (:716-753) chooses the execution mode:

Setting Behaviour
WORKFLOW_STATE_ASYNC unset/False (default) Runs apply_document_state.call(...) inline in the request thread.
WORKFLOW_STATE_ASYNC = True Enqueues apply_document_state to the database backend on commit. Requires python manage.py db_worker to be running.

Because the async path defers to transaction.on_commit, the document write happens after the branch transaction commits; the inline path runs inside it.

History and Notifications

State-based runtime actions are recorded as WorkflowTransaction rows through the _history helper (state_workflow.py:798-807). actor is nulled when the actor has no PK.

Emitted action type Where Notes
CREATION workflow_submission.py:112 Before the initial state exists.
ASSIGNMENT start_state_workflow, _move_branch_to_state, _fire_fork, _arrive_at_join "Workflow started in state", "State X assigned to", fork spawn list, "Join activated".
APPROVAL _record_state_vote, threshold-progress check One row per state vote; includes target, count, and required approvals.
STATUS_CHANGE transition_branch, _warn_if_no_assignees, _arrive_at_join, cancel_state_workflow Moves, unassigned warnings, join-completion and auto-continue.
COMPLETION _finalize_if_done:776 Instance done, no active branches.
NOTIFICATION tasks/workflow_tasks.py:151 Added by the notification task after sending.

Metadata keys you can rely on (used by templates/tests): state_code, from_state, to_state, to_state_type, branch_id, fork_scope, fork_node, join_node, targets, comment, target_state_id, approved_count, required_approvals, threshold, warning='no_effective_assignees'.

Approver Actions. When a state is entered, _prepopulate_assignee_actions creates one pending WorkflowApproverAction per effective assignee. A transition vote sets the actor's row approved with its target_state; when the target threshold is met, _close_assignee_actions keeps the acting row and sets every other pending row is_available=False. Until then, pending assignees can keep voting.

Notifications. Queued through _notify → queue_notification → send_workflow_notification. State assignment notifications send to all effective assignees (group members expanded), use the state's state_assignment_template_code when set, fall back to the workflow-level state_assignment template, and optionally send a second originator notification. If an in-flight instance is pinned to an older version with no template, the runtime falls back to the active version's template for the same state code. The side-effect key includes the step instance so re-entering the same state sends a fresh notification.

notification_type Caller Template present
state_assignment _notify_state_assignees Yes; per-state code overrides the workflow mapping.
state_completion _finalize_if_done Yes.
join_ready no current caller Yes, unused by the engine.

Designer Load/Save Pipeline

The designer is a JSON graph of nodes and connections. WorkflowDesignerView renders the page (views/worflow_design.py:711); the graph is fetched from WorkflowDesignerLoadView (:442) and written by WorkflowDesignerSaveView (:536).

Load — views/worflow_design.py:470-493

For workflow_kind == 'state_based' the load view:

  1. Loads steps (select_related assignees) and transitions ordered by order, id.
  2. Uses template.design_data only when _design_data_matches_steps agrees the snapshot still matches the DB; otherwise it rebuilds the layout with _statetemplates_to_graph (:237-278).
  3. Enriches the payload with workflow_kind, state_choices (from template_state_choices), state_choices_path (from explicit_state_choices_path), version, is_latest, is_active, template_id and cloned_from_version (:484-492).

_design_data_matches_steps (:281-316) returns True only when the node count equals the step count, the connection count equals the transition count, the multiset of node types equals the multiset of step_types, and the set of state codes matches. Any drift — a node added through the admin, a changed state_code, a dropped transition — invalidates the snapshot and the DB graph wins. This is why node placement can appear to "reset": a layout is only remembered while the graph is unchanged.

_statetemplates_to_graph produces the fallback vertical column layout: x=360 for every node, y=60 + idx*170 in order order, with id='step-<n>' and zIndex=idx+1 (:242-265). Only state nodes get stateCode, threshold and state notification settings; condition nodes get conditionExpressionId; states and joins get assigneeUserIds/assigneeGroupIds. Condition edges are emitted as true/false connections.

Save — views/worflow_design.py:536-567

WorkflowDesignerSaveView.post parses the body, requires at least one node, then clones the template first (clone_workflow_template), so every save is a new version. State-based saves go to _save_state_workflow_template(new_template, nodes, connections) (:365-439):

  1. Delete all StateTransition rows of the new template.
  2. Null the next_step/if_true/if_false/matched_join FKs, then delete the cloned StepTemplate rows.
  3. Rebuild steps from nodes (_graph_to_steptemplates), assign order = index + 1, and save.
  4. Set assignees for state and join nodes from assigneeUserIds/assigneeGroupIds (joins are saved too but the validator later rejects join assignees). State nodes also persist approvalThreshold, stateAssignmentTemplateCode, and notifyOriginatorOnAssignment.
  5. Create one StateTransition per unique (source, target) pair with the connection label (max 150 chars), order = connection index, and condition_result for condition-node edges.
  6. _resolve_matched_joins walks each fork's branches to the reachable joins and assigns matched_join when exactly one join is common to all branches that reach a join (:339-362). Branches that end at an END node instead are excluded from the match.
  7. Persist design_data = {'nodes': ..., 'connections': ...} on the cloned template.
  8. Run validate_template(new_template) and return the errors in the JSON response without blocking the save:
json
{
  "success": true,
  "message": "Workflow saved successfully",
  "step_count": 14,
  "template_id": "...",
  "version": 2,
  "errors": ["S16: 'Closed' has no assignees. ..."]
}

The saved version is inactive; activation is a separate admin action (see Versioning). Because the save returns errors but still succeeds, an invalid graph can be persisted — it simply cannot be activated.

Versioning

clone_workflow_template (services/workflow_versioning.py:11-113) copies a template into a new version inside one transaction:

  • Computes max(version) + 1 for the workflowname, clears is_latest on previous versions and creates the clone with is_active=False, is_latest=True, cloned_from=source (:13-38).
  • Copies workflow_kind, state_choices_path, design_data, module, entity and description (:28-33).
  • Recreates every StepTemplate including state_code, thresholds, state notification settings, assignees and the other notification/condition fields; then remaps next_step, if_true, if_false and matched_join to the new step PKs.
  • Recreates every StateTransition against the remapped steps, preserving label, order, condition_expression, condition_mode, condition_result, and comment_required.

Runtime pinning: WorkflowInstance.template is a PROTECT FK, and submit_workflow resolves the active template at submit time (workflow_submission.py:74-77). An in-flight instance therefore keeps using the version it started with, including that version's StepTemplate.assignee_users and the StateTransition graph.

Activation (WorkflowTemplateActivateView, enezaadminstrator/views/workflowtemplates.py:394-433) runs validate_template(template), which dispatches on workflow_kind (workflow_template_validator.py:360-371), deactivates the other versions of the same workflowname, and sets is_active=True.

Runtime UI Wiring

The runtime UI deliberately mixes HTMX, a plain-DOM modal and plain fetch.

  1. Menu item. For a state-based instance, WorkflowMixin.get_workflow_menu_items appends View History and, when workflow_instance.state_status == 'active', one Move to State item per transitionable branch (enezaframework/views/mixins.py:1342-1381). The label is Move to -> <transition_display_name(...)> when the branch has exactly one allowed transition, otherwise Move to -> .... The item carries 'modal': True and hx_vals={"list_url": "<app>:<list_url_name>"}.
  2. Modal markup. components/layouts/default.html:268-290 defines #statetransitionmodal (initially hidden) with an inner #statetransitionmodal-body. navactions.html:68-74 renders an action with modal: True as an hx-get targeting #statetransitionmodal-body that calls window.enezaStateTransitionModal.open().
  3. Plain-DOM control. window.enezaStateTransitionModal is defined at default.html:166-183; open() removes hidden, close() re-adds it and empties the body. An htmx:afterSwap listener opens the modal when the swapped element is #statetransitionmodal-body (default.html:185-192). It is intentionally Alpine-free.
  4. Picker. StateWorkflowTransitionPickerView (views/workflow_api.py:973-1018) renders workflow_instance/partials/state_transition_form.html with transition_options built from transition_display_name; the form posts to workflow_state_transition.
  5. Form interception. workflow_actions.js (static/eneza_workflow_engine/js/workflow_actions.js) listens for submit, matches URLs containing /transition/ (among others), posts via fetch with X-Requested-With: XMLHttpRequest, closes the state modal and shows the result through the Alpine workflowActionModal (#createmodal).

StateWorkflowTransitionView.post (views/workflow_api.py:1021-1112) responds in three shapes: JSON for X-Requested-With: XMLHttpRequest, an empty 200 with an HX-Trigger: workflowResult payload for HTMX, or the eneza_workflow_engine/workflow_result.html partial otherwise. Errors are WorkflowTransitionError subclasses, so the HTTP status comes from the exception's http_status.

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.