ADR 0042: Publication-Pending Release Evidence (CANDIDATE_VERIFIED)
- Status: Accepted (founder decision recorded on AAASM-6419, binding)
- Date: 2026-11-01
- Ticket: AAASM-6419 (Epic AAASM-6159). Builds on Core ADR 0037; amends nothing in it.
Context
The release-evidence gate (Core ADR 0037, scripts/qa/check-release-evidence.py)
knows one authorizing state per journey, PASS, plus a human-approved waiver.
Some release-blocking journeys (the fidelity: published_artifact ones, for
rc.8: J04, J08, J17, J53, J56, J59) contain checks that can only be run against
artifacts that exist after publication. Before publication they cannot be
PASS; the only existing way to let such a row through is a waiver, which says
“this check is excused”, not “this check is still owed”.
The founder decision for B1 is binding: no blanket waiver for those rows. Separate each row into pre-publication candidate verification and the checks that genuinely need published artifacts; record the latter as PENDING, never PASS; prepare the post-publication checks and failure response for a later, SHA-specific release approval.
Decision
- A third per-journey state,
PUBLICATION_PENDING, and a QA verdict tokenCANDIDATE_VERIFIED. The token contains noPASSsubstring, so every existingPASSpattern (the readiness greps, the status-token rules) cannot match it by accident. The security sign-off has no pending state: its verdict stays exactlyPASS | BLOCK, andCANDIDATE_VERIFIEDthere is a checker BLOCK. - Pending is admitted only against a committed obligation record
(
docs/release/qa-signoff/v<version>.publication-obligations.json) plus a new registry fieldpublication_checks: [{id, channel, summary}], valid only whenfidelity == published_artifact. Per journey the obligation lists the already-verifiedcandidate_checks(eachPASS, pre-publication fidelity, evidence reference, artifact digests), thepending_checks(id, channel, why publication-only, command, expected result),depends_on, and afailure_response(proposed actions, who is notified, deadline). Pending check ids must equal the registry’spublication_checksids, so a pending check cannot be added, dropped or renamed without a registry change, andpublication_checksis part of the per-journey registry digest (only when declared, so no existing digest changes) so editing it after evidence drifts R2. - The checker never trusts the emitter.
check-release-evidence.pyreads the registry entry and the obligation file from git at the tag target (never the working tree), requires the obligation to be byte-identical at the candidate commit, compares the evidence’s embedded copy to it, takes fidelity andpublication_checksfrom the registry, and recomputes the verdict from the journeys. The evidenceverdictfield and the QAVerdict:line must both equal the recomputed value. The obligation file lives underdocs/release/qa-signoff/, which R1 already classifies EXECUTABLE after the candidate; the strict tag-guard allowlist is deliberately not widened. - Pending is never PASS, and never authorizes anything. The verdict is
PASSonly when no row is pending;CANDIDATE_VERIFIEDwhen every row is resolved and at least one is pending; otherwiseBLOCK. A valid pending-only record makes the checker exit 3, not 0. Every consumer that requires exit 0 (release.yml’srelease-evidence-gate,release-tag-guard.shstep 4) therefore keeps refusing with no workflow change.release-readiness.shreports the state with its own line (CANDIDATE_VERIFIED — N obligations pending (not PASS)), fails check 14 on exit 3, and never prints “safe to tag” for it. - A pending row cannot also be waived. The emitter resolves pending plus an
exception to
BLOCK, and the checker rejects a hand-edited record that has both. - A pending Result cell is never ambiguous. The emitter refuses a cell that
says
PUBLICATION_PENDINGand also names another state (FAILED,BLOCKING,UNTESTED,PARTIAL, …). The checker blocks a row whose Result cell claimsPUBLICATION_PENDINGbut whose status is anything else, and readiness check 12 refusesVerdict: PASSover a pending row. - Candidate checks are bound to named artifacts. Every
candidate_checksentry must carry a non-emptyartifact_digestsmap of name tosha256:<64 hex>; a missing, empty or malformed map makes the obligation inadmissible. The digests reach the evidence record’sartifactsfield and the checker requires them to equal the committed obligation file’s. Known gap: no candidate build-digest manifest exists today, so the checker validates format, presence and thecandidate_shabinding but cannot cross-check the digests against an independently produced build record. Closing that needs a candidate build manifest and is follow-up work. - A journey that declares
publication_checkscan never be a barePASS. R3 refuses aPASSrow for it; before publication its only legal state isPUBLICATION_PENDINGwith an obligation. A post-publicationPASSrequires a future evidence attempt (see Consequences); this ADR defines no post-publication evidence shape. - Duplicate journey ids in the evidence are a
BLOCK, so a later row can never silently replace an earlier one.
Consequences
- A release can honestly be recorded as “candidate verified, N publication checks owed” without a waiver, and the owed checks, commands and failure response are committed and digest-bound before anything is published.
CANDIDATE_VERIFIEDdoes not authorize tagging or publishing. Authorizing a publish on aCANDIDATE_VERIFIEDrecord is a separate owner decision (SHA-specific release approval) and needs arelease.ymlchange that this ADR does not make.- Recording post-publication results is not built here. Evidence is immutable (Core ADR 0037, R1b), so a post-publication result is a new evidence attempt, never an in-place edit of the pending record. Specifying that appender is follow-up work.
- Adding
publication_checksto the real catalog entries and writing the real obligation files is a follow-up catalog change; this ADR’s implementation only makes the state expressible and verifiable.
Alternatives considered
- Blanket waiver for the six journeys: rejected by the founder; it records the check as excused and loses what is still owed.
- Checker exit 0 for
CANDIDATE_VERIFIED: would let the unchangedrelease.ymlgate publish a candidate with owed checks; rejected as unsafe. - Widening the tag-guard allowlist for the obligation file: rejected; the obligation must be final at the candidate, so a later edit is an R1 BLOCK.
Update — AAASM-6424: rc-only two-stage verification
The owner decision this ADR reserved (“authorizing a publish on a
CANDIDATE_VERIFIED record is a separate owner decision”) has been made, for
prerelease candidates only. Nothing above is reversed: a pending row is still
never PASS, and the checker’s default exit for a pending-only record is still 3.
-
Scope. Only versions matching
MAJOR.MINOR.PATCH-rc.N(for example0.0.1-rc.8). Final tags, and every other prerelease series (alpha, beta), keep the exit-0-only gate unchanged. -
Two stages. Stage 1: every check that can run before publication has passed on the pinned candidate. Stage 2: only checks that demonstrably depend on actual publication remain pending, each with an automated post-publication verification. The rc may be tagged on a stage-1-complete record; stage 2 runs against the published artifacts.
-
New checker exit 4, opt-in.
check-release-evidence.py --accept-rc-candidate-verifiedturns a record that would exit 3 into exit 4 (RC_CANDIDATE_ELIGIBLE) only when the version is an rc and every additional rule below holds. Without the flag, or for a non-rc version, exit 3 is unchanged. Exit 4 is never exit 0: consumers must name it explicitly. -
Additional rules for exit 4 (on top of every R1-R11 rule, which already bind the record to the candidate SHA, the candidate artifact digests and the registry digest and block any FAIL or NOT_COVERED row):
- every journey is either
PASSorPUBLICATION_PENDING; a waiver-admitted row blocks, because a waived check has not passed; - every pending check in the committed obligations file declares, in addition
to the existing
why_publication_only(the reason), anautomated_procedurethat is the exact id of a non-manual runner inscripts/qa/post-publish-verify.pyRUNNERS(equal to the check id). A manual marker is not accepted, with or without a justification: the founder requires an automated verification for every owed check. A check with no runner needs a runner before the rc can be tagged. Each pending check also needs afailure_responsethat is a reference of the formdocs/release/rc-post-publication-failure-response.md#<anchor>whose anchor exists in that document at the tag target.
- every journey is either
-
Authorization is mechanical. Exit 4 additionally requires a committed owner approval record
docs/release/approvals/v<version>.approval.jsonwithtag,commit_sha(the exact tagged commit),candidate_sha,approved_by,approved_at,scope: "rc-publish"andevidence_sha256(sha256 of the evidence file as committed at the tag target). The record names the tagged commit, so it cannot be inside it: it is read from--approval-ref, which must descend from the tag target (<remote>/mainfor the guard,origin/maininrelease.yml). Absent, malformed or mismatching records refuse the rc path (exit 1). The record is owner-authored data; no tool creates one. The ref must be fully qualified (refs/remotes/...), since a short name can be shadowed by a tag or branch.What this does and does not authenticate. The trust root is who can land a commit on
main: anyone who can do so can add an approval record. This repo change adds explicit.github/CODEOWNERSlines for the approvals directory,scripts/qa/, the guard, the readiness script and the two release workflows, but CODEOWNERS only routes review. The protection holds only if branch protection onmainrequires code-owner review and does not allow the author to approve their own change. This change cannot configure or verify that. Owner follow-up: confirm those GitHub settings. Until then the approval is a recorded intent, not an authenticated one. -
Consumers.
release-tag-guard.sh,release-readiness.shandrelease.yml’srelease-evidence-gatepass the flag and the approval ref and accept exit 0 or 4.CANDIDATE_VERIFIEDremains technical eligibility; the approval record is the authorization. No publish job condition changes. -
Stage 2 cannot be skipped.
.github/workflows/post-publish-verify.ymlruns on-rc.Ntag pushes, runsscripts/qa/post-publish-verify.pyagainst the published tag (retrying while publication completes) and fails visibly unless every owed check isPASS, pointing at the failure-response document. It retries only results the verifier marks retryable (INCONCLUSIVE, or a FAIL that only says the artifact is not there yet); a definitive FAIL stops at once..github/workflows/post-publish-watchdog.yml(scheduled, read-only) fails visibly if a published rc tag has no successful run after six hours. It is best-effort: GitHub disables schedules after 60 days of repository inactivity and a failed run notifies nobody beyond GitHub’s own failed-workflow notice. The release-assets check proves presence (published, not draft,SHA256SUMSnames every required tarball), not tarball contents. Sequencing: the tag-target commit lands onmainfirst, the approval commit later; the guard fetches<remote>/mainbefore reading it, readiness does not. It is a separate read-only workflow because the GHCR images are pushed bydocker.ymlin parallel withrelease.yml, so norelease.ymljob can await them. -
Consequence for the obligations file. An rc obligations file written before this update has no
automated_procedureor per-checkfailure_response; it yields exit 3 (blocked for tagging) until they are added. Checks with no runner must gain a runner.
Last updated: 2026-11-05 by Chisanan232