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
- Component Map
- Data Model
- Runtime Lifecycle
- Branches, Forks and Joins
- Document State Synchronisation
- History and Notifications
- Designer Load/Save Pipeline
- Versioning
- Runtime UI Wiring
- See Also
- 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 --> DOCThe 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=Trueinstance (:58-67). - Resolves the active template when none is passed (
:69-82). - For
workflow_kind == 'state_based'requiresdocument.workflowstate(:84-90). - Creates
WorkflowInstancewithstate_status=StateLifecycle.ACTIVE(:98-109). - Writes a
CREATIONtransaction, then delegates tostart_state_workflowand 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 (withget_initial_statekept as an alias).- Raises
StateWorkflowErrorwhen START is missing or points nowhere, or when a state/condition initial target lacks assignees/query. - Creates the first
WorkflowBranch. A state target gets itsWorkflowStepInstance, pending approver actions,ASSIGNMENThistory 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:
- Re-reads the instance and the branch with
SELECT FOR UPDATEvia_for_update— skipped on backends withouthas_select_for_update(SQLite) (:45-49,:265-271). - Rejects a non-active instance with
WorkflowAlreadyCompletedError(:273-274). - Verifies the branch belongs to the instance and is still active (
:276-280). - Verifies the
(template, source_state, target_state)StateTransitionexists; otherwise raisesStateWorkflowError(:286-294). - Re-checks
user_can_transition(source_state, user, document)inside the lock. - Records the user's
WorkflowApproverActionvote with itstarget_state, plusAPPROVALhistory and a comment. - If the target has fewer than
state_required_approvalsvotes, records threshold-progress history and returns without moving. Otherwise completes the current step instance, closes pending votes, writesSTATUS_CHANGEwith threshold metadata, dispatches the target, callssync_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_workflowis a service API only. It is not wired to a URL, the action menu orWorkflowMixin; 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
join_node = fork_node.matched_join(may beNone).- Requires at least two outgoing transitions; otherwise raises
StateWorkflowError(:472-475). - Generates a fresh
scope = uuid.uuid4(). - Completes the parent branch and points it at the fork node (
:479-482). - For each outgoing transition creates a child
WorkflowBranchcarryingparent_branch,fork_scope,fork_nodeandjoin_node(:485-495). - A child targeting a
stategets a step instance, an unassigned warning and a notification; a child targeting a nestedforkrecurses through_fire_fork(:497-502). - Writes an
ASSIGNMENTtransaction listing the spawned states with their assignees and metadatafork_scope,fork_node,join_node,targets(:504-523).
Arriving at a join — state_workflow.py:526-602
- Records
scope = branch.fork_scopeandparent = branch.parent_branch. - Completes the arriving branch, pointing it at the join node (
:530-533). - Counts remaining active branches with the same
fork_scope. If any exist the join is not yet ready and the call returnsNone(:535-554). - When the scope is clear, creates a merged
WorkflowBranchthat 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). - 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). - Otherwise it writes the "continued automatically" transaction and calls
_dispatch_targetfor 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 outerfork_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:
- Walk active branches in
started_at, idorder and pick the first whosecurrent_stateis astatenode with a non-emptystate_code— the oldest active business state. - If none, fall back to the first active branch's
current_state(a join or fork node). - A code is only emitted when the primary is a
statenode; 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. - Updates
WorkflowInstance.current_stepto the primary when it changed. - 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 noworkflowstatefield (: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**retryseconds) viaapply_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:
- Loads steps (
select_relatedassignees) and transitions ordered byorder, id. - Uses
template.design_dataonly when_design_data_matches_stepsagrees the snapshot still matches the DB; otherwise it rebuilds the layout with_statetemplates_to_graph(:237-278). - Enriches the payload with
workflow_kind,state_choices(fromtemplate_state_choices),state_choices_path(fromexplicit_state_choices_path),version,is_latest,is_active,template_idandcloned_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):
- Delete all
StateTransitionrows of the new template. - Null the
next_step/if_true/if_false/matched_joinFKs, then delete the clonedStepTemplaterows. - Rebuild steps from
nodes(_graph_to_steptemplates), assignorder = index + 1, and save. - Set assignees for
stateandjoinnodes fromassigneeUserIds/assigneeGroupIds(joins are saved too but the validator later rejects join assignees). State nodes also persistapprovalThreshold,stateAssignmentTemplateCode, andnotifyOriginatorOnAssignment. - Create one
StateTransitionper unique(source, target)pair with the connection label (max 150 chars),order = connection index, andcondition_resultfor condition-node edges. _resolve_matched_joinswalks each fork's branches to the reachable joins and assignsmatched_joinwhen 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.- Persist
design_data = {'nodes': ..., 'connections': ...}on the cloned template. - Run
validate_template(new_template)and return the errors in the JSON response without blocking the save:
{
"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) + 1for theworkflowname, clearsis_lateston previous versions and creates the clone withis_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
StepTemplateincludingstate_code, thresholds, state notification settings, assignees and the other notification/condition fields; then remapsnext_step,if_true,if_falseandmatched_jointo the new step PKs. - Recreates every
StateTransitionagainst the remapped steps, preservinglabel,order,condition_expression,condition_mode,condition_result, andcomment_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.
- Menu item. For a state-based instance,
WorkflowMixin.get_workflow_menu_itemsappends View History and, whenworkflow_instance.state_status == 'active', one Move to State item per transitionable branch (enezaframework/views/mixins.py:1342-1381). The label isMove to -> <transition_display_name(...)>when the branch has exactly one allowed transition, otherwiseMove to -> .... The item carries'modal': Trueandhx_vals={"list_url": "<app>:<list_url_name>"}. - Modal markup.
components/layouts/default.html:268-290defines#statetransitionmodal(initiallyhidden) with an inner#statetransitionmodal-body.navactions.html:68-74renders an action withmodal: Trueas anhx-gettargeting#statetransitionmodal-bodythat callswindow.enezaStateTransitionModal.open(). - Plain-DOM control.
window.enezaStateTransitionModalis defined atdefault.html:166-183;open()removeshidden,close()re-adds it and empties the body. Anhtmx:afterSwaplistener opens the modal when the swapped element is#statetransitionmodal-body(default.html:185-192). It is intentionally Alpine-free. - Picker.
StateWorkflowTransitionPickerView(views/workflow_api.py:973-1018) rendersworkflow_instance/partials/state_transition_form.htmlwithtransition_optionsbuilt fromtransition_display_name; the form posts toworkflow_state_transition. - Form interception.
workflow_actions.js(static/eneza_workflow_engine/js/workflow_actions.js) listens forsubmit, matches URLs containing/transition/(among others), posts viafetchwithX-Requested-With: XMLHttpRequest, closes the state modal and shows the result through the AlpineworkflowActionModal(#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
- State-Based Workflows — overview and quick start.
- State-Based Workflow Reference — APIs, rules, schemas and commands.
- State-Based Workflow Troubleshooting — diagnosing stuck or invisible transitions.
- 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.