Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. A third per-journey state, PUBLICATION_PENDING, and a QA verdict token CANDIDATE_VERIFIED. The token contains no PASS substring, so every existing PASS pattern (the readiness greps, the status-token rules) cannot match it by accident. The security sign-off has no pending state: its verdict stays exactly PASS | BLOCK, and CANDIDATE_VERIFIED there is a checker BLOCK.
  2. Pending is admitted only against a committed obligation record (docs/release/qa-signoff/v<version>.publication-obligations.json) plus a new registry field publication_checks: [{id, channel, summary}], valid only when fidelity == published_artifact. Per journey the obligation lists the already-verified candidate_checks (each PASS, pre-publication fidelity, evidence reference, artifact digests), the pending_checks (id, channel, why publication-only, command, expected result), depends_on, and a failure_response (proposed actions, who is notified, deadline). Pending check ids must equal the registry’s publication_checks ids, so a pending check cannot be added, dropped or renamed without a registry change, and publication_checks is part of the per-journey registry digest (only when declared, so no existing digest changes) so editing it after evidence drifts R2.
  3. The checker never trusts the emitter. check-release-evidence.py reads 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 and publication_checks from the registry, and recomputes the verdict from the journeys. The evidence verdict field and the QA Verdict: line must both equal the recomputed value. The obligation file lives under docs/release/qa-signoff/, which R1 already classifies EXECUTABLE after the candidate; the strict tag-guard allowlist is deliberately not widened.
  4. Pending is never PASS, and never authorizes anything. The verdict is PASS only when no row is pending; CANDIDATE_VERIFIED when every row is resolved and at least one is pending; otherwise BLOCK. A valid pending-only record makes the checker exit 3, not 0. Every consumer that requires exit 0 (release.yml’s release-evidence-gate, release-tag-guard.sh step 4) therefore keeps refusing with no workflow change. release-readiness.sh reports 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.
  5. 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.
  6. A pending Result cell is never ambiguous. The emitter refuses a cell that says PUBLICATION_PENDING and also names another state (FAILED, BLOCKING, UNTESTED, PARTIAL, …). The checker blocks a row whose Result cell claims PUBLICATION_PENDING but whose status is anything else, and readiness check 12 refuses Verdict: PASS over a pending row.
  7. Candidate checks are bound to named artifacts. Every candidate_checks entry must carry a non-empty artifact_digests map of name to sha256:<64 hex>; a missing, empty or malformed map makes the obligation inadmissible. The digests reach the evidence record’s artifacts field 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 the candidate_sha binding but cannot cross-check the digests against an independently produced build record. Closing that needs a candidate build manifest and is follow-up work.
  8. A journey that declares publication_checks can never be a bare PASS. R3 refuses a PASS row for it; before publication its only legal state is PUBLICATION_PENDING with an obligation. A post-publication PASS requires a future evidence attempt (see Consequences); this ADR defines no post-publication evidence shape.
  9. 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_VERIFIED does not authorize tagging or publishing. Authorizing a publish on a CANDIDATE_VERIFIED record is a separate owner decision (SHA-specific release approval) and needs a release.yml change 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_checks to 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 unchanged release.yml gate 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.

  1. Scope. Only versions matching MAJOR.MINOR.PATCH-rc.N (for example 0.0.1-rc.8). Final tags, and every other prerelease series (alpha, beta), keep the exit-0-only gate unchanged.

  2. 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.

  3. New checker exit 4, opt-in. check-release-evidence.py --accept-rc-candidate-verified turns 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.

  4. 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 PASS or PUBLICATION_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), an automated_procedure that is the exact id of a non-manual runner in scripts/qa/post-publish-verify.py RUNNERS (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 a failure_response that is a reference of the form docs/release/rc-post-publication-failure-response.md#<anchor> whose anchor exists in that document at the tag target.
  5. Authorization is mechanical. Exit 4 additionally requires a committed owner approval record docs/release/approvals/v<version>.approval.json with tag, commit_sha (the exact tagged commit), candidate_sha, approved_by, approved_at, scope: "rc-publish" and evidence_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>/main for the guard, origin/main in release.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/CODEOWNERS lines 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 on main requires 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.

  6. Consumers. release-tag-guard.sh, release-readiness.sh and release.yml’s release-evidence-gate pass the flag and the approval ref and accept exit 0 or 4. CANDIDATE_VERIFIED remains technical eligibility; the approval record is the authorization. No publish job condition changes.

  7. Stage 2 cannot be skipped. .github/workflows/post-publish-verify.yml runs on -rc.N tag pushes, runs scripts/qa/post-publish-verify.py against the published tag (retrying while publication completes) and fails visibly unless every owed check is PASS, 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, SHA256SUMS names every required tarball), not tarball contents. Sequencing: the tag-target commit lands on main first, the approval commit later; the guard fetches <remote>/main before reading it, readiness does not. It is a separate read-only workflow because the GHCR images are pushed by docker.yml in parallel with release.yml, so no release.yml job can await them.

  8. Consequence for the obligations file. An rc obligations file written before this update has no automated_procedure or per-check failure_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