ADR 0013: Split promotion preconditions between the integration module and ProjectRootDO
Context
Section titled “Context”Promotion is one atomic S<n> -> S<n+1> transition. Eight preconditions must be true before it:
- The Candidate status is
verified. - The required Evidence exists.
- The required Evidence has the pass state.
- The required human approval exists.
- The policy version is valid.
- The current Canonical Generation equals the expected generation.
- The current canonical tree equals the expected tree, when the tree check is on.
- The Candidate fork still holds the composed commit.
Most of these preconditions read data that ProjectRootDO does not own. Candidate status, the Verification Plan, Evidence, and approvals are rows in the product database. The Candidate commit is in a fork in the git service. ProjectRootDO owns only the Canonical Generation, tree, ref, and active policy version. An actor cannot read the product database atomically with its own SQLite state. Such reads also make the most important correctness boundary depend on a second database.
Decision
Section titled “Decision”Rhumbatron checks the preconditions for POST /v1/candidates/:candidateId/promote in two steps,
in this order.
-
Integration module (
promoteCandidateRPC). The API authenticates the user, checks that the user is a Project owner or admin (ADR 0028), and calls the integration module through the in-processINTEGRATIONbinding. The integration module has no public route. It reads the authoritative rows and checks them with the pure functioncheckPromotion(packages/evidence/src/promotion.ts):- rule 1: the Candidate status is
verified, and its base is the current generation; - rules 2 and 3:
gateRequiredChecksgates the required checks again from theevidence_indexrows. It never trusts the cachedscore_jsongate. Onlychecked_supportedpluspasssatisfies a check; - rule 4: when the plan or the policy risk tiers require it, a
grantedapproval exists for the exact composed tree. Arejectedapproval always blocks; - rule 5 (early): the policy version of the plan equals the active version;
- rule 8: the composed commit is in the Candidate fork with the recorded tree;
- selection: no sibling Candidate is still composing or verifying (
selection_pending, ADR 0026).
checkPromotionreturns every block at once. Then the module builds and Zod-validates aPromotionRequestand recordscanonical.promotion.requested. - rule 1: the Candidate status is
-
ProjectRootDO (
promote). It decides on its own state in one SQLite transaction. It checks rule 5 (authoritative policy version), rule 6 (expected generation = current), and rule 7 (expected tree = current, when the Candidate recorded itsbase_tree). It writes the next generation and thecanonical.advancedoutbox row together. The Event recordsrequiredEvidenceIdsandapprovalIdfor audit.
The expected generation is always the base_generation of the Candidate. The browser cannot
choose it. The idempotency key is the Candidate ID plus the expected generation. A
retry returns alreadyPromoted. A generation or tree mismatch marks the Candidate stale and
records candidate.stale. Rhumbatron does not retry a stale promotion blind.
Approvals
Section titled “Approvals”POST /v1/changes/:changeId/approve records one decision per Candidate in approvals
(migration 0008). The approver is the authenticated Project owner or admin from the session. The
request body never names the approver. Only a verified Candidate that needs approval can get a
decision. The first decision is final. An identical repeat returns the stored row. A rejection
also sets the Candidate to rejected. The policy is deterministic (approvalRequired, checkApprovalDecision). No model
takes part. The integration module records approval.requested for a verified Candidate that
needs approval. That Candidate has no siblings, or it is the winner of a selection (ADR 0026).
The Event has reason (one sentence, also the Candidate status reason, from approvalReason or
policyHoldReason) and trigger (policy or acceptance). approval.granted or
approval.rejected is recorded with the decision.
Canonical source after promotion
Section titled “Canonical source after promotion”ProjectRootDO is the authority, so the canonical repository in the git service moves after the
compare-and-swap. The integration module fetches the Candidate fork with isomorphic-git into memory.
It pushes the composed commit to main of the canonical repository as a fast-forward only (no
force push; receive-pack also checks the expected value). Then it writes
source/<project>/<tree>/index.json. Agents and the verification workflow use that index to find
the canonical commit, so the integration module writes it only after main moves. The canonical
ref is artifacts://<project>@<commit>.
If the sync fails, the generation stays advanced, and the response has sourceSynced: false.
To repair it, promote again. The retry is alreadyPromoted and runs the sync again. Until the sync
succeeds, the first step of a Candidate verification waits (bounded retries). It does not compose
on a commit that main does not have. The sync uses no Sandbox time.
Stale Candidates after promotion
Section titled “Stale Candidates after promotion”- On
canonical.advanced, the Causal Router marks every other in-flight Candidate of the Project on an older base asstale(one database batch, deterministic). It marks the promoted Candidatepromoted. This step repairs the database copy when the integration module stops between the compare-and-swap and its own write. A Change inawaiting_approvalwith no verified Candidate goes back toverifying. - The Causal Router then sends
candidates.recomposeon theintegrationqueue. The promotion path also runs the same settle step (settleStaleBehind). Each stale Candidate of an open Change gets a recomposed Candidate on the current generation, a blocked status, or nothing when its Change is closed (ADR 0020, “Stale recomposition”). The order of the two paths does not matter. - The Causal Router wakes the agents of active Tasks on
canonical.advanced.
Consequences
Section titled “Consequences”- The actor trusts its caller for rules 1 to 4 and 8. Only code with a
PROJECT_ROOTbinding can call it, and only the integration module callspromote. ProjectRootDO has no public route. - A precondition can change between the check and the compare-and-swap (for example, an added
Evidence row). Evidence IDs are stable per check. Only the verification workflow of a Candidate
writes its Evidence, and that workflow is finished when the Candidate is
verified. - The API code gets the
INTEGRATIONbinding from the core service (services/core/src/main.ts). - The approver can also be the author of the Change. Only the Project owner and admins can approve (ADR 0028).
- The rules in ProjectRootDO are the pure function
decidePromotion(workers/coordinator/src/promotion-cas.ts). The order is: already promoted, generation, tree, then policy version. - After the canonical
mainsync succeeds, promotion startsReleaseWorkflowfor a Project that has a live app (ADR 0020). A release never fails or reverses a promotion. - Auto-promotion: after the gate and the selection, the verification workflow calls the pure
function
autoPromoteDecision(autoPromoteRiskTiers). Then it callspromoteCandidatewith triggerautoand actorsystem:auto-promotion. This is the same path, with the same preconditions, as a manual promotion. The default policypolicy-1listslowandmediumas automatic and gateshighandcritical. A plan gate (humanApprovalRequired, for example an authorization change) or a gated policy tier is never automatic. An unknown risk tier is never automatic. An acceptance shortfall is never automatic (ADR 0026). - A Candidate that does not promote automatically waits for a person.
holdForPersonadds the plan gate when the plan has none. The Change isawaiting_approval, andapproval.requestedstates the reason. The inbox tells the owner, the admins, and the requester (approval_requiredorpromotion_ready). The Change page has the Ship action in the header and under Needs attention. Ship calls the samePOST /v1/candidates/:candidateId/promote, so every precondition above applies. - Promotion sets the Change status to
promoting, then toreleasingorcompleted.
Diagram
Section titled “Diagram”Preconditions in the integration module (checkPromotion).
flowchart TD
S["POST /v1/candidates/:id/promote"] --> M["API: session and<br/>Project owner or admin"]
M --> R["integration.promoteCandidate"]
R --> C1{"1. status verified,<br/>base = canonical generation?"}
C1 -- no --> X["stale or blocked"]
C1 -- yes --> C2{"2-3. required checks pass<br/>from evidence_index rows?"}
C2 -- no --> X
C2 -- yes --> C4{"4. approval granted<br/>when required?"}
C4 -- no --> X
C4 -- yes --> C5{"5. plan policy version<br/>= active version?"}
C5 -- no --> X
C5 -- yes --> C8{"8. composed commit<br/>in Candidate fork?"}
C8 -- no --> X
C8 -- yes --> SP{"No sibling still<br/>composing or verifying?"}
SP -- no --> X
SP -- yes --> E["Record canonical.promotion.requested,<br/>call ProjectRootDO.promote"]
Compare-and-swap and source sync.
sequenceDiagram
participant I as Integration module
participant P as ProjectRootDO
participant A as Git service
participant W as ReleaseWorkflow
I->>P: promote(expectedGeneration = base_generation, expectedTree, policyVersion)
alt already promoted (same Candidate, generation + 1)
P-->>I: ok, alreadyPromoted
else generation or tree mismatch
P-->>I: stale
I->>I: mark Candidate stale, record candidate.stale
else policy mismatch
P-->>I: policy_mismatch (blocked)
else match
P->>P: one transaction, S(n) to S(n+1) plus canonical.advanced outbox row
P-->>I: ok, generation n+1
end
opt ok (advanced or alreadyPromoted)
I->>A: fast-forward canonical main to the composed commit
I->>I: write the source index, settle stale Candidates, return sourceSynced
I->>W: start rel_project_generation (synced, live app exists)
end