# Decision record — the book status state machine + agent-facing status contract

> **Status: DECIDED (Spike PBI #1999, 2026-07-10).** This ratifies the `ProjectStatus` state machine
> exactly as the code enforces it today and specifies the single, described **status DTO** an agent (and
> the MCP server, Feature #1411 / build PBI #1433) reads to know *where a book is, whether it is moving,
> and — if not — what it is waiting on or blocked on*. It changes **docs, not product code**. The two
> consuming implementation PBIs execute against it: the **status endpoint** `GET /v1/projects/{id}/status`
> (#2000) emits the DTO; the **transition-table refactor** (#2001) consolidates the scattered guards into
> the one table below. The lifecycle edge-case audit (#2005) reads its state vocabulary from here.
>
> **Follow-up implemented (PBI #2015, 2026-07-16; refined by PBI #1537, 2026-07-16).** This spike
> originally found `ProjectStatus.Failed` declared but never assigned in product code (§5, §7 finding 1) —
> a book whose pages all exhaust their retry cap was stuck `Processing` forever with a dead-end `retry`
> affordance. #2015 closed that gap by assigning `ProjectStatus.Failed` once a book has no reachable path
> to `Complete` left. **#1537 then gave that outcome its own honest name**: `ProjectStatus.Failed` reads as
> a book-level *error* (`phase:"error"`, `blockedOn:"retry"`) — but a book with permanently-exhausted pages
> did **not** fail; it finished, just with some pages missing. So #1537 introduces a dedicated terminal
> `ProjectStatus.CompletedWithErrors` and **reverts `ProjectStatus.Failed` to its original declared-but-
> never-assigned state** — the exact mechanism #2015 built (the `moreWork`/`hasExhaustedFailure` decision
> in `RunBatchAsync`, the `RetryFailedPagesAsync` exhausted-gate, `ProjectStateMachine`'s `BatchRunTargets`)
> is unchanged; only the terminal value it writes changed. `CompletedWithErrors` maps to `phase:"done"`,
> `nextAction:"download"` (never `"retry"` — the remaining failures are exhausted, so offering retry would
> just refuse again), with `title`/`detail` naming the failed-page count. It is also exportable (a gap note
> flags the missing pages) and fires the completion notification (distinguishing copy from a clean
> `Complete`). This document's §2/§3/§5/§6/§7/§8 are updated below to reflect the rename; the rest of the
> DTO mapping required no further changes.

---

## TL;DR — decision table

| Question | Answer | Where |
|---|---|---|
| **How many book states are there?** | **10** `ProjectStatus` values, each with exactly one *kind* (`intake` / `working` / `waiting` / `error` / `terminal`). | [§2](#2-the-states-and-their-kinds) |
| Is the transition set already fixed in code? | **Yes** — enforced ad-hoc across five call sites. [§3](#3-the-legal-transition-table) makes it explicit + complete; #2001 makes it the *sole* authority. | [§3](#3-the-legal-transition-table) |
| Can an agent read one value and know what to do? | **Not today** — the truth is split across `ProjectStatus` + `JobStatus` + `ModelBackendStatus` + `PageStatus`. The **status DTO** ([§4](#4-the-described-status-dto)) folds all four into one described object. | [§4](#4-the-described-status-dto) |
| **Waiting vs blocked — the whole point** | `PausedNeedsCapacity` = **waiting** (on GPU, auto-resumes, `nextAction=null`). `PausedNeedsCredit` = **blocked** on the user (`nextAction=add_credit`). An agent must know whether to *wait* or *prompt*. | [§4](#4-the-described-status-dto), [§6](#6-worked-example-payloads) |
| Is `ProjectStatus.Failed` ever set? | **No, as of PBI #1537** — declared but never assigned (reverting #2015's original choice). Once every included page is `Approved` or has permanently exhausted its retry attempts (no `Pending` page left anywhere), the book instead settles the dedicated terminal `ProjectStatus.CompletedWithErrors` — `phase:"done"`, `nextAction:"download"`, never `"retry"` (the remaining failures are exhausted, so retry would just refuse again). A *whole-batch* failure (every page in one run threw) is still a same-state settle (`JobStatus.Failed`, book unchanged) — the book only goes terminal once no reachable work is left. | [§5](#5-current-state--how-the-four-signals-live-today), [§7](#7-findings--flags-for-the-consuming-pbis) |
| Closed taxonomies (machine-stable)? | **Four:** `phase`, `waitingOn`, `blockedOn`, `nextAction`. Enumerated in [§4.2](#42-the-four-closed-taxonomies). | [§4.2](#42-the-four-closed-taxonomies) |

---

## 1. Context / why

Translation runs **fully on the API with no human gate** (PBI #1455): a book normalizes, then the batch
ladder widens `1 → 5 → 25 → 100 → rest` automatically, each processed batch auto-confirming and enqueuing
the next. The only thing that pauses an in-flight book is **affordability** (next batch over the wallet →
clean pause) or **GPU capacity** (cheap tier throttled → clean pause); failed pages are recovered by an
explicit **retry**.

For an agent — or the MCP server that will *drive and narrate* a run — that automatic machine has to be
**legible**. The agent must be able to answer, at any instant and from one read: **is this book moving? if
it stopped, is it waiting (do nothing, it self-heals) or blocked (prompt the human)? and what is the one
sensible next action?**

Today that answer is spread across **four separate signals**, none of which is described as a unit:

| Signal | Type | Values |
|---|---|---|
| `ProjectStatus` | book | `Created`, `Calibrating`, `Processing`, `PausedNeedsCredit`, `PausedNeedsCapacity`, `Complete`, `CompletedWithErrors`, `Failed`, `Cancelling`, `Cancelled` |
| `JobStatus` | batch job | `Queued`, `Running`, `Succeeded`, `Failed` |
| `ModelBackendStatus.State` | GPU overlay (#1833) | `ready`, `starting`, `throttled`, `unconfigured` |
| `PageStatus` | page | `Pending`, `Processing`, `Approved`, `Failed` |

An agent reading only `ProjectStatus` **cannot** tell a healthy mid-batch `Processing` from one whose GPU
is cold-starting (`Queued` job + backend `starting`) or capacity-throttled. That ambiguity is what this
spike removes.

---

## 2. The states and their kinds

Every `ProjectStatus` (`src/BookTranslation.Core/Domain/Enums.cs`) with its **kind** — the coarse bucket
used to reason about it — plus whether it is terminal and how it leaves.

| State | Kind | Terminal? | Meaning | How it is entered | How it leaves |
|---|---|:--:|---|---|---|
| `Created` | `intake` | no | Project row exists; **no pages** produced yet. | `CreateProjectAsync` (`∅ → Created`). | `normalize` → `Calibrating`. |
| `Calibrating` | `intake` | no | Pages exist; the careful "prove it on a few pages" phase (ladder rungs `1 → 5 → 25`). Spans the **pre-commit** window (all pages `Pending`, page selection still editable) *and* the first batch's run. | `normalize` from `Created`. | `processBatch` → `Processing` \| `Complete` \| `PausedNeedsCapacity` \| `Cancelled`; `cancel` → `Cancelling` \| `Cancelled`. |
| `Processing` | `working` | no | A batch auto-confirmed; the book is moving and ready for the next rung. **The steady working state.** | `processBatch` settles here whenever more included work remains. | `processBatch` (next rung); `autoAdvance` pause → `PausedNeedsCredit`; `processBatch` throttle → `PausedNeedsCapacity`; last work done → `Complete`; `cancel` → `Cancelling` \| `Cancelled`. |
| `PausedNeedsCapacity` | `waiting` | no | Cheap GPU tier was **capacity-throttled** past the bounded wait, so the run cleanly paused rather than boosting to a pricier tier (PBI #1998, spike #1956 §Q2 — *WAIT, never boost*). Pages stay `Pending` (retryable). **Not user-actionable — auto-resumes.** | `processBatch` catches `RunPodCapacityException` → `PauseForCapacityAsync`. | `autoAdvance`/`processBatch` retries on the next Worker poll → `Processing`/`Complete`, or re-pauses here; `cancel` → `Cancelling` \| `Cancelled`. |
| `PausedNeedsCredit` | `waiting` | no | Next batch is **over the wallet balance**: cleanly paused until the owner adds credit (PBI #1535). **Blocked on the user** — but non-terminal; auto-resumes within one poll after a top-up. | `autoAdvance` when `QuoteNextBatchAsync` is not affordable. | `autoAdvance` when affordable again → `Processing` (then enqueues); `cancel` → `Cancelling` \| `Cancelled`. |
| `Complete` | `terminal` | **yes** | Every included page processed. A completion notification fires once. | `processBatch` when no included `Pending`/`Failed` pages remain. | — (terminal). `retry` is not reachable (no failed pages by definition). |
| `CompletedWithErrors` | `terminal` | **yes** | Assigned since **PBI #1537** (refines #2015's original choice of `Failed`): every included page is `Approved` or has permanently exhausted `MaxProcessingAttempts`, with no `Pending` page left anywhere in the book — see [§5](#5-current-state--how-the-four-signals-live-today). The book **finished as far as it can**, not a book-level failure: still exportable (a gap note flags the missing pages), fires the completion notification (distinguishing copy), and maps to `phase:"done"` + `nextAction:"download"` — never `"retry"`, since the remaining failures are permanently exhausted. | `processBatch`/`retry` settling `RunBatchAsync` when no reachable work remains but an exhausted page does; or `retry` refusing with nothing left to retry and no pending work elsewhere. | — (terminal). |
| `Failed` | `error` | **yes** | Declared but **never assigned** in product code (reverted to this by #1537 — see above). Reserved for a genuine book-level unrecoverable failure, distinct from `CompletedWithErrors`. | (none today) | — |
| `Cancelling` | `working` | no (transient) | Owner asked to stop an in-flight run (PBI #1534); the in-flight batch winds down. The auto-advancer never starts a new batch from here. | `cancel` when a batch job is in flight. | `finalizeCancel` (pipeline or auto-advancer) → `Cancelled`. |
| `Cancelled` | `terminal` | **yes** | Owner-cancelled; terminal. Already-translated pages are retained (readable + exportable); no further batch runs. | `cancel` with nothing in flight, or `finalizeCancel` from `Cancelling`. | — (terminal). |

**Kind semantics for the agent:** `intake` = still being set up (may be idle awaiting a *Proceed*).
`working` = actively progressing on its own. `waiting` = paused, will resume itself or after a user top-up
(never a dead end). `error` = terminal failure needing attention. `terminal` = finished (done or
cancelled), nothing more will happen.

---

## 3. The legal transition table

The exact moves the code allows, named as **events**. This is the table #2001 turns into the single
`ProjectStateMachine` authority (Core, no Infrastructure/RunPod types) that every guard routes through.

| From \ Event | `normalize` | `processBatch` † | `retry` † | `autoAdvance` | `cancel` | `finalizeCancel` |
|---|---|---|---|---|---|---|
| `Created` | → `Calibrating` | — | — | — | → `Cancelled` | — |
| `Calibrating` | — | → `Processing` \| `Complete` \| `CompletedWithErrors` \| `PausedNeedsCapacity` \| `Cancelled` | → (same targets as `processBatch`) | — | → `Cancelling`\|`Cancelled` | — |
| `Processing` | — | → `Processing` \| `Complete` \| `CompletedWithErrors` \| `PausedNeedsCapacity` \| `Cancelled` | → (same) | → `PausedNeedsCredit` (unaffordable) / enqueue | → `Cancelling`\|`Cancelled` | — |
| `PausedNeedsCapacity` | — | → `Processing` \| `Complete` \| `CompletedWithErrors` \| `PausedNeedsCapacity` \| `Cancelled` | → (same) | enqueue (status unchanged until the batch runs) | → `Cancelling`\|`Cancelled` | — |
| `PausedNeedsCredit` | — | → `Processing` \| `Complete` \| `CompletedWithErrors` \| `PausedNeedsCapacity` \| `Cancelled` | → (same) | → `Processing` (affordable, then enqueue) / stay | → `Cancelling`\|`Cancelled` | — |
| `Complete` | — | ✗ 409 | ✗ (no failed pages) | no-op | ✗ 409 | — |
| `CompletedWithErrors` | — | ✗ 409 | ✗ (no *retryable* failed pages — the remaining ones are exhausted) | no-op | ✗ 409 | — |
| `Failed` | — | ✗ 409 | → (same) † (still a no-op today — see below; state is unreachable) | no-op | ✗ 409 | — |
| `Cancelling` | — | → `Cancelled` (finalizes, no work) | → `Cancelled` (finalizes) | → `Cancelled` (finalizes) | no-op (idempotent 200) | → `Cancelled` |
| `Cancelled` | — | → `Cancelled` (no-op) | → `Cancelled` (no-op) | no-op | ✗ 409 | no-op |

† **`processBatch`** = `ProcessNextBatchAsync`; **`retry`** = `RetryFailedPagesAsync`. Both first call
`TryFinalizeCancellationAsync`, so from `Cancelling`/`Cancelled` they settle to terminal `Cancelled` and do
no work. Both funnel through `RunBatchAsync`, whose terminal write is: cancellation-won → `Cancelled`;
capacity-throttled → `PausedNeedsCapacity`; more reachable work (an included `Pending` page, or a `Failed`
page still under `MaxProcessingAttempts`) → `Processing`; no reachable work left but an included page
permanently exhausted its attempts → **`CompletedWithErrors`** (PBI #1537, refines #2015's original choice
of `Failed`); else → `Complete`. A **whole-batch failure** (every page in the run threw) is *ordinarily* a
**same-state settle** — the job is marked `JobStatus.Failed` and the book stays where it was, pages `Failed`
+ individually retryable — **unless that very failure was the one that exhausted the last retryable page**,
in which case it also settles `CompletedWithErrors` before rethrowing (PBI #1537), so the book itself is
done even though that particular job/attempt reports `JobStatus.Failed`. `retry` from `Failed` is legal in
the table but a no-op today: nothing resets a page's `ProcessingAttempts`, so a retry attempt from `Failed`
always finds `retryable.Count == 0` again — moot regardless, since `Failed` is unreachable in practice.
`retry` from `CompletedWithErrors` is likewise always refused by the same attempt-cap check (every
remaining `Failed` page there is, by definition, exhausted).

**Event → call-site map** (the five guards #2001 replaces with this table):

| Event | Guard / call site | Legal-`from` set enforced today |
|---|---|---|
| `processBatch` start guard | `TranslationPipeline.ProcessNextBatchAsync` (`Pipeline/TranslationPipeline.cs:222`) | `Calibrating, Processing, PausedNeedsCredit, PausedNeedsCapacity` |
| `processBatch` enqueue guard | `Program.cs` `POST /projects/{id}/process` (`:1965`) | `Calibrating, Processing, PausedNeedsCredit, PausedNeedsCapacity` |
| `autoAdvance` resumable set | `BatchAutoAdvancer.TryAdvanceAsync` (`Jobs/BatchAutoAdvancer.cs:79`) | `Processing, PausedNeedsCredit, PausedNeedsCapacity` (+ `Cancelling`→finalize) |
| `autoAdvance` pre-filter | `Worker.AdvanceReadyProjectsAsync` (`Worker.cs:102`) | `Processing, PausedNeedsCredit, PausedNeedsCapacity` |
| `processable` set (quote) | `BillingService.QuoteNextBatchAsync` (`Billing/BillingService.cs:660`) | `Calibrating, Processing, PausedNeedsCredit, PausedNeedsCapacity` |
| `cancel` exclusion guard | `Program.cs` `POST /projects/{id}/cancel` (`:2019`) | reject `Complete, CompletedWithErrors, Failed, Cancelled`; no-op `Cancelling`; else `Cancelling`/`Cancelled` |

**Helpers #2001 should expose** (so a new state is one table row, not five edits):
`CanProcessNextBatch(status)` ⇒ `{Calibrating, Processing, PausedNeedsCredit, PausedNeedsCapacity}`;
`IsResumable(status)` ⇒ `{Processing, PausedNeedsCredit, PausedNeedsCapacity}`;
`IsTerminal(status)` ⇒ `{Complete, CompletedWithErrors, Failed, Cancelled}`;
`CanCancel(status)` ⇒ not terminal and not already `Cancelling`.

### State diagram

```
                 create            normalize           processBatch (more work)
        ∅ ─────────────▶ Created ───────────▶ Calibrating ───────────────┐
                            │                     │                       │
                            │ cancel              │ processBatch          ▼
                            ▼                     │  (last work)     ┌──────────┐
                        Cancelled ◀───────────────┼─────────────────│Processing│◀─┐
                            ▲                     │                  └────┬─────┘  │ autoAdvance
              finalizeCancel│                     │  processBatch         │        │ (affordable)
                            │                     ▼  (no more work)       │        │
                       Cancelling            Complete ◀───────────────────┤        │
                            ▲                (terminal)                    │        │
                     cancel │(in-flight)                                   │        │
   ┌────────────────────────┴───────────────────┬───────────────────┬─────┘        │
   │                                             │                   │              │
   │ processBatch: RunPodCapacityException       │ autoAdvance:      │ processBatch │
   ▼                                             ▼  unaffordable     │  throttle    │
 PausedNeedsCapacity  ──── autoAdvance/processBatch retries ────▶ (Processing/Complete)
 (waiting, auto)                                                     ▲
 PausedNeedsCredit  ──── autoAdvance: top-up makes affordable ───────┘
 (waiting, blocked-on-user)

 CompletedWithErrors (terminal, done-but-flagged) — reached instead of Complete when no reachable work is
 left but an included page permanently exhausted its retries (PBI #1537). Still exportable + notifies.

 Failed (error, terminal) — declared, never assigned today (reverted to this by #1537); a whole-batch
 failure that does NOT exhaust the last page ⇒ JobStatus.Failed, book unchanged (same-state settle).
```

---

## 4. The described status DTO

The single object `GET /v1/projects/{id}/status` (#2000) returns. **Composed, never stored** — derived on
each read from: the project (`ProjectStatus` + page counts + `ApprovalCount`) + the latest
`ProcessingJob` for it + the `IModelBackendReadiness` probe (#1833) + the `BatchQuote`
(`QuoteNextBatchAsync`, expected/ceiling per #1997). No new persistence.

### 4.1 Fields

| Field | Type | Machine-stable? | Source | Notes |
|---|---|:--:|---|---|
| `projectId` | GUID | — | project | |
| `state` | enum `ProjectStatus` | **yes** | project | The exact state-machine value (`"PausedNeedsCapacity"`), stable forever. |
| `phase` | enum (§4.2) | **yes** | derived | Coarse bucket for a glance; overlay-aware (a `Calibrating`/`Processing` book with an active job reads `working`). |
| `title` | string | no | derived | Short human headline (`"Waiting for GPU capacity"`). Display only. |
| `detail` | string | no | derived | One-sentence explanation. Display only. |
| `isWaiting` | bool | **yes** | derived | True when the book will resume **on its own** (no user action). |
| `waitingOn` | enum\|null (§4.2) | **yes** | derived | Why it is waiting; `null` unless `isWaiting`. |
| `isBlocked` | bool | **yes** | derived | True when it needs a **user action** to proceed. |
| `blockedOn` | enum\|null (§4.2) | **yes** | derived | What the user must resolve; `null` unless `isBlocked`. |
| `resumesAutomatically` | bool | **yes** | derived | True for `waiting` states; the agent should poll, not prompt. Mutually consistent with `isWaiting`. |
| `nextAction` | enum\|null (§4.2) | **yes** | derived | The single sensible next move (`add_credit`/`retry`/`download`), or `null` when the right move is "wait". |
| `progress` | object | — | project | `{ pagesTranslated, pagesTotal, ladderRung }`. `pagesTranslated` = included `Approved`; `pagesTotal` = included pages; `ladderRung` = the current/next batch width (`"1"`,`"5"`,`"25"`,`"100"`,`"rest"`). |
| `activeJob` | object\|null | — | latest job | `{ id, status }` for the newest `ProcessNextBatch`/`RetryFailedPages` job that is `Queued`/`Running`; else `null`. |
| `backend` | object | — | `IModelBackendReadiness` | `{ state, detail, warmupStage }` from `ModelBackendStatus` (`ready`/`starting`/`throttled`/`unconfigured`). The GPU overlay. `warmupStage` (#2149) is the **coarse cold-start progress** while `state` is `starting` — `initializing` → `loading_models` (`running` is reserved/never-emitted) — else `null`. It lets a client tell the user *where* in a multi-minute cold start the GPU is, and distinguishes a normal cold start from a capacity throttle (`throttled`, no warmup stage). |
| `quote` | object\|null | — | `BatchQuote` | `{ expectedUsd, ceilingUsd, balanceUsd }` — expected "about $X" (`ExpectedUsd`), never-exceed "$Y" (`EstimatedUsd`, the gated ceiling), and wallet `BalanceUsd`. `null` when no batch is pending (e.g. `Complete`). |
| `updatedUtc` | UTC ISO-8601 | — | project | Last state change (UTC, per the project's own timestamp discipline). |

### 4.2 The four closed taxonomies

Closed enums so the contract is machine-stable — an agent/SDK can `switch` on them exhaustively.

| Taxonomy | Values | Meaning of each |
|---|---|---|
| **`phase`** | `intake` \| `working` \| `waiting` \| `done` \| `error` \| `cancelled` | Coarse lifecycle. `intake`=setting up; `working`=progressing; `waiting`=paused, self-/top-up-resuming; `done`=`Complete`; `error`=`Failed`; `cancelled`=`Cancelling`/`Cancelled`. |
| **`waitingOn`** | `null` \| `gpu_capacity` \| `gpu_warmup` \| `queue` \| `credit` | Why a **waiting** book is paused. `gpu_capacity`=throttled tier (`PausedNeedsCapacity`); `gpu_warmup`=backend `starting` while a job is `Queued`; `queue`=job `Queued` with a `ready` backend (waiting its turn). `credit` is **reserved** — the canonical mapping never emits it (an out-of-credit book is *blocked*, see below), it is kept in the closed set only for forward-compat. |
| **`blockedOn`** | `null` \| `credit` \| `retry` | What a **blocked** book needs from the user. `credit`=`PausedNeedsCredit` (add funds); `retry`=persistent page failures past the auto-retry cap (needs an explicit retry / attention). |
| **`nextAction`** | `null` \| `add_credit` \| `retry` \| `download` | The one move to surface. `null`=wait (the agent should poll). `add_credit`↔`blockedOn=credit`; `download`=`Complete`; `retry`=failed pages present. |

> ⚠️ **Waiting is not blocked.** `waitingOn != null` ⇒ `isWaiting=true`, `resumesAutomatically=true`,
> `nextAction=null` — the agent **waits**. `blockedOn != null` ⇒ `isBlocked=true`,
> `resumesAutomatically=false`, `nextAction` is the fix — the agent **prompts the human**. A book is never
> both `isWaiting` and `isBlocked`. This is the single distinction the whole DTO exists to make crisp.

---

## 5. Current state — how the four signals live today

Read from the repo (verified, not assumed):

- **`ProjectStatus`** transitions are written in exactly the places [§3](#3-the-legal-transition-table)
  lists; there is no central authority — each guard re-lists its own legal set (which is why threading
  `PausedNeedsCapacity` in for #1998 touched five sites). #2001 fixes that.
- **`ProjectStatus.CompletedWithErrors` is assigned since PBI #1537** (refines #2015's original choice of
  `ProjectStatus.Failed`; `ProjectStatus.Failed` itself is once again declared but never assigned).
  `TranslationPipeline.RunBatchAsync` (settling a `processBatch`/`retry` run — both the packages-succeeded
  path and the whole-batch-failure path, if that very failure was the exhausting one) and
  `RetryFailedPagesAsync` (refusing with nothing retryable left) all write `ProjectStatus.CompletedWithErrors`
  once no reachable work remains — every included page is `Approved` or has permanently exhausted
  `MaxProcessingAttempts`, with no `Pending` page left anywhere in the book. An *ordinary* whole-batch
  failure (every page in a single run threw, but at least one remains retryable) is still expressed only as
  `JobStatus.Failed` on the `ProcessingJob` row (`JobProcessor.RunOneAsync` catch, `JobProcessor.cs`) plus
  per-page `PageStatus.Failed`, with the **book staying in its prior working state** — that same-state
  settle is unchanged; it is a distinct, narrower case than the terminal-book decision. The DTO's `retry`
  affordance still keys primarily off the presence of *retryable* `Failed` **pages** — a `CompletedWithErrors`
  book has Failed pages too, but none retryable, so it reads `nextAction:"download"`, never `"retry"`.
- **`JobStatus`** (`ProcessingJob.cs`) is the batch's own lifecycle — `Queued`→`Running`→`Succeeded`/
  `Failed`. It is *the* signal that disambiguates a `Processing` book that is actively mid-batch
  (`Running`) from one merely ready for the next rung (no active job).
- **`ModelBackendStatus`** (`ModelBackendReadiness.cs`, #1833/#1977) is a **global** GPU health overlay
  (`ready`/`starting`/`throttled`/`unconfigured`), cached ~10 s and probed through the same
  `RunPodClient` transport real work uses. It is *not* per-book — the DTO layers it over the book to
  explain a stalled `Queued` job (`starting`→`gpu_warmup`, `throttled`→`gpu_capacity`).
- **`ProjectLifecycle`** (`ProjectSummary.cs`) is a pre-existing *coarser* dashboard bucket
  (`Processing`/`PausedNeedsCredit`/`Complete`/`Failed`/`Cancelled`). It notably folds
  `PausedNeedsCapacity` **into `Processing`** (a capacity wait is work-in-progress, not user-actionable).
  The DTO's `phase` is a **different, agent-oriented** taxonomy (it has an explicit `waiting`); the two are
  siblings, not the same field — `phase` is the one the status endpoint emits.

---

## 6. Worked example payloads

Every variation from the AC, plus intake and cancelled for completeness. (`title`/`detail` are
illustrative display copy; the enums are the contract.)

### 6a. Healthy — mid-batch, moving
```json
{
  "projectId": "…", "state": "Processing", "phase": "working",
  "title": "Translating", "detail": "Batch of 25 pages is running.",
  "isWaiting": false, "waitingOn": null,
  "isBlocked": false, "blockedOn": null,
  "resumesAutomatically": false, "nextAction": null,
  "progress": { "pagesTranslated": 31, "pagesTotal": 100, "ladderRung": "25" },
  "activeJob": { "id": "…", "status": "Running" },
  "backend": { "state": "ready", "detail": "A100-80GB" },
  "quote": { "expectedUsd": 4.20, "ceilingUsd": 4.35, "balanceUsd": 12.00 },
  "updatedUtc": "2026-07-10T09:31:22Z"
}
```

### 6b. GPU warm-up — job queued, backend cold-starting (waiting)
```json
{
  "projectId": "…", "state": "Processing", "phase": "waiting",
  "title": "Starting the GPU", "detail": "Warming up the GPU — loading the translation models. The first page can take a few minutes; it starts automatically.",
  "isWaiting": true, "waitingOn": "gpu_warmup",
  "isBlocked": false, "blockedOn": null,
  "resumesAutomatically": true, "nextAction": null,
  "progress": { "pagesTranslated": 1, "pagesTotal": 100, "ladderRung": "5" },
  "activeJob": { "id": "…", "status": "Queued" },
  "backend": { "state": "starting", "detail": "GPU infrastructure is starting", "warmupStage": "loading_models" },
  "quote": { "expectedUsd": 0.90, "ceilingUsd": 0.95, "balanceUsd": 12.00 },
  "updatedUtc": "2026-07-10T09:30:05Z"
}
```

### 6c. GPU capacity wait — `PausedNeedsCapacity` (waiting, auto-resumes)
```json
{
  "projectId": "…", "state": "PausedNeedsCapacity", "phase": "waiting",
  "title": "Waiting for GPU capacity", "detail": "The GPU tier is busy; your run paused and resumes automatically when capacity frees.",
  "isWaiting": true, "waitingOn": "gpu_capacity",
  "isBlocked": false, "blockedOn": null,
  "resumesAutomatically": true, "nextAction": null,
  "progress": { "pagesTranslated": 25, "pagesTotal": 100, "ladderRung": "25" },
  "activeJob": null,
  "backend": { "state": "throttled", "detail": "GPU capacity unavailable — retrying" },
  "quote": { "expectedUsd": 4.20, "ceilingUsd": 4.35, "balanceUsd": 12.00 },
  "updatedUtc": "2026-07-10T09:28:47Z"
}
```

### 6d. Out of credit — `PausedNeedsCredit` (blocked on the user)
```json
{
  "projectId": "…", "state": "PausedNeedsCredit", "phase": "waiting",
  "title": "Add credit to continue", "detail": "The next batch costs more than your balance. Add credit and it resumes automatically.",
  "isWaiting": false, "waitingOn": null,
  "isBlocked": true, "blockedOn": "credit",
  "resumesAutomatically": true, "nextAction": "add_credit",
  "progress": { "pagesTranslated": 6, "pagesTotal": 100, "ladderRung": "25" },
  "activeJob": null,
  "backend": { "state": "ready", "detail": "A100-80GB" },
  "quote": { "expectedUsd": 4.20, "ceilingUsd": 4.35, "balanceUsd": 1.10 },
  "updatedUtc": "2026-07-10T09:25:10Z"
}
```
> Note the deliberate combination: `isBlocked=true`, `nextAction=add_credit`, yet
> `resumesAutomatically=true` — it *is* blocked on a user action, but once that action lands the auto-
> advancer resumes it with no further click. `phase` is `waiting` (paused), `isWaiting` is **false**
> (it will not resume without the top-up). `waitingOn` stays `null`; the `credit` value is carried on
> `blockedOn`, not `waitingOn`.

### 6e. Failed pages — book still `Processing`, retry needed
```json
{
  "projectId": "…", "state": "Processing", "phase": "working",
  "title": "Some pages need a retry", "detail": "3 page(s) failed to process. Retry them to finish the book.",
  "isWaiting": false, "waitingOn": null,
  "isBlocked": true, "blockedOn": "retry",
  "resumesAutomatically": false, "nextAction": "retry",
  "progress": { "pagesTranslated": 94, "pagesTotal": 100, "ladderRung": "rest" },
  "activeJob": { "id": "…", "status": "Failed" },
  "backend": { "state": "ready", "detail": "A100-80GB" },
  "quote": { "expectedUsd": 0.75, "ceilingUsd": 0.80, "balanceUsd": 9.30 },
  "updatedUtc": "2026-07-10T09:33:59Z"
}
```
> Because `ProjectStatus.Failed` is never set ([§5](#5-current-state--how-the-four-signals-live-today)),
> "failed" surfaces here as `state:"Processing"` + failed pages + a `Failed` `activeJob`. The endpoint
> (#2000) derives `blockedOn:"retry"` / `nextAction:"retry"` from the presence of retryable `Failed`
> pages. A book that *were* ever `state:"Failed"` maps to `phase:"error"` + `nextAction:"retry"` — the
> mapping is defined even though the state is currently unreachable.

### 6f. Complete
```json
{
  "projectId": "…", "state": "Complete", "phase": "done",
  "title": "Translation complete", "detail": "All 100 pages are translated and ready to download.",
  "isWaiting": false, "waitingOn": null,
  "isBlocked": false, "blockedOn": null,
  "resumesAutomatically": false, "nextAction": "download",
  "progress": { "pagesTranslated": 100, "pagesTotal": 100, "ladderRung": "rest" },
  "activeJob": null,
  "backend": { "state": "ready", "detail": "A100-80GB" },
  "quote": null,
  "updatedUtc": "2026-07-10T09:40:12Z"
}
```

### 6g. Intake — `Calibrating`, pre-commit (awaiting Proceed)
```json
{
  "projectId": "…", "state": "Calibrating", "phase": "intake",
  "title": "Ready to start", "detail": "Pages are prepared. Processing begins with a single calibration page.",
  "isWaiting": false, "waitingOn": null,
  "isBlocked": false, "blockedOn": null,
  "resumesAutomatically": false, "nextAction": null,
  "progress": { "pagesTranslated": 0, "pagesTotal": 100, "ladderRung": "1" },
  "activeJob": null,
  "backend": { "state": "ready", "detail": "A100-80GB" },
  "quote": { "expectedUsd": 0.00, "ceilingUsd": 0.00, "balanceUsd": 12.00 },
  "updatedUtc": "2026-07-10T09:20:00Z"
}
```
> The first (calibration) batch runs without a dollar estimate (`BatchQuote.HasEstimate=false` until a
> page is measured), so `expectedUsd`/`ceilingUsd` are `0.00` here.

### 6h. Cancelled (and the transient `Cancelling`)
```json
{
  "projectId": "…", "state": "Cancelled", "phase": "cancelled",
  "title": "Cancelled", "detail": "You stopped this run. Already-translated pages are still available.",
  "isWaiting": false, "waitingOn": null,
  "isBlocked": false, "blockedOn": null,
  "resumesAutomatically": false, "nextAction": "download",
  "progress": { "pagesTranslated": 40, "pagesTotal": 100, "ladderRung": "25" },
  "activeJob": null,
  "backend": { "state": "ready", "detail": "A100-80GB" },
  "quote": null,
  "updatedUtc": "2026-07-10T09:22:30Z"
}
```
> `Cancelling` reads identically but `state:"Cancelling"`, `phase:"cancelled"`, with the in-flight
> `activeJob` still `Running` as it winds down and `nextAction:null` until it settles. `nextAction` above
> is `download` only when at least one page was translated before the stop; otherwise `null`.

### 6i. CompletedWithErrors — some pages permanently failed (PBI #1537)
```json
{
  "projectId": "…", "state": "CompletedWithErrors", "phase": "done",
  "title": "Completed (2 pages failed)",
  "detail": "Translation finished, but 2 pages could not be translated after repeated attempts. The rest is ready to read and download.",
  "isWaiting": false, "waitingOn": null,
  "isBlocked": false, "blockedOn": null,
  "resumesAutomatically": false, "nextAction": "download",
  "progress": { "pagesTranslated": 98, "pagesTotal": 100, "ladderRung": "rest" },
  "activeJob": null,
  "backend": { "state": "ready", "detail": "A100-80GB" },
  "quote": null,
  "updatedUtc": "2026-07-16T09:44:12Z"
}
```
> Deliberately **not** the §6e shape: §6e's `state:"Processing"` + `blockedOn:"retry"` is for a book still
> *making progress* with a retryable failure. Here every remaining `Failed` page has exhausted
> `MaxProcessingAttempts` — retry would just refuse — so this reads like §6f (`phase:"done"`,
> `nextAction:"download"`), not like a blocked book. `title`/`detail` are the one place the two terminal
> "done" outcomes diverge: they name the gap so a reader isn't left silently wondering why the page count
> looks short. `quote` is `null` for the same reason as `Complete` — no batch is pending.

---

## 7. Findings & flags for the consuming PBIs

1. **The exhausted-retries terminal outcome is `ProjectStatus.CompletedWithErrors`, not `ProjectStatus.Failed`
   (PBI #1537, refining #2015 which was decided by the edge-case audit #2005)**
   ([§5](#5-current-state--how-the-four-signals-live-today)). `ProjectStatus.Failed` is once again declared
   but never assigned — reserved for a genuine book-level failure, distinct from "done, but some pages
   permanently failed." #2000's `retry` affordance still derives from *retryable* failed **pages**; a
   `CompletedWithErrors` book has failed pages too, but none retryable, so it correctly reads
   `nextAction:"download"` rather than offering a retry that would just refuse again.
2. **`waitingOn=credit` is reserved, not emitted.** The canonical mapping puts an out-of-credit book on
   `blockedOn=credit`. Keep `credit` in the `waitingOn` closed set for forward-compat but do not emit it;
   #2000's tests should assert `PausedNeedsCredit ⇒ blockedOn=credit, waitingOn=null`.
3. **`PausedNeedsCapacity` auto-advance keeps the status until the batch runs.** `BatchAutoAdvancer` flips
   only `PausedNeedsCredit → Processing` before enqueuing; a capacity-paused book is enqueued while still
   `PausedNeedsCapacity` and only moves when `RunBatchAsync` next settles it. The DTO for that instant is
   `state:"PausedNeedsCapacity"` with an `activeJob:Queued` — #2000 should treat that as `waitingOn=gpu_capacity`
   (the capacity pause), not `gpu_warmup`.
4. **`phase` ≠ `ProjectLifecycle`.** Don't reuse the dashboard bucket; `phase` is its own agent-facing
   taxonomy with an explicit `waiting`. (`ProjectLifecycle` folds capacity waits into `Processing`.)
5. **Overlay precedence for `Processing`.** When `state=Processing` with a `Queued` active job, the backend
   overlay decides `waitingOn`: `starting ⇒ gpu_warmup`, `throttled ⇒ gpu_capacity`, `ready ⇒ queue`. A
   `Running` job with a `ready` backend is plain `working` (no `waitingOn`).
6. **`ProjectStateMachine` lives in Core** (#2001) — a pure `(from, event) → to` table plus the four
   helpers in [§3](#3-the-legal-transition-table), no Infrastructure/RunPod types, consumed by both the
   pipeline and the API guards. Zero behavior change: the legal/illegal matrix must match this document
   row-for-row, table-tested.

---

## 8. Verification — verified, not assumed

Every claim above traces to code in this repo (paths relative to root):

| Claim | Proof |
|---|---|
| The 10 `ProjectStatus` values + their doc-comments | `src/BookTranslation.Core/Domain/Enums.cs:12-59` |
| `JobStatus` = `Queued/Running/Succeeded/Failed` | `src/BookTranslation.Core/Domain/ProcessingJob.cs:14-27` |
| `ModelBackendStatus.State` = `ready/starting/throttled/unconfigured` | `src/BookTranslation.Infrastructure/ModelBackendReadiness.cs:45-53` |
| `PageStatus` = `Pending/Processing/Approved/Failed` | `src/BookTranslation.Core/Domain/Enums.cs:64-77` |
| `processBatch` start guard set | `Pipeline/TranslationPipeline.cs:222-228` |
| Terminal write (Processing/Complete/CompletedWithErrors/Cancelled) | `Pipeline/TranslationPipeline.cs` `RunBatchAsync` `moreWork`/`hasExhaustedFailure` decision, both the packages-succeeded path and the whole-batch-failure path (PBI #1537, refines #2015) |
| Capacity pause → `PausedNeedsCapacity` | `Pipeline/TranslationPipeline.cs:407-418` (+ `ProcessOnePageAsync` catch `:609-622`) |
| Cancellation finalize (pipeline) | `Pipeline/TranslationPipeline.cs:437-450` |
| Auto-advance resumable set + credit pause/resume | `Jobs/BatchAutoAdvancer.cs:65-131` |
| `Cancelling → Cancelled` backstop | `Jobs/BatchAutoAdvancer.cs:65-71` |
| Whole-batch failure ⇒ `JobStatus.Failed`, book unchanged (unless that failure itself exhausted the last page, PBI #1537) | `Jobs/JobProcessor.cs` `RunOneAsync` |
| `/process` enqueue guard | `Api/Program.cs:1965-1967` |
| `/cancel` exclusion + `Cancelling`/`Cancelled` decision | `Api/Program.cs:2019-2052` |
| Worker auto-advance pre-filter | `Worker/Worker.cs:102-106` |
| `processable` set for the quote | `Infrastructure/Billing/BillingService.cs:660-663` |
| `ProjectStatus.CompletedWithErrors` assigned once no reachable work remains (PBI #1537); `ProjectStatus.Failed` reverted to never-assigned | `grep "Status = ProjectStatus.CompletedWithErrors" src` (excl. tests) ⇒ writes in `TranslationPipeline.cs` (`RunBatchAsync` settle, both paths + `RetryFailedPagesAsync` exhausted-gate); `grep "Status = ProjectStatus.Failed" src` (excl. tests) ⇒ zero writes; covered by `RetryFailedPagesTests`, `ProcessingAndExportTests`, `NotificationEmitTests` + `ProjectStateMachineTests` |
| Export gap note + completion notification for `CompletedWithErrors` (PBI #1537) | `Export/MultiFormatExportService.cs` `GapNote`; `Notifications/NotificationService.cs` `NotifyTranslationCompleteAsync`; `Jobs/JobProcessor.cs` `NotifyIfCompletedWithErrorsAsync` |
| `BatchQuote` expected/ceiling/balance fields | `Core/Abstractions/IBilling.cs:397-409` (`ExpectedUsd`, `EstimatedUsd`=ceiling, `BalanceUsd`) |
| Ladder `1 → 5 → 25 → 100 → rest` | `Core/Pipeline/BatchPolicy.cs:30` (`Ladder = {1,5,25,100}`), `NextBatchSize` `:56-62` |
| `ProjectLifecycle` folds capacity into Processing | `Core/Domain/ProjectSummary.cs:92-105` |

**Related decision records:** the no-human-gate pivot (`automatic-translation-no-human-gate.md`), the
capacity WAIT-never-boost policy (spike #1956 / `ocr-gpu-reliability-and-the-canary.md`, PBI #1998), the
expected/ceiling quote (#1997), and the model-readiness overlay (#1833). This record is the state-machine
authority those consume.
