# Job States & Transitions

A job is always in exactly one of seven states. Three are non-terminal, meaning the job still has work ahead of it, and four are terminal, meaning it has settled and will not move again on its own. This page is the canonical reference for that state machine: the enum itself, where the current state lives on the job row, every legal transition and the event that drives it, and the exact strings recorded as the cause when a job goes terminal. The [Job Lifecycle](/docs/core-concepts/job-lifecycle) page tells the same story as narrative; this page is the enumeration.

## The seven states

The state is the `JobState` enum. Its members, in declaration order, are the persisted ordinals, so the order is load-bearing for stored data and never changes.

```csharp title="JobState.cs"
public enum JobState
{
    Scheduled,       // 0
    AwaitingParent,  // 1
    Leased,          // 2
    Succeeded,       // 3
    Cancelled,       // 4
    DeadLettered,    // 5
    Quarantined,     // 6
}
```

| Ordinal | State | Terminal | Meaning |
|--:|---|---|---|
| 0 | `Scheduled` | no | Enqueued and waiting for its due time; eligible to be claimed once due. |
| 1 | `AwaitingParent` | no | Held back until its parents reach a terminal state; becomes `Scheduled` once the last parent resolves. |
| 2 | `Leased` | no | Claimed by a worker and running under a lease that must be renewed by heartbeat. |
| 3 | `Succeeded` | yes | Terminal: the job completed successfully. |
| 4 | `Cancelled` | yes | Terminal: the job was cancelled, either by an operator or because an on-success parent failed. |
| 5 | `DeadLettered` | yes | Terminal: the job exhausted its retry budget and was set aside for inspection. |
| 6 | `Quarantined` | yes | Terminal: the job could not be routed to a handler and was set aside. |

The enum member is spelled `DeadLettered`, one word. In prose the domain term is written Dead-Lettered.

`Leased` is the only in-flight state; a job is only ever running while it is `Leased`. Two terminal states are easy to confuse. A job is `DeadLettered` when it ran and kept failing until it used up its attempt ceiling. A job is `Quarantined` when it could not be routed at all, because its Wire Name has no registered handler or its payload no longer deserializes. Dead-lettering is a failure of the work; quarantine is a failure to even start it.

## Terminal versus non-terminal

Terminality is available as an extension on the enum:

```csharp title="JobState.cs"
public static bool IsTerminal(this JobState state);
```

It returns true for `Succeeded`, `Cancelled`, `DeadLettered`, and `Quarantined`, and false otherwise. The guarantee behind it is absolute: a terminal job never transitions again on its own. Only an explicit operator action, such as a requeue, can move it, and only from two of the four terminal states. Everything else in the table below is an automatic transition BackWave applies for you.

## Where the state lives on the job row

The current state and the facts that go with it live on the `JobRecord`. These fields are how you read a job's position in the state machine.

| Field | Type | Meaning |
|---|---|---|
| `State` | `JobState` | The job's current lifecycle state. |
| `TerminalAt` | `DateTimeOffset?` | When the job reached a terminal state, or null while it is still live. |
| `TerminalCause` | `string?` | A short human-readable reason for the terminal state, or null while live. |
| `Attempt` | `int` | How many times execution has been started. A claim increments this, because claiming is the start of an attempt. |
| `LeaseOwner` | `string?` | The worker holding the lease; non-null only while `Leased`. |
| `LeaseExpiry` | `DateTimeOffset?` | When the current lease lapses; non-null only while `Leased`. |
| `CancelRequested` | `bool` | The cooperative-cancel flag, observed by the handler via heartbeat. |
| `ParentsRemaining` | `int` | Countdown of non-terminal parents; reaches zero when the last parent resolves. |
| `DueTime` | `DateTimeOffset` | The UTC instant the job becomes eligible; meaningful while `Scheduled`. |
| `Mode` | `DependencyMode` | How the parent gate resolves; defaults to `DependencyMode.OnSuccess`. |

`TerminalAt` and `TerminalCause` are null while the job is live and set on the terminal transition, with one exception: a `Succeeded` job stamps `TerminalAt` but leaves `TerminalCause` null. `LeaseOwner` and `LeaseExpiry` are non-null only while `Leased`.

## The transition table

Every legal transition, the event that triggers it, and its effect on `Attempt`. A job enters the machine from `(none)` at enqueue or when a schedule mints an instance, moves through the non-terminal states, and settles in a terminal state. Each state-changing operation also appends one entry to the job's Transition Log.

| From | To | Trigger | Attempt effect |
|---|---|---|---|
| (none) | `Scheduled` | Enqueued with no parents, or with all parents already terminal and satisfied | starts at 0 |
| (none) | `Scheduled` | A recurring schedule's due tick mints an instance, or an operator triggers a schedule now | starts at 0 |
| (none) | `AwaitingParent` | Enqueued with one or more still-non-terminal parents | starts at 0 |
| (none) | `Cancelled` | Enqueued as an on-success child whose gating parent had already reached a non-`Succeeded` terminal state | starts at 0 |
| `AwaitingParent` | `Scheduled` | The last gating parent resolves and the gate is satisfied | unchanged |
| `AwaitingParent` | `Cancelled` | An on-success parent reaches a non-`Succeeded` terminal state (cascades to this job's own children) | unchanged |
| `Scheduled` | `Leased` | A worker claims a due job (queue not paused, concurrency slot free) | `Attempt += 1` |
| `Leased` | `Leased` | A heartbeat renews the lease and extends `LeaseExpiry` | unchanged |
| `Leased` | `Succeeded` | The worker reports `JobOutcome.Success` | unchanged |
| `Leased` | `Scheduled` | The worker reports `JobOutcome.Failure` with a retry remaining, or the lease expires with a retry remaining | unchanged |
| `Leased` | `DeadLettered` | The worker reports `JobOutcome.Failure` with the ceiling exhausted, or the lease expires with the ceiling exhausted | unchanged |
| `Leased` | `Cancelled` | The worker reports `JobOutcome.Cancelled` after observing the cooperative-cancel flag | unchanged |
| `Leased` | `Quarantined` | The worker reports `JobOutcome.Unroutable` | unchanged |
| `Scheduled` / `AwaitingParent` | `Cancelled` | An operator cancels a not-yet-running job (cascades to children) | unchanged |
| `DeadLettered` / `Quarantined` | `Scheduled` | An operator requeues the job | reset to 0 |

A few points the table compresses:

- **The claim raises the Attempt.** A fresh job sits at `Attempt` 0, and the first claim makes it 1, which is the number the first real attempt runs under. A failure does not raise the Attempt again; the claim already did. A lease expiry disposes an attempt that was already counted at claim, so it does not re-increment either.
- **`Leased` has two paths to `Scheduled` and two to `DeadLettered`.** One pair is driven by a reported outcome, the other by lease expiry when a worker crashes or is cut off. The expiry pair is the crash-recovery path, using the same retry-or-dead-letter decision as a reported failure.
- **Reported outcomes are fenced.** A reported outcome applies only when the caller still holds the live lease for exactly that attempt: the state must be `Leased`, the lease owner and attempt number must match, and the lease must not have expired. A stale write changes nothing. This is what makes the recorded outcome exactly-once even though a handler body can run more than once.
- **Terminal transitions cascade.** Any transition that sends a job to a terminal state resolves the latches of its waiting children in the same atomic step, which may release or cancel them, recursively.
- **`Succeeded` is where output is stored.** Job Output is persisted atomically with the transition to `Succeeded` and only there. Output over the size limit is rejected rather than truncated.
- **Requeue is the only terminal-to-active edge.** It accepts only `DeadLettered` or `Quarantined`. A requeue returns the job to `Scheduled`, due now, with `Attempt` reset to 0, clearing the lease, the cancel flag, `TerminalAt`, and `TerminalCause`. `Succeeded` and `Cancelled` are not requeueable.

Whether the parent gate releases a child to `Scheduled` or cancels it is set by the job's `DependencyMode`: `OnSuccess` (the default) releases only if every parent `Succeeded` and cancels the child if any parent reaches another terminal state, while `OnAnyTerminal` releases once every parent is terminal whatever the outcome. The [Dependencies](/docs/core-concepts/dependencies) page owns the full model.

## Retry and the attempt ceiling

Whether a failed `Leased` job goes back to `Scheduled` or on to `DeadLettered` is decided above the store by the `RetryPolicy`, then handed to the store as a plain next-due-time that is either set (retry) or null (dead-letter). The store never runs policy code.

```csharp title="RetryPolicy.cs"
public sealed record RetryPolicy
{
    public int MaxAttempts { get; init; } = 10;
    public Func<int, TimeSpan> Backoff { get; init; } = DefaultBackoff;

    public static RetryPolicy Default { get; }

    public static TimeSpan DefaultBackoff(int attempt)
        => TimeSpan.FromSeconds(Math.Min(Math.Pow(2, attempt), 300));
}
```

`MaxAttempts` defaults to 10, so a job dead-letters when its 10th attempt fails. The default backoff is two to the power of the attempt number in seconds, capped at five minutes. Configuring retries is covered on the [Retries and Error Handling](/docs/guides/configure-retries-and-error-handling) guide.

## Recorded terminal causes

When a job goes terminal, `TerminalCause` records one short reason on the job row. It is not the same thing as Failure Detail: `TerminalCause` is why the terminal state was reached, a single string on the job; Failure Detail is the full per-attempt exception text captured separately on a Transition Log entry. The recorded value depends on the terminal state and the path taken.

| Terminal state | Cause path | Recorded `TerminalCause` |
|---|---|---|
| `Succeeded` | any | null (never set; only `TerminalAt` is stamped) |
| `Cancelled` | on-success parent failed | `parent-failure:{parentState}` |
| `Cancelled` | operator cancels a not-yet-running job | the operator identity passed to the cancel call |
| `Cancelled` | cooperative cancel of a running job | `operator-cancel` |
| `DeadLettered` | reported failure exhausted the budget | the handler exception's message |
| `DeadLettered` | lease expiry exhausted the budget | `Lease expired on attempt {n} (attempt ceiling reached).` |
| `Quarantined` | no registered handler | `no handler registered for wire name '{wireName}'` |
| `Quarantined` | payload no longer decodes | `payload for wire name '{wireName}' no longer decodes: {message}` |

For a parent-failure cancel, the interpolated `{parentState}` is the failing parent's `JobState` name, so the concrete recorded values are `parent-failure:DeadLettered`, `parent-failure:Cancelled`, and `parent-failure:Quarantined`. A succeeded parent satisfies the gate and never produces a cancel. This cause is recorded both when the failure is already visible at enqueue and when a parent fails later while the child waits.

Only an operator-requested cancel of a running job produces a `Cancelled` terminal. A handler's own `OperationCanceledException`, such as an `HttpClient` timeout, is reported as an ordinary failure and retries like any other.

## Terminal retention classes

Terminal jobs are retained and purged by class, not one state at a time. `TerminalStateClass` groups the four terminal states into two, each purged on its own keep window.

```csharp title="TerminalStateClass.cs"
public enum TerminalStateClass
{
    SucceededOrCancelled,
    DeadLetteredOrQuarantined,
}
```

| Class | Members | Typical retention |
|---|---|---|
| `SucceededOrCancelled` | `Succeeded`, `Cancelled` | Jobs that ended as intended; usually kept for a short window. |
| `DeadLetteredOrQuarantined` | `DeadLettered`, `Quarantined` | Jobs someone may still need to inspect; usually kept for a longer window. |

Each class has its own keep window and is purged independently. A job's Transition Log is deleted with the job.

## Operator-action results

The operator actions that touch the state machine return a result telling you what the machine did, so a caller never has to re-read the job to find out whether anything changed.

| Result type | Value | Meaning |
|---|---|---|
| `CancelResult` | `CancelledImmediately` | A `Scheduled` or `AwaitingParent` job moved straight to `Cancelled`. |
| `CancelResult` | `CancellationRequested` | A `Leased` job had the cancel flag set; it cancels cooperatively on the next heartbeat. |
| `CancelResult` | `NotCancellable` | The job was absent or already terminal; nothing changed. |
| `RequeueResult` | `Requeued` | A `DeadLettered` or `Quarantined` job returned to `Scheduled` with `Attempt` reset to 0. |
| `RequeueResult` | `NotRequeueable` | The job was not in one of the two requeueable terminal states. |
| `OutcomeResult` | `Applied` | A reported outcome passed the lease fence and was applied. |
| `OutcomeResult` | `StaleLease` | The reporter no longer held the live lease for that attempt; nothing changed. |
| `TriggerScheduleResult` | `Triggered` | A schedule minted one instance now. |
| `TriggerScheduleResult` | `ScheduleNotFound` | No such schedule; nothing changed. |
| `EnqueueResult` | `Ok`, `Duplicate`, `PayloadTooLarge`, `WireNameTooLong`, `UnknownParent`, `TooManyParents` | The outcome of an enqueue. |

Cancelling a running job is always cooperative: `CancelResult.CancellationRequested` sets the flag, and the job reaches `Cancelled` only once the handler observes its `CancellationToken` and returns. The full cancellation path is on the [Cancel a Running Job](/docs/guides/cancel-a-running-job) guide.

## Where to go next

- [Job Lifecycle](/docs/core-concepts/job-lifecycle): the same state machine as narrative, with the retry loop, crash recovery, and the Transition Log.
- [Dependencies](/docs/core-concepts/dependencies): `AwaitingParent`, `ParentsRemaining`, on-success versus any-terminal, and the cancellation cascade.
- [Execution Guarantee](/docs/core-concepts/execution-guarantee): the `Leased` state, heartbeat renewal, lease expiry, and the lease fence in depth.
- [Retries and Error Handling](/docs/guides/configure-retries-and-error-handling): configuring `RetryPolicy` and what sends a job to Dead-Lettered.
- [Cancel a Running Job](/docs/guides/cancel-a-running-job): cooperative cancellation through the `CancellationToken` and the heartbeat-delivered flag.
- [Limits & Defaults](/docs/reference/limits-and-defaults): the default `MaxAttempts`, backoff cap, and other defaults in one place.
