> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Troubleshooting: campaign and conference state-machine transition errors

> Decode the five state-machine refusal codes — CAMPAIGN_NOT_DRAFT, CAMPAIGN_NOT_PENDING_APPROVAL, CONFERENCE_STATE_UNRESOLVED, INVALID_STATE_TRANSITION, and INVARIANT_VIOLATION — across campaign launch, approval, and supervisor-conference take-over flows. Map each code to the exact transition it gates, run the legality table backwards from the failure, and never mutate outside a legal transition.

# Troubleshooting: campaign and conference state-machine transition errors

A state-machine violation is a 409 that refused your write because the
object you are updating has moved on. The guard did not lose the race —
your read did. Read the current status with a GET first, and you will see
the transition the code names, or the one it replaced. All five codes on
this page close on the same recovery step; never mutate outside the legal
transition, never loop the retry.

The five surfaces grouped here:

| Surface | Code | Where it fires |
| - | - | - |
| `POST /campaigns/:id/approvals` — launch a draft for supervisor sign-off | `CAMPAIGN_NOT_DRAFT` | Draft no longer `draft` or `scheduled` |
| `POST /campaigns/:id/approvals/:approvalId/approve` and `/reject` | `CAMPAIGN_NOT_PENDING_APPROVAL` | Campaign no longer `pending_approval` |
| `POST /voice/conferences/:id/lock` | `CONFERENCE_STATE_UNRESOLVED` | No connected participants in the room |
| Any take-over, hold, or lifecycle write that races the state machine | `INVALID_STATE_TRANSITION` | Generic gate on the route's atomic status flip |
| Any supervisor action whose leg resolves to a retired provider branch | `INVARIANT_VIOLATION` | Fail-closed sentinel on the softswitch routing contract |

## Campaign states — the legal-transition map

A marketing campaign moves through exactly this progression (mirror of the
[campaign lifecycle concept page](/concepts/campaign-lifecycle)):

```
draft
  → submitted            (your POST, gated by approval when your trust tier requires it)
  → pending_review       (a supervisor is picking up the approval row)
  → approved             (the supervisor signed off; dispatch begins)
```

`approved` is the terminal state for this gate; the campaign then follows
the wider `draft → scheduled → sending → running → completed` arc that the
concept page owns. The two codes below gate *only* the first three hops —
they are the door on a write that assumes the campaign still sits in the
state your UI last observed.

### Per-transition legality table

Each row names the only transition the write is trying to perform, the
only starting states that accept it, and the code you get when the
precondition no longer holds:

| Write | Legal starting states | Blocked when | Code |
| - | - | - | - |
| Submit for approval | `draft`, `scheduled` | Any other live or terminal state | `409 CAMPAIGN_NOT_DRAFT` |
| Approve the pending request | `pending_approval` | The campaign moved on (dispatched, edited to `draft`, or cancelled) | `409 CAMPAIGN_NOT_PENDING_APPROVAL` |
| Reject the pending request | `pending_approval` | Same — the campaign moved on | `409 CAMPAIGN_NOT_PENDING_APPROVAL` |
| Lock a conference room | Any state with ≥1 connected participant | Zero connected participants (legs still dialing, or all settled) | `409 CONFERENCE_STATE_UNRESOLVED` |
| Any other atomic state flip guarded by a status whitelist | Whichever the route declares | The precondition read raced your write | `409 INVALID_STATE_TRANSITION` |

The legality gate runs as an atomic flip: the write reads the current
status, decides it is in the allowed list, and flips — all in one
transaction. A 409 from this gate means the row's status moved between
your earlier GET and this write, or your GET never happened and your UI
is carrying a stale state map.

## CAMPAIGN\_NOT\_DRAFT — submit-for-approval gate

`POST /campaigns/:id/approvals` opens the supervisor queue on a campaign
your trust tier requires sign-off for. The only legal starting states are
`draft` and `scheduled` — everything else already holds a live
disposition the approval flow cannot reset.

```json theme={null}
{
  "error": {
    "code": "CAMPAIGN_NOT_DRAFT",
    "message": "Cannot submit campaign for approval — status is no longer 'draft' or 'scheduled'.",
    "details": {}
  }
}
```

Recovery — in this order, never skip the read:

1. **GET the campaign first.** Read `status` back from
   `GET /campaigns/:id` before you react. Whatever it says now is the
   legal truth.
2. **Match on the status.** If it moved to `pending_approval` since you
   queued the submit, another writer beat you. If it moved to `sending` or
   `running`, a supervisor approved it while your request was in flight.
   If it moved to `cancelled` or `failed`, the approval path is closed.
3. **Re-route.** When the current state is one of the legal starts, retry
   the same POST once. When it is not, close the loop — the duplicate
   attempt is the bug to fix, not the gate.

### What NOT to try

* Never PATCH the campaign back to `draft` to force the gate open — an
  approved campaign in `sending`/`running` rejects the rewrite with its
  own gate, and an approval that already landed is not retractable via
  the status field.
* Never loop the same POST — the 409 is deterministic. Each retry
  appends a fresh rejection to your audit trail without changing the
  campaign's actual state.

## CAMPAIGN\_NOT\_PENDING\_APPROVAL — approve / reject race

`POST /campaigns/:id/approvals/:approvalId/approve` (or `/reject`) closes
the supervisor decision. Both flip the campaign out of
`pending_approval`; both refuse with the same code when the campaign has
already moved.

```json theme={null}
{
  "error": {
    "code": "CAMPAIGN_NOT_PENDING_APPROVAL",
    "message": "Cannot approve — campaign status is no longer 'pending_approval'.",
    "details": {}
  }
}
```

Recovery:

1. **The row already moved.** Either a second supervisor decided first
   (their `decided_by_user_id` is on the approval row), or the integration
   you are holding took a path the gate does not accept (an operator
   edited the campaign and reverted it to `draft`).
2. **List the pending queue again.** `GET
   /campaigns/approvals/pending?campaign_id=:id` returns the current
   decision surface. If the row is gone, the decision already landed.
3. **Rely on idempotence.** If your goal was "approve this campaign" and
   the list shows it is no longer pending, treat the goal as reached —
   the 409 you just saw is the proof another supervisor closed it first.

### What NOT to try

* Never re-approve against a second approval row for the same campaign —
  the first decision sticks and subsequent approvals are redundant.
* Never interpret the 409 as a transient fault; the precondition read is
  the whole point, and looping widens the supervisor audit log.

## CONFERENCE\_STATE\_UNRESOLVED — the lock-a-room gate

`POST /voice/conferences/:id/lock` refuses when no leg in the room is
currently `connected`. A lock only applies to a live room: legs that are
still `pending`, `dialing`, or `ringing` are named as unresolved, legs in
any terminal state (`failed`, `no_answer`, `busy`, `rejected`,
`disconnected`) are already settled, and an empty room resolves to the
same refusal.

The gate reads the room's participant roster — the same one the
`GET /voice/conferences/:id` response and the `participant_joined` /
`participant_left` webhooks present — and refuses whenever zero
participants are in `connected`.

```json theme={null}
{
  "error": {
    "code": "CONFERENCE_STATE_UNRESOLVED",
    "message": "Conference cannot be locked — no participants are connected and 2 leg(s) are still dialing. Wait for the room to settle (at least one participant connected) before locking.",
    "details": {
      "conferenceId": "conf_abc123",
      "connected": 0,
      "unresolved": 2
    }
  }
}
```

Recovery:

1. **Poll the roster once.** `GET /voice/conferences/:id` returns the
   participant list; read it before reacting. The `unresolved` count in
   `error.details` tells you whether legs are still dialing or already
   settled.
2. **Wait for one `participant_joined`.** The
   `conference.participant_joined` webhook fires the moment a leg enters
   the room; the gate is open from that point on.
3. **Then POST the lock once.** The delegate only needs the first
   connected participant — the rest of the room can still be dialing.

The same gate executes on **unlock**: when you release the lock, the
write proceeds unconditionally; the refusal only ever fires on the
locking direction. The [conference lifecycle runbook](/troubleshooting/conference-failures)
covers the room-level event flow this gate reads, and the
[supervisor take-over runbook](/troubleshooting/supervisor-takeover-failures)
covers the take-over side of the same plane.

### What NOT to try

* Never retry the lock in a tight loop while legs are dialing — poll
  `GET /voice/conferences/:id` on a backoff (the roster converges in
  milliseconds once a leg answers).
* Never treat an `unresolved` count of zero as a green light — that is
  the settled-but-never-connected case the gate closes explicitly.

## INVALID\_STATE\_TRANSITION — the generic 409

Whenever a route guards its write with an atomic status flip — the same
"read the precondition, flip, commit" shape every state-machine surface
uses — the generic `INVALID_STATE_TRANSITION` code covers the
rest of the corpus. The code name is intentionally broader than the
per-surface codes above; read it as "the precondition a specific route
declared did not pass."

```json theme={null}
{
  "error": {
    "code": "INVALID_STATE_TRANSITION",
    "message": "<route-specific message>",
    "details": {}
  }
}
```

Recovery is identical across every surface:

1. **Identify the route.** The `error.message` (and the endpoint your
   request hit) names the transition it tried — match it back to the
   ["From an error code to a runbook"](/reference/troubleshooting-hub)
   table on the hub.
2. **GET the current state first.** Before any retry, re-read the
   object.
3. **Re-issue once, with the correct state.** The route's
   status-whitelist flip rejects stale preconditions deterministically;
   one retry after a fresh read is all that is ever safe.

`INVALID_STATE_TRANSITION` is the catch-all to route to when a take-over,
queue-flow flip, or lifecycle write you control fails with 409 and your
surface is not one of the four named above. The
[Error Code Reference](/reference/error-codes) keeps the per-surface
message strings; this page owns the recovery shape.

## INVARIANT\_VIOLATION — the take-over sentinel

A supervisor action — listen, whisper, barge, unlisten — must never
resolve to the retired provider branch. When the routing layer resolves a
supervisor leg to a path outside the current provider contract, the
take-over refuses with `409 INVARIANT_VIOLATION` and a route-level log
breadcrumb.

```json theme={null}
{
  "error": {
    "code": "INVARIANT_VIOLATION",
    "status": 409,
    "message": "Supervisor actions route exclusively through the platform softswitch. This call's control-plane stamp cannot be honoured by the current routing contract.",
    "details": { "verb": "listen" }
  }
}
```

Recovery:

1. **Read the leg stamp.** `GET /voice/calls/:id` returns the call
   metadata the resolver read; the refuse names the provider branch it
   resolved to in `details`.
2. **Re-stamp or terminate.** A leg stamped with a pre-cutover provider
   value or an explicit override env needs a re-dial; terminate the
   supervisor leg and re-dial against the current softswitch routing
   contract.
3. **Escalate with the breadcrumb.** When the refuse persists past a
   re-dial, the platform-side config your tenant inherited is the cause —
   open a ticket with the request id and the `verb` you attempted.

This code is the one refusal customers on the supervisor plane treat as
fail-closed: the take-over never silently degrades to a retired path, so
the only valid fix is routing the leg through the current softswitch
surface. The [supervisor take-over runbook](/troubleshooting/supervisor-takeover-failures)
carries the per-verb recovery table for the non-409 failure family.

## Decision checklist

Run these in order on any of the five codes:

1. Re-read the object (`GET /campaigns/:id`, `GET /voice/conferences/:id`,
   `GET /voice/calls/:id`).
2. Match the current status against the legality table for the surface
   you called.
3. Re-issue the same write at most once, after the read reflects the new
   precondition.
4. If the precondition still fails, the object is in a disposition the
   write cannot advance — treat the current status as the terminal
   answer, and close the loop from your side.

## When to escalate

* The same `INVALID_STATE_TRANSITION` recurs on multiple surfaces — the
  gate contract your tenant relies on is ambiguous; open a ticket with the
  endpoint and the `error.details` blob.
* `CAMPAIGN_NOT_PENDING_APPROVAL` lands repeatedly because more than one
  supervisor in your tenant holds the queue — the visibility contract in
  the approvals list is what you lean on; ask support whether the
  pending-list shape should include a claim row.
* `CONFERENCE_STATE_UNRESOLVED` lands when the webhook stream shows at
  least one `participant_joined` before your POST — the roster and the
  webhook stream disagree; file the conference id and the request id
  together.
* `INVARIANT_VIOLATION` returns a `verb` you did not configure — the
  legacy provider stamp is in metadata you inherited; the platform team
  needs the legacy record to look at, not just the request id.

## Cross-references

* [Campaign lifecycle concept](/concepts/campaign-lifecycle) owns the
  campaign's full `draft → … → completed` arc; the approval gate this
  page decodes is the front-door subset.
* [ACD queue model](/concepts/acd-queue-model) owns the routing shape
  behind queue-flow state transitions; the conference and take-over
  flows this page gates are the voice-plane sibling of the queue model.
* [Supervisor take-over failures](/troubleshooting/supervisor-takeover-failures)
  owns the `SUPERVISOR_*` family this page's `INVARIANT_VIOLATION` sits
  beside.
* [Conference lifecycle failures](/troubleshooting/conference-failures)
  owns the event flow the `CONFERENCE_STATE_UNRESOLVED` gate reads.
* [Campaign and journey enrollment errors](/troubleshooting/campaign-journey-errors)
  owns the runtime-refusal family (audience, enrollment) beside the
  launch-state family this page gates.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.