# Order state transitions follow valid sequence

- **Pattern:** `ab-001134` (`ecommerce-order-management.order-lifecycle.state-transitions`)
- **Severity:** critical
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-001134
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-001134)

## Why it matters

Without application-layer transition guards, any code path — including a customer-facing API route — can write an arbitrary status value to any order. An attacker who discovers the order update endpoint can skip 'confirmed' and 'shipped' to mark their own order 'delivered', triggering fulfillment or refund logic without a real purchase completing the intervening steps. Even without malice, a race condition or a bug in an admin tool can jump an order from 'pending' to 'delivered' in one call, corrupting history and triggering incorrect emails. A database enum constraint alone (CWE-841) does not prevent logically invalid sequences — application-layer guards must enforce the allowed graph.

## Severity rationale

Critical because unguarded transitions allow both malicious status manipulation by authenticated users and accidental logical corruption that invalidates order history and triggers incorrect downstream actions.

## Remediation

Implement an explicit transition map in `lib/orders/state-machine.ts` and route every status update through it. All direct `db.orders.update()` calls that change status must be replaced with `transitionOrder()`.

```ts
// lib/orders/state-machine.ts
const ALLOWED_TRANSITIONS: Record<string, string[]> = {
  pending:   ['confirmed', 'cancelled'],
  confirmed: ['shipped', 'cancelled'],
  shipped:   ['delivered'],
  delivered: [],
  cancelled: [],
}

export async function transitionOrder(orderId: string, newStatus: string, actorId: string) {
  const order = await db.orders.findUniqueOrThrow({ where: { id: orderId } })
  const allowed = ALLOWED_TRANSITIONS[order.status] ?? []
  if (!allowed.includes(newStatus)) {
    throw new Error(`Invalid transition: ${order.status} → ${newStatus}`)
  }
  return db.orders.update({ where: { id: orderId }, data: { status: newStatus } })
}
```

## Detection

- **ID:** `state-transitions`
- **Severity:** `critical`
- **What to look for:** Count every code path that updates an order's status field. For each path found, enumerate what transition it performs and whether a guard clause validates the current status before allowing the update. A valid state machine for orders typically follows: `pending -> confirmed -> shipped -> delivered`, with `cancelled` reachable from `pending` and `confirmed` (and sometimes `shipped` with special handling). Look for guard clauses such as `if (order.status !== 'confirmed') throw new Error(...)`, a dedicated `transitionOrder` function that maps at least 3 allowed transitions, or a state machine library (xstate, robot, etc.). Also check for negative cases — whether status can be set to arbitrary values through an update endpoint. Quote the exact guard clause or transition map if one exists.
- **Pass criteria:** A transition validator, guard clause, or state machine definition explicitly restricts which transitions are allowed, covering at least 3 distinct transitions. Invalid transitions are rejected at the application layer (not just at the DB schema layer). The allowed transitions match a sensible business flow — an order cannot go from `pending` to `delivered` without passing through `confirmed` and `shipped`. A database enum constraint alone does not count as pass — application-layer validation must exist. Report the count of validated transitions even on pass: "Found N transitions with guards."
- **Fail criteria:** No validation exists on state transitions — any status value can be written to any order at any time. Or the validation is present but incomplete (covering fewer than 3 transitions), allowing logically invalid jumps (e.g., `pending` -> `delivered`, `shipped` -> `pending`, `cancelled` -> `confirmed`).
- **Skip (N/A) when:** The project has no order status update logic because it delegates all order state management to an external platform (Shopify, Medusa, etc.) with its own state machine. The codebase contains 0 status-update handlers.
- **Detail on fail:** Describe which invalid transitions are permitted. Example: `"No transition validation found. The PATCH /api/orders/[id] endpoint accepts any status value. An order can be set from 'pending' to 'delivered' in a single call with no intermediate states required. 0 of 4 status-update paths have guards."`
- **Cross-reference:** If transitions are unguarded, the `history-log` check is also likely failing — unguarded writes rarely include history entries. Also check whether `cancellation-history` is affected.
- **Remediation:** Implement an explicit transition map in `lib/orders/state-machine.ts` and validate every status update against it:

  ```ts
  // lib/orders/state-machine.ts
  const ALLOWED_TRANSITIONS: Record<string, string[]> = {
    pending:   ['confirmed', 'cancelled'],
    confirmed: ['shipped', 'cancelled'],
    shipped:   ['delivered'],
    delivered: [],
    cancelled: [],
  }

  export async function transitionOrder(
    orderId: string,
    newStatus: string,
    actorId: string
  ) {
    const order = await db.orders.findUniqueOrThrow({ where: { id: orderId } })
    const allowed = ALLOWED_TRANSITIONS[order.status] ?? []

    if (!allowed.includes(newStatus)) {
      throw new Error(
        `Invalid transition: ${order.status} → ${newStatus}. ` +
        `Allowed: ${allowed.join(', ') || 'none'}`
      )
    }

    return db.orders.update({
      where: { id: orderId },
      data: { status: newStatus, updatedAt: new Date() },
    })
  }
  ```

  All status updates throughout the codebase should go through `transitionOrder()` rather than calling `db.orders.update()` directly.

## External references

- cwe CWE-841
- iso-25010 functional-correctness

Taxons: data-integrity

HTML version: https://auditbuffet.com/patterns/ab-001134
