Skip to content

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

QuestionAnswerWhere
How many book states are there?10 ProjectStatus values, each with exactly one kind (intake / working / waiting / error / terminal).§2
Is the transition set already fixed in code?Yes — enforced ad-hoc across five call sites. §3 makes it explicit + complete; #2001 makes it the sole authority.§3
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) folds all four into one described object.§4
Waiting vs blocked — the whole pointPausedNeedsCapacity = 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, §6
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.CompletedWithErrorsphase:"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, §7
Closed taxonomies (machine-stable)?Four: phase, waitingOn, blockedOn, nextAction. Enumerated in §4.2.§4.2

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:

SignalTypeValues
ProjectStatusbookCreated, Calibrating, Processing, PausedNeedsCredit, PausedNeedsCapacity, Complete, CompletedWithErrors, Failed, Cancelling, Cancelled
JobStatusbatch jobQueued, Running, Succeeded, Failed
ModelBackendStatus.StateGPU overlay (#1833)ready, starting, throttled, unconfigured
PageStatuspagePending, 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.

StateKindTerminal?MeaningHow it is enteredHow it leaves
CreatedintakenoProject row exists; no pages produced yet.CreateProjectAsync (∅ → Created).normalizeCalibrating.
CalibratingintakenoPages 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.processBatchProcessing | Complete | PausedNeedsCapacity | Cancelled; cancelCancelling | Cancelled.
ProcessingworkingnoA 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; cancelCancelling | Cancelled.
PausedNeedsCapacitywaitingnoCheap 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 RunPodCapacityExceptionPauseForCapacityAsync.autoAdvance/processBatch retries on the next Worker poll → Processing/Complete, or re-pauses here; cancelCancelling | Cancelled.
PausedNeedsCreditwaitingnoNext 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); cancelCancelling | Cancelled.
CompleteterminalyesEvery 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).
CompletedWithErrorsterminalyesAssigned 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. 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).
FailederroryesDeclared 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)
Cancellingworkingno (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.
CancelledterminalyesOwner-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.


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 \ EventnormalizeprocessBatchretryautoAdvancecancelfinalizeCancel
CreatedCalibratingCancelled
CalibratingProcessing | Complete | CompletedWithErrors | PausedNeedsCapacity | Cancelled→ (same targets as processBatch)Cancelling|Cancelled
ProcessingProcessing | Complete | CompletedWithErrors | PausedNeedsCapacity | Cancelled→ (same)PausedNeedsCredit (unaffordable) / enqueueCancelling|Cancelled
PausedNeedsCapacityProcessing | Complete | CompletedWithErrors | PausedNeedsCapacity | Cancelled→ (same)enqueue (status unchanged until the batch runs)Cancelling|Cancelled
PausedNeedsCreditProcessing | Complete | CompletedWithErrors | PausedNeedsCapacity | Cancelled→ (same)Processing (affordable, then enqueue) / stayCancelling|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
CancellingCancelled (finalizes, no work)Cancelled (finalizes)Cancelled (finalizes)no-op (idempotent 200)Cancelled
CancelledCancelled (no-op)Cancelled (no-op)no-op✗ 409no-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):

EventGuard / call siteLegal-from set enforced today
processBatch start guardTranslationPipeline.ProcessNextBatchAsync (Pipeline/TranslationPipeline.cs:222)Calibrating, Processing, PausedNeedsCredit, PausedNeedsCapacity
processBatch enqueue guardProgram.cs POST /projects/{id}/process (:1965)Calibrating, Processing, PausedNeedsCredit, PausedNeedsCapacity
autoAdvance resumable setBatchAutoAdvancer.TryAdvanceAsync (Jobs/BatchAutoAdvancer.cs:79)Processing, PausedNeedsCredit, PausedNeedsCapacity (+ Cancelling→finalize)
autoAdvance pre-filterWorker.AdvanceReadyProjectsAsync (Worker.cs:102)Processing, PausedNeedsCredit, PausedNeedsCapacity
processable set (quote)BillingService.QuoteNextBatchAsync (Billing/BillingService.cs:660)Calibrating, Processing, PausedNeedsCredit, PausedNeedsCapacity
cancel exclusion guardProgram.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

FieldTypeMachine-stable?SourceNotes
projectIdGUIDproject
stateenum ProjectStatusyesprojectThe exact state-machine value ("PausedNeedsCapacity"), stable forever.
phaseenum (§4.2)yesderivedCoarse bucket for a glance; overlay-aware (a Calibrating/Processing book with an active job reads working).
titlestringnoderivedShort human headline ("Waiting for GPU capacity"). Display only.
detailstringnoderivedOne-sentence explanation. Display only.
isWaitingboolyesderivedTrue when the book will resume on its own (no user action).
waitingOnenum|null (§4.2)yesderivedWhy it is waiting; null unless isWaiting.
isBlockedboolyesderivedTrue when it needs a user action to proceed.
blockedOnenum|null (§4.2)yesderivedWhat the user must resolve; null unless isBlocked.
resumesAutomaticallyboolyesderivedTrue for waiting states; the agent should poll, not prompt. Mutually consistent with isWaiting.
nextActionenum|null (§4.2)yesderivedThe single sensible next move (add_credit/retry/download), or null when the right move is "wait".
progressobjectproject{ pagesTranslated, pagesTotal, ladderRung }. pagesTranslated = included Approved; pagesTotal = included pages; ladderRung = the current/next batch width ("1","5","25","100","rest").
activeJobobject|nulllatest job{ id, status } for the newest ProcessNextBatch/RetryFailedPages job that is Queued/Running; else null.
backendobjectIModelBackendReadiness{ state, detail, warmupStage } from ModelBackendStatus (ready/starting/throttled/unconfigured). The GPU overlay. warmupStage (#2149) is the coarse cold-start progress while state is startinginitializingloading_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).
quoteobject|nullBatchQuote{ 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).
updatedUtcUTC ISO-8601projectLast 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.

TaxonomyValuesMeaning of each
phaseintake | working | waiting | done | error | cancelledCoarse lifecycle. intake=setting up; working=progressing; waiting=paused, self-/top-up-resuming; done=Complete; error=Failed; cancelled=Cancelling/Cancelled.
waitingOnnull | gpu_capacity | gpu_warmup | queue | creditWhy 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.
blockedOnnull | credit | retryWhat 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).
nextActionnull | add_credit | retry | downloadThe one move to surface. null=wait (the agent should poll). add_creditblockedOn=credit; download=Complete; retry=failed pages present.

⚠️ Waiting is not blocked. waitingOn != nullisWaiting=true, resumesAutomatically=true, nextAction=null — the agent waits. blockedOn != nullisBlocked=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 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 — QueuedRunningSucceeded/ 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 (startinggpu_warmup, throttledgpu_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), "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). 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. phaseProjectLifecycle. 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, 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):

ClaimProof
The 10 ProjectStatus values + their doc-commentssrc/BookTranslation.Core/Domain/Enums.cs:12-59
JobStatus = Queued/Running/Succeeded/Failedsrc/BookTranslation.Core/Domain/ProcessingJob.cs:14-27
ModelBackendStatus.State = ready/starting/throttled/unconfiguredsrc/BookTranslation.Infrastructure/ModelBackendReadiness.cs:45-53
PageStatus = Pending/Processing/Approved/Failedsrc/BookTranslation.Core/Domain/Enums.cs:64-77
processBatch start guard setPipeline/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 → PausedNeedsCapacityPipeline/TranslationPipeline.cs:407-418 (+ ProcessOnePageAsync catch :609-622)
Cancellation finalize (pipeline)Pipeline/TranslationPipeline.cs:437-450
Auto-advance resumable set + credit pause/resumeJobs/BatchAutoAdvancer.cs:65-131
Cancelling → Cancelled backstopJobs/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 guardApi/Program.cs:1965-1967
/cancel exclusion + Cancelling/Cancelled decisionApi/Program.cs:2019-2052
Worker auto-advance pre-filterWorker/Worker.cs:102-106
processable set for the quoteInfrastructure/Billing/BillingService.cs:660-663
ProjectStatus.CompletedWithErrors assigned once no reachable work remains (PBI #1537); ProjectStatus.Failed reverted to never-assignedgrep "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 fieldsCore/Abstractions/IBilling.cs:397-409 (ExpectedUsd, EstimatedUsd=ceiling, BalanceUsd)
Ladder 1 → 5 → 25 → 100 → restCore/Pipeline/BatchPolicy.cs:30 (Ladder = {1,5,25,100}), NextBatchSize :56-62
ProjectLifecycle folds capacity into ProcessingCore/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.