Change lifecycle
This page follows one user goal from the browser to a release in three sequence diagrams. Then it shows the status state machines.
The path uses these stable IDs.
| Object | ID rule | Source |
|---|---|---|
| Change | chg_<uuid>. Idempotent on clientRequestId per requester. |
workers/api/src/projects.ts |
| ChangeWorkflow instance | The Change ID | workers/api/src/projects.ts |
| Task | tsk_<change>_<key>, where <change> is the Change ID without chg_ and <key> is t1, t2, … |
workers/integration/src/change-workflow.ts |
| Agent | agt_<task>: one logical Agent per Task |
workers/integration/src/dispatch.ts |
| Task run | run_<task>_<attempt> |
workers/agents/src/agent-do.ts |
| ChangeSet | cs_<run> |
workers/agents/src/agent-do.ts |
| Candidate | cand_ plus the first 24 hex characters of the SHA-256 composition key |
workers/integration/src/candidate-create.ts |
| Release | rel_<project>_<generation> |
workers/integration/src/release-model.ts |
Part 1: from goal to ready Tasks
Section titled “Part 1: from goal to ready Tasks”The API stores the Change and starts one ChangeWorkflow (request field startPlanning, default true).
The API returns before any model call.
A request of a member waits for review first (ADR 0028):
- While the Project setting
memberRequestReviewis on, the API stores the request asproposed. It starts no ChangeWorkflow. - When an owner or admin includes the request, the Change becomes
createdat the current Canonical Generation. - The integration code then starts the ChangeWorkflow with the same instance ID.
- Shared Projects describes the review.
The ChangeWorkflow runs the intake gate, calls the Root Planner, and compares the plan with the other open requests. Then it stores the Work Graph and dispatches the ready Tasks.
- An unclear, duplicate, or conflicting request waits for its requester (
needs_input). - A request that conflicts with an earlier request of another person waits for the choice of that person (
awaiting_choice, ADR 0032). - After “apply theirs”, the request is
queued. It plans again after the earlier request promotes or ends.
The intake gate reads the request as a change to the current app.
It reads the canonical file paths, the package.json name and description, the start of the README.md, and recent request titles.
It asks only when two readings build materially different products.
Each question has its own answer, with the readings as options and “Something else”.
Small details, such as colors, sizes, and defaults, go to the Root Planner as assumptions.
The intake gate asks at most one round. After an answer, or after “Build it as it is”, the request goes to planning.
Shared Projects describes the intake gate, the request comparison, and first-come conflicts (ADR 0025, ADR 0032).
sequenceDiagram
autonumber
actor User
participant API as API (core)
participant Root as ProjectRootDO
participant DB as Product database
participant CW as ChangeWorkflow
participant GW as Models (Anthropic, embed, rerank)
participant Shard as ResourceShardDO
participant WQ as agent-wakeups queue
User->>API: POST /v1/projects/:id/changes (through the web app)
API->>Root: getCanonical()
alt a member's request, review on
API->>DB: INSERT changes (status proposed)
API-->>User: 201 Change (proposed)
User->>API: POST /v1/changes/:id/include (owner or admin)
API->>DB: integration RPC includeChange: status created
API->>CW: integration CHANGE_WORKFLOW.create(id = changeId)
else owner or admin, or review off
API->>DB: INSERT changes (status created)
API->>CW: CHANGE_WORKFLOW.create(id = changeId)
API-->>User: 201 Change
end
CW->>DB: status compiling
CW->>GW: intake gate (embeddings, reranker, Clef-flash, Sonnet)
opt unclear or duplicate (at most one intake round)
CW->>DB: status needs_input, clarification
User->>API: POST /v1/changes/:id/clarify
API->>CW: integration RPC clarifyChange, event clarification
end
CW->>Root: admit()
CW->>GW: Root Planner, tool submit_work_graph
GW-->>CW: WorkGraphPlan (Zod and cycle check)
CW->>Root: registerScope(), then compare with each earlier request
opt supported conflict with an earlier request of another person
CW->>DB: request_choices row, status awaiting_choice
User->>API: POST /v1/changes/:id/choose (the earlier requester)
API->>CW: integration RPC chooseFirstCome, event first_come
CW->>GW: apply theirs or moot: Root Planner on the current tree
end
CW->>DB: tasks, task_dependencies, title, assumptions, status running (one batch)
CW->>Shard: change.compiled, task.created (outbox)
CW->>DB: pending to ready when dependencies are completed
CW->>Shard: task.ready (outbox)
CW->>WQ: one agent.wake per ready Task
Notes:
- The Root Planner routes to the
opustier (taskKind = plan,complexity = large). WithALLOW_OPUSset tofalse(the default), theopustier runs on Sonnet 5.5 (ADR 0021). - The plan has a short
titlefor the request, at most 6 words. The planner sees the goal with the clarification answers, so the title names the final request. parsePlancuts the title to 48 characters at a word boundary. A missing title never causes a repair turn.- The ChangeWorkflow writes the title to
changes.titlewith the Tasks. Without a title, the API showsfallbackTitle(goal)(packages/protocol/src/change-title.ts). - The plan has
assumptions: up to 8 small product details that the goal leaves open and that the planner decided, for example colors, sizes, or defaults. - The planner also gets the open details that the intake gate found.
parsePlandrops bad entries and cuts the list, so assumptions never cause a repair turn. - The ChangeWorkflow writes the assumptions to
changes.assumptions_json(0032_change_assumptions.sql). The Change page shows them as “Assumptions”. - To change an assumption, the requester sends a follow-up request.
- The prompt asks for 2 to 8 Tasks. The schema accepts 1 to 12 (
MAX_TASKS_PER_CHANGE). The planner makes at most two model calls: the plan and one repair turn. - A UI Task that calls routes of another Task must depend on that Task. The prompt states the rule.
parsePlan(workers/integration/src/root-planner.ts) also repairs the plan without a model call.- It adds the edge UI → API between a UI Task and an API Task on the same area when no dependency path joins them.
- The area is the first resource segment, for example
todosintodos:uiandtodos:api. - Every UI Task edits
public/index.html, and every API Task editssrc/app.ts. Two Tasks of one kind built in parallel on one base do not merge. - So
parsePlanalso runs Tasks of one kind in plan order when no dependency path joins them. The later key depends on the earlier key (serializeSameSurface). - A UI Task and an API Task still run in parallel.
- The Work Graph JSON goes to the object store at
work-graphs/<project>/<change>.json. - Change Events go to the ResourceShardDO that owns
change:<id>. This keeps ProjectRootDO cold. - The API writes the Change to ProjectViewDO directly.
- The intake gate records
change.intake.reviewedand anINTAKEDecision Receipt inchange_decisions. An answer recordschange.clarification.answered. - An
awaiting_choiceorqueuedChange waits for the eventfirst_come. It runsadvanceFirstComeafter each wake. - It also wakes every 6 hours while the choice is open, and every hour while it is queued. After 7 days without a choice, the Change is
not_selected. - A
needs_inputChange waits for the eventclarificationand reads the database once a day. After 7 days without an answer, the Change iscancelled. - The intake gate asks at most one round. The request comparison can ask more rounds. After 3 answered rounds in total, the Change plans without another question.
Part 2: from a wake to a published ChangeSet
Section titled “Part 2: from a wake to a published ChangeSet”Each wake starts one AgentDO run. The Agent works in its own Git fork and Sandbox directory. When the run ends, the Agent publishes a ChangeSet and declares a typed Intent. The ResourceShardDO then detects conflicts. Agents and models describes the run, model routing, and the Pi loop.
sequenceDiagram
autonumber
participant WQ as agent-wakeups queue
participant AC as Wake consumer
participant DB as Product database
participant A as AgentDO (Pi)
participant GW as Anthropic API
participant Art as git service
participant SBX as Sandbox
participant Shard as ResourceShardDO
participant ER as Event router
WQ->>AC: agent.wake (task_ready)
AC->>DB: admitAgent: ready to assigned (max 8 per Change)
AC->>Shard: task.assigned, agent.woken
AC->>A: start(input)
A->>DB: route tier, MODEL_ROUTE Decision Receipt, status running
A->>Shard: model.route.decided, task.started, advisory Claims
loop Pi tool loop (max 16 model calls on haiku, 32 on sonnet and opus)
A->>GW: model call
A->>Art: fork canonical as ws-{run} (first workspace tool call)
A->>SBX: read_file, edit_file, write_file, run_command in /workspace/{run suffix}
end
A->>SBX: git commit and push to ws-{run}
A->>DB: changesets (proposed), task completed
A->>Shard: changeset.proposed, intent.declared, task.completed
Shard-->>ER: event records from the outbox (JetStream)
ER->>DB: dedupe, find newly ready Tasks
ER-->>WQ: change.dispatch on the integration queue, which sends the next wakes
Notes:
- Only Task kinds
implement,migrate, andtestget a workspace. Other kinds get read-only file tools. - A Task with dependencies starts on a stacked workspace: canonical plus the dependency ChangeSets. See Agents and models.
- Conflict detection is deterministic and runs before
task.completed. - Conflict detection never compares two Tasks that a Work Graph path joins, in either direction and through any number of edges.
- The later Task of such a pair runs on a workspace that holds the work of the earlier Task, so the two are not rival designs.
- A later Task that breaks the earlier work fails the checks of the Candidate.
- A model review (Sonnet tier, then Opus tier) runs later from a durable schedule, off the run path.
- Conflict detection and conflict review read protected resources from the Project policy row (
project_policies,protectedResources). - A Project without a row, or with a row that does not parse, has no protected resources for them.
- When the last Task completes, AgentDO moves the Change to
verifyingand recordsverification.startedwith reasonwork_graph_completed.
Part 3: from Candidates to a release
Section titled “Part 3: from Candidates to a release”The causal router turns verification.started into one candidate.create command.
The integration code applies the integrate rule, then the fork rule.
It starts one CandidateVerificationWorkflow per Candidate.
Sibling Candidates on one base wait for each other. The last one to settle selects the winner (ADR 0026).
Policy or a person promotes the winner. ProjectRootDO runs the compare-and-swap.
sequenceDiagram
autonumber
actor User
participant ER as Event router
participant INT as Integration
participant VW as CandidateVerificationWorkflow
participant SBX as Sandbox
participant DB as Database and object store
participant Root as ProjectRootDO
participant Art as git service
participant RW as ReleaseWorkflow
ER->>INT: candidate.create (integration queue)
INT->>DB: fork rule, INSERT candidates (composing, max 3)
INT->>VW: create(id = candidateId)
VW->>Art: fork canonical as cand-{id}
VW->>SBX: merge ChangeSets in Work Graph order
VW->>DB: plan_json, status verifying, candidate.composed
loop each check in the Verification Plan
VW->>SBX: run check (or apps service, or acceptance model call)
VW->>DB: Evidence row, output object
end
VW->>DB: gate: verified or rejected
VW->>DB: last sibling: claim selection, rank, reject losers, candidate.selected
VW->>DB: approval.requested for a gated winner
User->>INT: POST /v1/changes/:id/approve (API RPC decideApproval)
User->>INT: POST /v1/candidates/:id/promote (API RPC promoteCandidate)
INT->>Root: promote(): compare-and-swap
Root->>Root: generation + 1 and canonical.advanced in one transaction
INT->>Art: point canonical main at the composed commit
INT->>RW: startRelease (only a Project with a live app)
RW->>RW: move the live app pointer, then smoke check
Notes:
-
The integrate rule runs before the fork rule.
-
A
confirmedorfork_requiredconflict between two Tasks on different semantic resources (an API and its UI) creates no Candidate. -
For such a conflict, the dependent Task gets a dependency edge. It runs again one time on a stacked workspace with the ChangeSet of the other Task.
-
The Change goes back to
running. One Candidate then holds both ChangeSets. -
Two Tasks of one Change that run in parallel on the same base can edit the same file. Their ChangeSets then do not merge.
-
The system then runs one Task again on top of the work of the other Task. Deterministic code decides this (
settle.tsandrebase.tsinworkers/integration/src):- A Candidate gets the rejection
file_conflicton the current base. The failed Task gets dependency edges to the other Tasks of that Candidate. It runs again on a stacked workspace with their ChangeSets, together with every Task built on it. The Change goes back torunning. - A Task fails with
dependency_merge_conflict, because two of its dependencies do not merge.task.failedqueueschange.settle. The dependency that did not merge gets edges to the dependencies before it. It runs again on their work, with every Task built on it. conflict.integratedrecords this re-run withtrigger: "dependency_merge_conflict". ATASK_RESTACKDecision Receipt records the decision.- A dependency ChangeSet does not merge with the canonical base, because another request promoted while this Change ran. That dependency and every Task built on it run again on the current Canonical State with the rebase instruction.
- The Change takes the current base generation.
candidate.rebase_requestedrecords this withtrigger: "dependency_merge_conflict". - A Change gets at most 2 of these re-runs (
MAX_TASK_RESTACKS). - The re-run Task gets the integrate instruction: build on the real routes, field names, and data shapes of the ChangeSet of the other Task.
- A Candidate gets the rejection
-
A Change with no Task left to run never stays
running. -
When a Task failed and no Task is ready, assigned, or running, the Change ends
failed.change.failedrecords it withphase: "tasks". -
blocked_reasonnames the Task and its reason, for example “The Task on canvas:activity failed: … Submit the request again.” -
Every 5 minutes, the event router also finds open Changes that are quiet for 3 minutes to 6 hours and cannot move on their own.
-
These are
runningChanges with no Task at work, andverifyingChanges with no live Candidate. The router queues onechange.settleper Change, at most 10 per sweep. -
The integration code checks the Change again. Then it runs the work again, dispatches the ready Tasks, or ends the Change with its reason.
-
candidate.createwaits while a conflict review that has a reservation from the last 10 minutes is open. The last queue delivery composes in all cases. -
A verified Candidate does not promote while a sibling on the same base composes or verifies.
-
A winner that does not meet every acceptance criterion never auto-promotes. Its Change goes to
awaiting_approval. See Source and verification. -
Auto-promotion applies when three conditions are true:
- The winner needs no human approval.
- The winner meets every acceptance criterion.
- The
autoPromoteRiskTiersof the Project policy lists the risk tier of the Change.
-
The policy is the
project_policiesrow. Without a row, the policy isDEFAULT_POLICY(policy-1inworkers/integration/src/candidate-composer.ts). -
DEFAULT_POLICYlistslowandmediuminautoPromoteRiskTiers, andhighandcriticalinhumanGateRiskTiers. -
For auto-promotion, the CandidateVerificationWorkflow calls
promoteCandidatewith actorsystem:auto-promotion.canonical.promotion.requestedrecordstrigger: "auto". -
Every promotion precondition applies (
autoPromoteDecisioninpackages/evidence/src/promotion.ts). -
These Candidates always wait for a person:
- a
highorcriticalChange; - an authorization change at any tier (the plan sets
humanApprovalRequired); - a winner with an acceptance shortfall;
- a Change without a risk tier, or with a tier that a stored policy does not list as automatic.
A destructive schema change (
destructiveMigration) gets the tiercriticalfrom the Root Planner, so it waits too. - a
-
A Candidate that waits for a person is never silent:
- The Change goes to
awaiting_approval. The status reason of the Candidate tells why. approval.requestedrecordsreasonandtrigger(policyoracceptance).- The inbox tells the owner, the admins, and the requester. The Change page shows the reason under Needs attention.
- An owner or admin approves the Candidate when approval is required.
- Then the owner or admin ships it with Ship on the Change page or Promote on the Candidate page. Both call
POST /v1/candidates/:id/promote.
- The Change goes to
-
The Change is
promotingduring the compare-and-swap. -
After the compare-and-swap, the Change is
releasingwhen the Project has a live app. Otherwise it iscompleted. -
The release ends the Change as
completed(deployedorsuperseded) orfailed. See Live apps. -
Every end after promotion records
change.completedorchange.failed. The projector mapsbuild.startedtoreleasing. These Events move ProjectViewDO out ofpromoting. -
A Change that is
releasing45 minutes after promotion is stuck (RELEASE_STUCK_AFTER_MS). -
Every 5 minutes, the event router reads
changesby(status, updated_at). It queues onerelease.recoverper stuck Change, at most 10 per sweep. -
The integration code then checks the Change again:
- A release that already finished settles the Change.
- A
queuedorbuildingrelease becomesfailed, and the Change becomesfailed. - A Project without a live app completes the Change.
- A Project with a live app but no release row gets a
failedrelease row, and the Change becomesfailed. The generation stays promoted. The next promotion releases as usual.
-
After
canonical.advanced, the causal router marks every othercomposing,verifying, orverifiedCandidate on an earlier basestale. -
The router queues one
candidates.recomposecommand. The integration code composes. -
Promotion runs the same sweep (
settleStaleBehindinworkers/integration/src/recompose.ts). -
The sweep takes each
verifiedCandidate on an earlier base. -
It also takes each
staleCandidate whose Change has no other active Candidate and none on a later base. The order of the two sweeps has no effect. -
Every stale Candidate ends in one of three states:
- a Candidate on the current Canonical State;
- a
blockedChange with a plain reason; - a closed Change.
-
A lineage recomposes at most 6 times (
MAX_RECOMPOSES). Each recomposition follows a promotion of another request, so a request that waits behind up to 6 others still lands. -
A Change never waits in
verifyingwith nothing running.
Status state machines
Section titled “Status state machines”The enums are in packages/protocol/src.
The diagrams show only the transitions that the code writes.
Change status
Section titled “Change status”stateDiagram-v2 [*] --> created: API insert [*] --> proposed: API insert, a member's request, review on proposed --> created: owner or admin includes it proposed --> declined: owner or admin declines it proposed --> cancelled: the requester withdraws it created --> compiling: ChangeWorkflow compiling --> needs_input: unclear, duplicate, or undecided conflict needs_input --> compiling: answer or proceed anyway needs_input --> cancelled: withdrawn, or no answer in 7 days compiling --> failed: planner failed compiling --> not_selected: the same requester asked again compiling --> awaiting_choice: conflict with an earlier request of another person awaiting_choice --> not_selected: keep mine, or no choice in 7 days awaiting_choice --> queued: apply theirs, earlier request in flight awaiting_choice --> compiling: earlier request ended (moot or apply theirs) queued --> compiling: earlier request promoted or ended awaiting_choice --> cancelled: withdrawn queued --> cancelled: withdrawn compiling --> running: Work Graph stored compiling --> budget_blocked: a budget refused a planner or intake call budget_blocked --> compiling: budget raised while planning running --> budget_blocked: budget or limit stop budget_blocked --> running: person raises the budget running --> verifying: last Task completed verifying --> running: Tasks re-run after a merge conflict or an integration verifying --> blocked: merge conflict after 4 re-runs, or stale Candidate not rebuilt blocked --> verifying: budget raise recomposes the stale Candidate verifying --> failed: no Candidate can ship verifying --> awaiting_approval: winner needs a person awaiting_approval --> verifying: no verified Candidate left verifying --> promoting: promotion requested awaiting_approval --> promoting: promotion requested promoting --> verifying: compare-and-swap refused promoting --> releasing: advanced, live app exists promoting --> completed: advanced, no live app releasing --> completed: deployed or superseded releasing --> failed: release failed or stuck
The pure rules are in workers/integration/src/change-status.ts.
| Rule | Source |
|---|---|
A person can cancel a Change in proposed, created, compiling, needs_input, awaiting_choice, queued, running, verifying, awaiting_approval, budget_blocked, or blocked. Nobody can cancel a Change with a promoted Candidate. |
CANCELLABLE_FROM in change-status.ts, CANCEL_FROM and cancelChange in change-commands.ts |
Only an owner or admin moves a proposed Change: include to created, decline to declined. Each update has a guard on proposed. A repeat returns the stored result. |
includeChange, declineChange in change-commands.ts |
A later conflicting request of the same requester makes the earlier Change not_selected from any of these statuses. This also applies from failed or completed, when the earlier Change has no promoted Candidate. See Shared Projects. |
request-check.ts |
Only the earlier requester chooses for an awaiting_choice Change. The system records expired after 7 days, or moot when the earlier request ends without a build. The later requester can withdraw the Change. advanceFirstCome applies each step with a guard on awaiting_choice or queued. See Shared Projects. |
firstComeStep, advanceFirstCome in first-come.ts |
A merge conflict makes the Change run the conflicting Tasks again: on the current base, or on top of the sibling work when the base stays the same. After 4 re-runs the Change is blocked with the reason “The work of this request did not merge after four re-runs. Submit it again.” |
MAX_REBASE_RERUNS in rebase.ts |
A running Change with a failed Task and no Task ready, assigned, or running ends as failed. blocked_reason names the Task and its reason. A verifying Change with no live Candidate, whose re-runs are used up, first gets its one repair Task when the newest Candidate failed a required check or the preview check. Otherwise it ends as failed with the reason of its newest Candidate: “No Candidate passed its required checks: …”, or “The agents’ work did not merge (…)” when the newest rejection is a merge. Both end with “Submit the request again.” |
settleChange in settle.ts |
The Change ends as failed when four conditions are true. Every Candidate on the latest base is rejected, and none for a cancel. The latest of them is not a merge conflict. A merge conflict waits for its re-run only until the Candidate of the re-run exists. No Candidate of the Change is composing, verifying, verified, or promoted. blocked_reason starts with “No Candidate passed its required checks:” and quotes the Candidate reasons. change.failed records it with phase: "verification". |
verificationDeadEnd |
A refused compare-and-swap returns the Change to its status before promoting. |
leavePromoting in promotion.ts |
A verified Candidate can also promote from running or budget_blocked. |
PROMOTABLE_FROM |
completed, cancelled, failed, not_selected, and declined are terminal. |
TERMINAL_CHANGE_STATUSES |
POST /v1/changes/:changeId/cancel:
- Cancels the open Tasks and rejects the open Candidates (reason
change cancelled). - A cancel during
compilingstores the planned Tasks ascancelledand records no compile. - A running CandidateVerificationWorkflow checks the Change at each step boundary. It stops before it uses more Sandbox or browser time.
- Dispatch runs only for a
runningChange. - An active Agent run stops at once.
cancelChangecallsAgentDO.stopIfCancelledfor each Task inassignedorrunning. stopIfCancelledaborts the model call in flight and releases the Claims and the Sandbox. When that call fails, the run stops at its next run check (every 60 s).- A cancelled Task stays
cancelled. Its run ends with no retry, no escalation, and notask.failedEvent.
POST /v1/changes/:changeId/budget raises budget_usd. The budget cannot go down.
- A
budget_blockedChange goes back torunning. - A Change that a budget stopped while planning has no Tasks. It goes back to
compiling, and a second ChangeWorkflow instance plans it again. compilingandrunningare the only statuses that a budget stop interrupts.- The budget-stopped Tasks become
readyagain with a fresh attempt window. Their blocked dependents becomepending. - The normal dispatch runs.
- On a
verifying,blocked, orawaiting_approvalChange, the same call retries a merge-conflict re-run within its bound. It also recomposes a stale Candidate.
A raise of the Project monthly budget (PATCH /v1/projects/:projectId) or of a user monthly budget (PATCH /v1/me/budget) runs the same restore for each Change that budget stopped (resumeBudgetBlocked in change-commands.ts, ADR 0030).
Task status
Section titled “Task status”stateDiagram-v2 [*] --> pending: ChangeWorkflow insert pending --> ready: dependencies completed pending --> blocked: a dependency failed or stopped ready --> assigned: admitAgent (slot free) ready --> ready: deferred at 8 active Agents ready --> blocked: wake dead-lettered assigned --> blocked: wake dead-lettered assigned --> running: AgentDO.start running --> running: retry or escalate running --> completed: complete_task and publish running --> failed: terminal failure running --> blocked: budget or limit stop blocked --> ready: dead letter retried, or budget raised completed --> pending: re-run after a merge conflict or an integration
A Change cancel moves the open Tasks (pending, ready, assigned, running, blocked) to cancelled.
A merge-conflict re-run sets a Task to ready directly when none of its dependencies re-runs.
Candidate status
Section titled “Candidate status”stateDiagram-v2 [*] --> composing: candidate row inserted composing --> verifying: composed, plan recorded composing --> rejected: incompatible, merge conflict, or Sandbox budget reached verifying --> verified: every required check passes verifying --> rejected: a required check fails or has a gap verified --> rejected: approver rejects, or a sibling wins the selection verified --> promoted: compare-and-swap succeeds composing --> stale: canonical advanced verifying --> stale: canonical advanced verified --> stale: canonical advanced stale --> rejected: Change cancelled, or its Tasks re-run promoted --> [*] rejected --> [*]
composed is in the enum and in RPC results. Database rows go from composing directly to verifying.
Rhumbatron never retries a stale Candidate blind. A second Candidate on the current base takes its place.
Release status
Section titled “Release status”stateDiagram-v2 [*] --> queued: promotion with a live app queued --> building: generation still canonical queued --> superseded: later generation promoted queued --> failed: not canonical, no app, or stuck building --> superseded: later generation promoted building --> failed: no bundle, or the pointer did not move building --> deployed: smoke check passed building --> smoke_failed: smoke check failed deployed --> smoke_failed: failure injection failed --> [*] smoke_failed --> [*] superseded --> [*]
The allowed transitions are in NEXT (workers/integration/src/release-model.ts).
A failed release never deletes canonical history.
Recovery is one more canonical transition.