Skip to main content

States and Transitions

Source of truth: src/qtg/domain/states.py State derivation: src/qtg/application/services/advance.py
QTG v3 uses a two-layer state model: RequestState (the full request lifecycle) and NodeState (the progress state of each execution node). The request state is derived from the states of its nodes.

Table of Contents

  1. RequestState Enum
  2. NodeState Enum
  3. REQUEST_TRANSITIONS - request-state transition graph
  4. NODE_TRANSITIONS - node-state transition graph
  5. State transition diagrams
  6. Terminal vs Recoverable states
  7. State Derivation — derive_request_state()
  8. apply_request_state_from_nodes() - side effects
  9. Effect of the has_side_effect flag
  10. Frontier advancement - advance_after_completion()

RequestState Enum

RequestState tracks the full lifecycle of a movement request. It is based on enum.StrEnum and stored in the DB column movement_requests.request_state.

NodeState Enum

NodeState is the execution progress state of an individual node in the plan graph. It is stored in the DB column movement_request_nodes.node_state.

REQUEST_TRANSITIONS

REQUEST_TRANSITIONS is the allowed transition graph of type Mapping[RequestState, set[RequestState]]. The can_transition_request_state() function checks this dictionary to decide whether a transition is allowed.

Main transition paths


NODE_TRANSITIONS

Main transition paths


State transition diagrams

RequestState transition diagram

NodeState transition diagram

NodeState linear path (happy path)


Terminal vs Recoverable states

Terminal states (no further transitions)

Terminal RequestState values: COMPLETED, FAILED, REJECTED, EXPIRED, CANCELLED
  • They have an empty set() in REQUEST_TRANSITIONS.
  • When a terminal state is reached, reservation handling (consumed/released) and callback delivery run.
Terminal NodeState values: COMPLETED, FAILED, CANCELLED, SKIPPED
  • These states have no transition targets.

Recoverable states

NodeState.UNKNOWN is the only recoverable node state.
  • SUBMITTING -> UNKNOWN: submit result is unclear because of a network timeout, etc.
  • OBSERVING -> UNKNOWN: RPC failure during observe
UNKNOWN nodes are handled by the recover_unknown_nodes() worker:
  • It calls executor.recover(context) to check the result.
  • It transitions to SUBMITTED (recovery succeeded) or FAILED (deterministic failure), depending on the result.
RequestState.MANUAL_INTERVENTION is a recoverable request-level state:
  • The operator can resume it to EXECUTING, or force it to FAILED/COMPLETED.
RequestState.WAITING_MANUAL_ACTION is the waiting state for a manual gate:
  • It returns to EXECUTING after the manual gate is resolved.

State Derivation

derive_request_state() checks the states of all nodes in a request and derives the request state. The system does not set RequestState directly from outside. It decides it from the set of node states.

derive_request_state() logic

Decision order (highest priority first)

Step 1: all-complete check
If all nodes are COMPLETED or SKIPPED, it returns COMPLETED immediately. Step 2: classify failed/unknown nodes
Step 3: decide MANUAL_INTERVENTION vs FAILED
Core rules:
  • If there is a failed/unknown node and there is also a completed side-effect node -> MANUAL_INTERVENTION
  • If there is a failed node and there is no completed side-effect node -> FAILED
  • If there are only unknown nodes (no failed nodes), and there is no completed side-effect node -> None (keep the current state)
Step 4: check manual-gate wait
  • If there is a READY node whose name starts with the manual_ prefix,
  • and there are no currently active running nodes,
  • then -> WAITING_MANUAL_ACTION
Step 5: no match

Summary table of derived results


apply_request_state_from_nodes()

This function writes the result of derive_request_state() to the DB and handles side effects (callback, reservation).

Side effects by state

The release condition for a reservation is decided by should_release_reservation():
  • REJECTED, EXPIRED: always release
  • CANCELLED, FAILED: release only when there is no completed side-effect node

Effect of the has_side_effect flag

has_side_effect is defined on MovementPlanNode.has_side_effect and copied to MovementRequestNode.has_side_effect.

Meaning

  • True: the node’s executor can change external state (withdrawal submit, on-chain tx send, etc.)
  • False: read-only or observe-only (deposit observe, finality check, etc.)

Role in state derivation

If a node with a side effect is already COMPLETED and another node fails:
  • the funds may already have moved, so the system cannot treat it as a simple FAILED;
  • it transitions to MANUAL_INTERVENTION and requires operator judgment.
Example: 3-node CEX lane
  • withdrawal_action completed, deposit_observe failed -> MANUAL_INTERVENTION
    • the withdrawal completed, but deposit confirmation failed - manual confirmation is required
  • withdrawal_action failed -> FAILED
    • the withdrawal itself failed - the system can fail safely

Role in reservation release

If there is a completed side-effect node, the system does not release the reservation even on FAILED/CANCELLED. The funds may already be in transit, so the hardcap quota must stay reserved.

Frontier Advancement

advance_after_completion() changes successor nodes to READY after a node completes and updates the frontier.

Frontier advancement order

  1. From the completed node’s plan_node, look up successors along on_success edges.
  2. Change successor nodes that are in BLOCKED state to READY.
  3. Update request.current_frontier with the list of successor keys.
  4. Send the frontier_advanced callback.
  5. If there is no successor (end of the graph), call apply_request_state_from_nodes() to decide request termination.

State transition validation functions

State transitions can be checked for validity through the validation functions:
The actual state transitions are performed by set_node_state() and set_request_state(). Each transition creates a MovementEvent audit record.

Cross-References