Deployment runs
Run execution, composition, and carried-work authority. Related contracts: timestamps, JSON, event records, and preview/ephemeral records.
Table: deployment_runs
One row per pipeline execution. Stage authority lives on the run, not on individual items. A run may be item-bound through deployment_run_items, or item-less for environment-level deploys such as Yoke prod/stage redeploys.
id TEXT PRIMARY KEY -- human-readable slug (e.g., 'run-20260315-001')
project_id INTEGER NOT NULL REFERENCES projects(id)
flow TEXT NOT NULL REFERENCES deployment_flows(id)
target_tier TEXT -- persistent | ephemeral | NULL (merge-only)
target_environment_id INTEGER -- internal REFERENCES environments(id); required exactly when target_tier='persistent'
release_lineage TEXT -- links preview->prod runs (shared lineage ID)
status TEXT NOT NULL DEFAULT 'created' -- created|executing|succeeded|failed|cancelled
current_stage TEXT -- stage authority lives here, not on items
current_stage_entered_at TEXT -- clock set when the stage changes; NULL means unknown age, no backfill
created_at TEXT NOT NULL -- app-supplied ISO-8601 UTC; see database migration timestamp discipline
started_at TEXT -- when execution actually began
completed_at TEXT
created_by TEXT -- 'operator' or 'system'
carried_work TEXT -- → JSONB on Postgres; resolved items, release output, and unresolved commit SHAs with subjects
artifact_identity TEXT -- optional immutable build artifact identity, distinct from release_lineage
composition_resolution TEXT -- explicit first-baseline attribution resolution
composition_frozen_at TEXT -- immutable admission-freeze timestamp
requirement_snapshot TEXT -- full flow-level QA plan/case content frozen at start
A run copies the internal target_tier and target_environment_id from its flow. Operators select or override a persistent target only with --environment <registered-name>; numeric keys are never accepted or emitted by the operator surface. Setting status=succeeded stamps environments.last_deployed_at on the referenced row.
Succeeded completion and composition freeze always record carried_work. A failed or cancelled transition (operator terminalization included) derives nothing, because it holds the run row and the derivation reads the repository host; a run that froze its composition already carries its record. A Shipping or Runs read that finds a terminal run unrecorded derives it and records it only when the answer is permanent: derived, or unknown for a reason that is a fact about the run's own record (current_release_lineage_missing, prior_release_lineage_missing, release_lineages_diverged, or a lineage a fully refreshed source cannot resolve). A failure the environment caused — fetch, provider, network, credential, missing checkout, or an ancestry read git could not complete (checkout_ancestry_unreadable, never reported as divergence) — returns its named unknown answer unrecorded and is derived again on a later read. A read may therefore write: deployment_runs.list and the Runs page commit that one derived field on their own connection, so any actor allowed to read a run can cause its carried work to be recorded; they never write membership, lifecycle, or gate state. Unrecorded answers (unfinished runs and transient failures) are cached per authority/run for 30 seconds with a single-flight guard so concurrent cold reads derive a run once. Their Carries lists and counts include bound projects; truly empty runs remain environment runs. Completion also compares this run's immutable release_lineage with the previous succeeded run for the same project and target environment. The resulting carried_work object keeps item matches under items, commits a release's own automation wrote under release_output, and commits made outside Yoke as SHAs under commits, subjects under commit_subjects, and authors under commit_authors, within each carried project. These commits never block creation, composition, start, or settlement; run pages, Shipping cards, Runs tables and release approvals offer an expandable "Also includes N commits made outside Yoke" list. Item matches come only from records binding an exact commit to its item — the item's merge receipt first, then QA and execution evidence and lane metadata; a commit message naming an item is never ownership evidence. Lane SHAs remain exact evidence; branch lookups use only the release landing window, with unresolved refs reported as lane_branch_refs_unresolvable. A merge receipt records every commit its landing contributed (contributed_commits): the item's own first-parent line from the landed commit down to what the target already held, taken before the merge and, where the landing produced a merge commit, from that merge's other parent. A fast forward therefore credits each of the item's commits, and a branch sync's second-parent commits stay with whoever authored them. An item whose receipt predates that record, or whose landing left nothing to derive it from, may optionally tie its other commits to the item: merge_receipt.commits.attest (CLI: yoke merge-receipt commits attest PREFIX-N --commit SHA [--commit SHA ...] --reason R) records full SHAs on its newest landed receipt entry under attested_commits, beside the derived set, with the reason given. Recording it changes no lifecycle state; historical runs remain unset and recording is forward-only. The record is evidence, not membership — enrolling from it is a separate step, described under release admission below.
Release-generated commits: a version-pin promotion can add an itemless commit to the next release's range. Its producing run records it through deployment_runs.release_output.record (CLI: yoke deployment-runs release-output record RUN-ID --project P [--commit REF]), stored per project inside that run's own bound_sources — and attribution reads that record instead of guessing from an author name or a file path. Hosted bridge callers pass --commit SHA --promotion-receipt-file PATH with the exact successful attempt artifact; the server rechecks repository, run, environment, product, immutable digest and bound-candidate ancestry. Its promotion_receipts retain deployed identity even when no commit was produced; missing identity refuses hosted QA, while generic callers keep their existing output/binding behavior. Item attribution is tried first and always wins; the record is consulted only for a commit no item claimed, and each release_output entry names the producing run_id and the recorded reason. Recorded release output needs no composition_resolution; unattributed real-code commits still refuse.
Completion compares through the registered checkout when available, otherwise through the project's authorized repository binding. derivation.source identifies the answer: checkout, repository_provider, or none.
An empty release and an unanswerable comparison are different facts.
derivation.contents_known is true when the answer is determinate: a comparison that ran, or a first run with no predecessor to compare against, which carries nothing and owes nothing — reporting that baseline as unknown would strand a project behind a blocker no repair could clear. derivation.status is derived from it: derived when commits were attributed, empty when the run genuinely carries nothing, and unknown when a comparison was owed and nobody could look. derivation.reason names the case and derivation.recovery names the
repair. Readers must not treat unknown as empty: an unknown record
suppresses both automatic enrollment and the omitted-delivery-ready-member scan
at composition freeze, because a set nobody computed can neither be admitted
nor cleared. Attribution is repaired, never guessed.
A record is written once and then frozen, which is right for a comparison that ran
and wrong for one that could not. deployment_runs.carried_work.repair (CLI: yoke deployment-runs carried-work repair RUN-ID) replaces exactly that case: it refuses
a derived record, refuses a second unanswerable derivation, and names why the retry
still failed.
Completion gates ask whether the deployed candidate contains an item's recorded merge, not whether it equals it — a batch has one tip, so equality could only ever complete a single-item release. Two questions in order: ancestry, read from the candidate's own side so a long history still costs one small page, then content, since work that reached the base under other commit ids adds nothing while failing every ancestry test (the merge boundary's own "this lane adds nothing", asked of the same source). Both run against every source the host offers; an unreadable one names each.
Definition-schema-v2 runs freeze admission before execution. The candidate
release_lineage must be a full commit SHA; every nonterminal delivery-ready
change carried by that candidate must be a member, while done history,
merge-only flows, Task items, and Epic task graphs are not admitted implicitly.
The run records the shared artifact identity, an immutable composition digest,
the effective flow for every member, and full recoverable QA requirement/plan
content. Missing first-baseline attribution must be resolved explicitly before
start. Cancellation preserves the frozen evidence. Schema-v1 runs retain their
legacy start behavior.
QA acceptance, target occupancy, takeover selection and audited member removal
read complete frozen plan/case selections, never a later standing publication.
Admission freezes attached post-deploy plans and plans named by admitted
requirements. A newly selected plan freezes its complete definition before
its first executable requirements, in the same transaction. Explicit
rematerialization owns authorized updates; withdrawals and replacements retain
their usual effect. Candidate ancestry checks still apply to frozen sources.
An admitted or already materialized selection missing its complete frozen
contract refuses as qa_frozen_plan_snapshot_incomplete. Preserve the run and
ask the control-plane operator to prepare a reviewed repair from original
admission evidence through operator.data_repair.run before resuming an active
run. Do not import current cases as historical acceptance. Historical requirement
and result records remain viewable without reconstructing an executable plan;
terminal history requires no rewrite. Missing original descriptive metadata
must be recorded truthfully; method configuration contracts, effective policies,
source/target identity, case order and baseline variants require original proof.
Composition completes candidate membership. Run creation and item-bound composition (deployment_runs.start_for_item, deployment_runs.validate_composition) enroll every delivery-ready item the pinned candidate carries and the run does not hold, through the same admission validation add_item uses. Creation validates provisionally in its insertion transaction and rolls back on refusal. It runs that validation twice in one read scope: first before the creation lock, on a provisional run-unlocked-* row that is always rolled back and whose verdict decides nothing, so the repository reads (bound branch heads, commit comparisons, containment) happen outside the lock; then under the lock on the real row, re-checking database facts against those same reads and re-deriving any comparison whose preceding release changed meanwhile. deployment_runs.execution.context revalidates before dispatch. Its driver reuses that context once; start diagnostics explain containment-only refresh, batched reads, and flushed timing records.
Before a stage first consumes a bound project, deployment_runs.execution.bound_sources_prepare may replace a stale pin with the declared branch's current head. It records rebindings (from/to commit, stage and time), re-derives carried work and admits newly carried members through existing custody, dependency and target rules. Recorded carried-work facts for the carrier and unchanged bound projects stay fixed. Existing requirement snapshots remain fixed; new members get snapshots. Containment is refreshed against the candidate. The source's consumed_by and consumed_at are persisted before dispatch; retries copy them, and a moved consumed source still requires a new run. Source walks happen before item/run locks; changed preparation state refuses a same-run retry. Missing or unreadable evidence never authorizes dispatch.
Creation pins bound sources and checks every shipped project; start revalidates the same composition before dispatch. Independent admission, flow, and attribution blockers are returned together with their identities. A stored record missing a recorded bound project refuses with cancel-and-recreate recovery. That candidate set is two sources unioned before any lock is taken. The first is carried_work above — what this run adds over the run before it. The second (deployment_run_unheld_candidates.unheld_candidate_ids) exists because the first is a commit range and so has a floor at the previous succeeded release: a landing behind that floor is outside every later range permanently, so a cancelled or superseded run can leave members behind that floor; the second source recovers those unheld landings. The second source walks the run's carried projects for items that are merged, still open, delivery-ready under their pinned workflow, whose newest landing this run's own pinned lineage contains, and which no release holds; only definite containment enrolls; not_contained waits for a later candidate, while undetermined refuses with the item, reason, and recovery so unknown attribution cannot silently omit a member. Custody there is asked of a landing, not of an item (delivery_landing_custody.landing_custody): an item may land several times and those landings may straddle releases, so a run holds a landing only when the item is one of that run's members and the run's pinned lineage contains that landing's commit and the run is created, executing, or succeeded. Membership alone over-answers — a membership predating a newer merge is not custody of it — and containment alone over-answers in the other direction, since every release cut after a merge contains it including releases that never named the item. The four states are held, remerged (member runs exist, none carries this landing), unheld, and undetermined; the first and last enrol nothing. The landing's identity is item_merge_identity — the merge commit the item's newest receipt names — so custody compares the same identity the close-out gate does, while merged_at and merge_queue_landed_at decide only whether an item landed at all, never which landing. Enrollment takes the item workflow-binding locks and then the run row, the order lock_run_with_stable_membership and add_item already use, and locks every carried item rather than only the currently eligible ones, so eligibility cannot move between the decision and the insert; a run that is no longer created enrolls nothing. Custody is resolved once, before any of those locks, and handed to every reader (deployment_run_unheld_candidates.resolve_candidate_custody, returning a CustodyResolution): enrollment, the carried-membership refusal, the unclosable-final-member refusal and the skipped-candidate notice all need the same answer, so one resolution avoids repeated network containment reads while holding the run row lock. Containment is answered per project, not per item (deployment_run_candidate_range.candidate_range_commits): one first-parent comparison from the previous succeeded release to the candidate lists every landing the candidate added, and a landing on that line is contained without its own repository read; only a landing outside the range (older than the previous release, or carried under another commit id) asks the source item by item. A holder run pinned to the same lineage reuses that answer, and an unreadable range falls back to the per-item question rather than refusing. Because custody is asked with exclude_run_id set to this run, enrolling its own members cannot change the answer, so one resolution stays valid for the whole composition. A reader handed no resolution still walks for itself, and each keeps the behaviour it had on an undeterminable custody: enrollment raises it by name, the narrowing readers degrade to no exclusion. Composition freeze then verifies rather than completes: the driver read its members before reaching it and seeds QA and stamps release against that list, so a member arriving at freeze would execute unseeded, and freeze refuses by name instead. An item-less environment run on a v2 flow enrolls like any other, and deliberate member choices — an explicit progress intent included — are never rewritten. Composition names what it skipped in one notice (deployment_run_skipped_candidates.skipped_candidate_notice): carried items a live or succeeded release holds; carried items back in rework, whose status is before their workflow's release stage (under release_stage delivery a stage inside an implementation skill's segment is not delivery-ready unless it is the release wait itself, so a Dash sent back to implementing after a landing is never composed); members an operator removed; and dependents of a blocker that has not shipped — a blocker a live or settling release holds included, named with that run, since composition counts only a recorded succeeded delivery as shipped while completion authority reads a settling run as delivered. yoke deployment-runs remove-item RUN-ID PREFIX-N --reason R (deployment_runs.remove_item, refused while another session is the run's live driver, created runs or unsettled members at executing item QA or failed item-qa-failed without shared run QA/approval) deletes the membership row and records the reason, time, session and actor in deployment_runs.membership_removals; enrollment and the omitted-work refusal subtract it so composition never re-enrolls the item, its code still ships, and the next release's unheld-custody pass enrolls it. add_item clears the entry while created. A live item QA dispatch can already have loaded the removed member: if materialization (deployment_member_removed) or gating refuses, it skips that member only when the membership row is absent and the run records the removal, then continues the other members. A member missing without a recorded removal still fails loudly. A failed item-QA run remains failed after removal; resume it with yoke watch deploy -- RUN-ID --from-stage item-qa. Removal takes over the member's QA copies the failure retired, so the resume never restores them. Every other failed stage refuses removal. Failed independent item QA automatically releases a member whose recorded merge is definitely outside its project's frozen lineage, using the same audited removal as settlement; unknown containment holds and reports recovery. Settlement also releases replaced candidates, and resume and settlement both release a member reopened for rework after the run froze whose current candidate is outside the frozen lineage, settled QA included; a contained one stays and unknown containment refuses by name. A carried delivery-ready item with no resolvable completion flow refuses with the item and flow-selection command named; select a project workflow delivery default before retrying the start. Other refusals include a carried item whose project or stage this run cannot admit, an underivable carried set; refused creation rolls back the run, so there is no run to update. A commit no item owns ships freely with its SHA, subject and author recorded under its project, and a retry (recorded retry_of; a takeover's moved snapshots are not one) of a run that already froze its members reuses that membership rather than deriving against a moved baseline.
Release wrap-up prints each member stamp and close-out step, then confirms the succeeded write; output before a slow operation does not assert completion. Composition warns for a carried item merged but not at release yet (wait, or add-item at release; never enrolled early); create refuses (release_stranding_unreleased_blocker) if it strands a carried dependent: retry at release. Removal queues a holder notice saying outstanding run-bound QA was cancelled, not passed, and not to attempt close-out because this run succeeds. When a cancelled requirement's latest verdict is fail or error, the notice names those requirements and keeps the failure handoff: classify the failure; a fixable failure is reworked and re-landed through yoke merge item, parking only after landing, while an external failure records its blocker; keep the claim either way; removal never changes item status. Without a red verdict, the item rides a later release: keep the claim and re-park. Settlement and failed-QA releases whose newer candidate already moved past the run send the later-release text. A takeover sends that notice only when it aborts the owner's running QA execution; the text then says the member's QA continues on the new run, and to keep the claim and stay parked. A notice for an execution still holding its Test Machine names the cleanup that frees it. A takeover that aborts nothing sends no owner notice: the new run's item-QA wake names the new run. Delivery-run cards show only QA bound to their own run; a run without item QA shows no item-QA count. Retracted requirements appear as cancelled with their removal reason and are excluded from pass counts, as are no-obligation records. Retracted run requirements are ignored by the done QA gate, while standing obligations and the item's delivery gate remain in force. Stage age reads only the durable current_stage_entered_at clock, including complete; repeated stage writes preserve it, older runs show unknown age, and snapshots without a source clock clear the prior stage's age when their stage changes.