nautechsystems/nautilus_trader · error · anyhow::Error

Invalid execution transition for intent {intent_id}: {curren

Error message

Invalid execution transition for intent {intent_id}: {current_status} -> {}

What it means

Thrown when execution_transition_allowed rejects the requested transition in record_execution_status. The state machine permits prepared to signed/recoverable; signed or broadcast to broadcast/included/replaced/dropped/reorged; included to finalized/reverted/reorged/replaced/dropped; replaced/dropped/reorged to included/finalized/reverted/replaced/dropped/reorged; and same-status repeats (idempotent). finalized, reverted, and recoverable are terminal, so this fires only for genuinely out-of-order or post-terminal observations.

Source

Thrown at crates/adapters/blockchain/src/cache/database.rs:3625

                "Execution gas used {} exceeds PostgreSQL BIGINT",
                gas_used.unwrap_or_default()
            )
        })?;
        let mut transaction = self
            .pool
            .begin()
            .await
            .map_err(|e| anyhow::anyhow!("Failed to start execution status transition: {e}"))?;
        let (current_status, fill_emitted, terminal_emitted) =
            sqlx::query_as::<_, (String, bool, bool)>(
            "SELECT status, fill_emitted, terminal_emitted FROM execution_intent WHERE id = $1 FOR UPDATE",
        )
        .bind(intent_id)
        .fetch_optional(&mut *transaction)
        .await
        .map_err(|e| anyhow::anyhow!("Failed to lock execution intent {intent_id}: {e}"))?
        .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} was not found"))?;
        anyhow::ensure!(
            execution_transition_allowed(&current_status, status),
            "Invalid execution transition for intent {intent_id}: {current_status} -> {}",
            status.as_str()
        );

        let active = match status {
            TransactionStatus::Finalized | TransactionStatus::Reverted => {
                !fill_emitted && !terminal_emitted
            }
            TransactionStatus::Recoverable => false,
            _ => true,
        };
        let hash_result = sqlx::query(
            "
            UPDATE execution_transaction_hash
            SET status = $3,
                block_number = COALESCE($4, block_number),
                block_hash = COALESCE($5, block_hash),

View on GitHub (pinned to 2114cf6f76)

Solutions

  1. Check whether the observation is stale: same-status repeats are already tolerated, so this error means the event is out of order or the intent is terminal
  2. Drop delayed observations whose target is no longer reachable from the current status
  3. Order observations by block_number before recording them
  4. If a real reorg invalidated a finalized execution, escalate and handle it as a new intent rather than forcing the transition

Example fix

// before: recording whatever arrives first
db.record_execution_status(intent_id, &tx_hash, status, ...).await?;

// after: skip observations the state machine cannot accept
let current = load_intent_status(&pool, intent_id).await?;
if current == status.as_str() {
    return Ok(()); // idempotent repeat
}
if !transition_allowed(&current, status) {
    tracing::warn!(%intent_id, %current, ?status, "dropping out-of-order observation");
    return Ok(());
}
db.record_execution_status(intent_id, &tx_hash, status, ...).await?;
Defensive patterns

Strategy: validation

Validate before calling

// Mirror the persisted state machine before recording
let current: String = sqlx::query_scalar("SELECT status FROM execution_intent WHERE id = $1")
    .bind(intent_id)
    .fetch_one(&pool)
    .await?;
if current != status.as_str() && !transition_allowed(&current, status) {
    // stale or out-of-order observation: drop it
}

Type guard

fn transition_allowed(current: &str, next: TransactionStatus) -> bool {
    if current == next.as_str() {
        return true;
    }
    match current {
        "prepared" => matches!(next, TransactionStatus::Signed | TransactionStatus::Recoverable),
        "signed" | "broadcast" => matches!(
            next,
            TransactionStatus::Broadcast
                | TransactionStatus::Included
                | TransactionStatus::Replaced
                | TransactionStatus::Dropped
                | TransactionStatus::Reorged
        ),
        "included" => matches!(
            next,
            TransactionStatus::Finalized
                | TransactionStatus::Reverted
                | TransactionStatus::Reorged
                | TransactionStatus::Replaced
                | TransactionStatus::Dropped
        ),
        "replaced" | "dropped" | "reorged" => !matches!(next, TransactionStatus::Prepared | TransactionStatus::Signed),
        _ => false,
    }
}

Try / catch

match db.record_execution_status(intent_id, &tx_hash, status, block, hash, success, gas, price).await {
    Err(e) if e.to_string().contains("Invalid execution transition") => {
        // log current->target, classify as stale/terminal, and skip or escalate - never force
    }
    other => other?,
}

Prevention

When it happens

Trigger: A late 'included' receipt arriving after the intent already finalized; attempting to move a finalized intent during a reorg; recording broadcast on an intent still 'prepared' (it was never signed); replaying stale observations after crash recovery.

Common situations: RPC or websocket events delivered out of order; restarts replaying old observations; chain reorganizations crossing the finalize boundary; duplicate events with differing block data.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of nautechsystems/nautilus_trader@2114cf6f76 (2026-08-21). Data as JSON: /api/errors/7f1279940c245a30. Report an issue: GitHub.