Skip to content

Turn on automatic UK promotion under d1344 - #635

Merged
MaxGhenis merged 28 commits into
mainfrom
w4/d1344-automatic-promotion
Oct 16, 2026
Merged

MaxGhenis merged 28 commits into
mainfrom
w4/d1344-automatic-promotion

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Merge hold: cos d1613 (security). Until 16 Oct 2026 this branch's history carried a pre-suppression aggregate-impact report (tests/fixtures/uk_metrics/budget_metrics_report.json, blobs c79f1a06 from 3ea0b19 and 6b599fb5 from b424ffb). That report published Disabled Students' Allowance, which comes from fewer than 10 records, unsuppressed. The tip now carries W3's suppressed 1.58.1 report at tool commit 228a05a3e. Max is deciding whether to rewrite the history. Don't merge, force-push or delete anything for it until d1613 is ruled.

Fixes #634

Stacked on #574. Known gaps and review bounds wait on other workstreams (scorecard#183; W3's approved aggregate-impact commit), so they land as follow-up PRs. The strict rule holds until then: no known gaps, and the current bounds only send moves to review. The base is w4/uk-candidate-qualification. It retargets to main when #574 merges.

Plan

  • Replay gate policy d1344-replay-regressions-v1 (this commit; details below).
  • Follow-up PR, known gaps: vendor scorecard#183's diff.base_events and the candidate's missing/unsupported lists once they merge on scorecard main, and implement the exact known-gap test. The vendored schema is closed, so receipts carrying those fields are refused until then.
  • Follow-up PR, review bounds and W3's suppression rules, with the re-pin to W3's approved aggregate-impact commit and the regenerated 1.58.1 fixture.
  • promotion.automatic: true, with the d1344 basis in uk.json (0b74ddb). This also fixes a dormant bug. The automatic step used gh pr merge --auto, but main is not branch-protected and the repository has allow_auto_merge off, so the call would have failed silently (check=False) and nothing would ever promote. Promotion now merges at the qualified commit only once every check on the candidate PR has passed. Skipped jobs count as passed; pending, failed or cancelled checks, none reported, or an unreadable list wait for the next run. --match-head-commit still refuses a moved branch.
  • Requalify stale-baseline candidates, for liveness (3b93070). A promotable pair that advances main, but was qualified against an earlier certificate, resolves to qualify without force, once per main commit. The promotion plan names it (PromotionPlan.requalify), a new requalify command prints it, and catch-up dispatches it after finishing waiting receipts. Rejected, unaccepted and sideways pairs never requalify. Five mutants are killed.
  • Independent review, then merge on the gates.

The d1344 basis

cos d1344, decided 15 October 2026: "Yes: delegate the methodology calls the CI plan raises to the workstream sessions, each with independent review agreement and receipt-recorded exceptions". The same decision turns automatic promotion on once #574 lands on its gates.

The replay call was made by W4.

  • The first independent review (GPT-6.1 Sol) disagreed. It showed that incompleteness includes inertness, and that diff flags can't prove the base's state.
  • W4 revised the policy. The second review agreed with changes, and W4 applied exactly those.
  • W6, which owns the replay receipt, and the coordinating session agreed. The coordinator's alternative, failing any head moved past the bound, was considered and sent to review instead.

The policy text ships as src/policyengine/data/qualification/replay_policy.md. Each receipt records its id and the text's SHA-256.

Fail:

  • rejected, failed or unbound receipts;
  • mapping flags;
  • lost results: a coverage row with a base value and no candidate value;
  • lost responses: a measure-year the candidate lists as inert, or a measure-year total of exactly £0 against a base of at least max(£1m, 1%) in absolute value;
  • incomplete grids.

Known gaps need positive base-side evidence. None exists yet, so there are none, and the rule is strict.

Review: every other flagged row. That includes sign reversals, construction and support flags, coverage gains and null/null rows, and base-only inertness.

Invariants (property-tested)

For every receipt:

  • the gate fails exactly when a regression is recorded;
  • no flagged row ever passes;
  • mapping flags and lost results always fail;
  • adding a flagged row never makes the verdict better (pass < needs_review < fail).

test_the_replay_policy_is_monotone_and_never_passes_a_flag checks these with Hypothesis. The decision record's regression table, the bound's edges (exactly £1m fails, £999,999 is reviewed, a negative base fails, a non-measure-year £0 is reviewed) and the first real receipt are example tests.

Differential check on real data. The 6.4.1 receipt (e3cc6521…, now a fixture) fails on exactly its four inert UC measure-years: £264.1m, £338.6m, £436.3m and £626.6m, each going to £0. Its incomplete spring_statement_2025 grid fails it too, and its other 345 flagged moves and 1 coverage gain go to review. That matches the decision record's stated effect.

Nine mutants are killed: the signed bound, a strict bound, any level, the inert list ignored, lost results as review, lost rows also reviewed, no counted flags, integrity-incomplete ignored, and incomplete events ignored.

Tests

tests/test_qualification_contracts.py, test_qualification_ledger_concurrency.py and test_uk_qualification_pipeline.py: 192 passed.

axiom: n/a: qualification CI infrastructure, no policy encoded.

🤖 Generated with Claude Code

MaxGhenis and others added 5 commits October 15, 2026 17:07
Policy d1344-replay-regressions-v1, decided by W4 under cos d1344 after
two independent reviews (the first disagreed, the second agreed with
changes, which this applies). The gate fails what a receipt identifies
as a regression: mapping flags; lost results (a coverage row with a
base value and no candidate value); lost responses (a measure-year the
candidate lists as inert, or a measure-year total of exactly 0 against
a base whose absolute value reaches the diff's bound, max(1m, 1%));
and incomplete grids. A gap is known only on base-side evidence, which
no receipt carries yet, so there are no known gaps. Every other flagged
row goes to review: numerical, structural (sign reversals included),
construction and support flags, coverage gains and null/null rows,
and inertness on a measure-year the candidate does not list.

The policy text ships as data/qualification/replay_policy.md, and the
gate records its id, the text's SHA-256, the known gaps, the
regressions and the review items. Tests pin the decision record's
regression table and the bound's edges, check the first real receipt
(6.4.1) fails on exactly its four inert UC measure-years and the
incomplete grid with 346 review items, and state the invariants as a
property: fail exactly when a regression is recorded, no flagged row
ever passes, mapping and lost results always fail, and adding a row
never improves the verdict. Nine mutants are killed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With automatic promotion, a target qualified against an earlier
certificate than main's must not wait for a manual --force. A
promotable pair (qualified, or needs-review with an exact acceptance)
that advances main in every component, but whose previous certificate
differs from main's, now resolves to "qualify" without force, once per
main commit: an attempt from the current base makes later runs
duplicates. The promotion plan names that pair (PromotionPlan.requalify),
a new `requalify` command prints it, and the two-hourly catch-up
dispatches the qualification after it has finished and published
waiting receipts. Rejected, unaccepted and sideways pairs are never
requalified.

Five mutants (promotability, the advance check, the baseline check,
the per-commit dedupe and the plan field) are each killed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cos d1344 (15 October 2026): automatic promotion is on once #574 lands
on its gates; this PR merges after #574. promotion.automatic is now
true, with that basis in uk.json.

The automatic step used `gh pr merge --auto`, but main is not
branch-protected and the repository has allow_auto_merge off, so it
would have failed silently (check=False) and nothing would ever be
promoted. Promotion now merges the readied candidate at its qualified
commit only once every check on its pull request has passed (a skipped
job counts as passed; pending, failed, cancelled, none reported, or an
unreadable list waits for the next promote run). --match-head-commit
still refuses a branch that moved. Four mutants of the check gate are
killed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 19 commits October 15, 2026 23:47
High: promotion squash-merged the candidate onto whatever main was at
merge time, so main code changed since the candidate's base (with the
certificate unchanged) produced a tree no gate had qualified. Promotion
now pushes the qualified commit to main without force, once its checks
pass. The push succeeds only while main is still the commit the
candidate was built on, so main becomes exactly the tested tree or
nothing changes; the promote job pushes as the App so main's release
workflow runs. When main has moved, the pair is requalified on the new
main: candidate_action and the new requalification_target treat a
promotable target built on an earlier main commit (the published
attempt's key) like one compared against an earlier certificate, once
per main commit and never while another requalification is in flight.

Medium: one passing check could let a merge through. The gate now
needs every check run on the qualified commit finished as success,
skipped or neutral, and every pull request workflow run for it finished
successfully, including the code-changes workflow that runs the tests;
a run still registering its jobs, none reported, or an unreadable list
waits.

Medium: requalification and recovery dispatches named only the model
and data versions, so resolve could pick another core and qualify a
different pair while the original stayed the stale target. Dispatches
now pass core_version and pair_id; resolve pins the core and fails the
run, qualifying nothing, if the versions resolve to another pair.

Medium: a mapping flag the counters stated with no flagged row became
reviewable. It now fails.

Lows: every early failure (unbound, rejected, failed, contradictory)
records the policy id, its digest and the failure as the regression;
the property test checks each verdict against an independent restatement
of the decision record and a source-row zero total is reviewed, not
failed; the policy text says a move below the bound raises no numerical
flag but is reviewed if another flag fires.

Mutation: the reviewer's three survivors, four for the check gate, three
for the stale-base rule and the counted mapping flag are each killed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round 2 of the #635 review:

High, a manual merge could still bypass qualification: promotion
readied the target even when main had moved, and a squash merge then
combined the pair with untested code. A candidate is now readied only
while main is still the commit it was built on (so any merge of it is
exactly the qualified tree); otherwise it is kept draft (readiness is
undone) and requalified on current main. With automatic promotion it
stays draft until its checks pass, is readied only for the push, and
is returned to draft if the push fails.

Medium, a non-forced push accepted main force-reset to an older
ancestor: the push is now leased on the candidate's parent
(--force-with-lease=refs/heads/main:<parent>), an atomic compare-and-
swap.

Medium, green pull-request checks described the candidate merged with
an older main: preparation now dispatches the code-changes workflow on
the candidate branch, testing the commit's own tree, and promotion
requires that run.

Medium, an older cancelled run blocked forever: the gate reads the
newest run of each workflow and event, and the newest check of each
name. refresh-pr-checks also reruns failed dispatched runs.

Medium, a pair without latest_attempt.json was stranded: its base is
the attempt whose own receipt is the published one, and an unknown base
counts as stale.

Medium, runs resolved on different main commits could both stage: no
pair qualifies while another of its attempts is in flight, and the
preparation lock rechecks eligibility against the ledger as it is now
(still-eligible), with the resolved force recorded in the candidate.

Ten mutants of these changes are killed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 1.58.0 report predates the tool's small-cell rule and published
Disabled Students' Allowance's total and caseload, which come from
fewer than 10 records, unsuppressed; totals containing it gave it back.
The 1.58.1 report at tool commit 228a05a3e publishes those figures as
suppressed and rebuilds every total without them.

The gate and tests that read the report move to the suppression format
in the next commit. History is left as is pending a security decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a3e pin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t evidence

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round 3 of #635:

- A job timeout cancels its run, and refresh re-ran only failed and
  timed-out runs, so a cancelled own-tree run blocked promotion with
  nothing to re-run it (requalification fires only when main moves).
  Refresh now re-runs the newest run of each workflow and event when it
  failed or timed out (its failed jobs) or was cancelled (in full), and
  stops after MAX_RUN_ATTEMPTS, so a genuine failure is not retried
  until a flaky test passes.
- Refresh re-ran every failed run, including ones a newer run replaced,
  whose re-run checks then came back newest and blocked. It now leaves
  replaced runs alone, and the promotion gate does not count checks
  from a replaced run's check suite. Checks outside any workflow run
  still count.
- The still-eligible recheck gets GH_TOKEN: without it, an earlier
  attempt's failed run read as live until its stage expired.
- cmd_promote returns every ready candidate except the target to draft,
  and a failed return to draft now fails the run (_set_ready reports
  it; _promote returns False only then).
- Tests: the cancelled-no-jobs own-tree run, a replaced run's re-run
  checks, an app's check, force reaching still-eligible and resolve's
  payload, the sweep and a failed undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rts; signed exclusions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…few records

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round 4 of #635:

- Medium: the first attempt of a qualified_exact_pair candidate's runs
  fails by design (its receipt is published after they start), so a cap
  of three left one re-run for flakes, then stranded the candidate while
  promote exited 0. MAX_RUN_ATTEMPTS is 4, and a candidate whose newest
  run is still unsuccessful at the cap fails the promote run with an
  error naming the runs to re-run by hand.
- A re-run GitHub refuses (a run older than 30 days) no longer aborts
  refresh and the steps after it: it warns and refresh goes on.
- Candidate pull requests are listed with their numbers and only from
  this repository's branches; the sweep returns stale ones to draft by
  number, so a fork's pull request with a candidate's branch name can
  neither shadow the real one nor make every run fail.
- A target already marked ready goes back to draft while it waits on
  its checks.
- Tests: a real listing with ready, draft and fork pull requests; each
  failed undo alone (sweep, target after main moved, target after a
  rejected lease); the cap at attempts 3 and 4; a refused re-run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… from population details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re-run

Round 5 review of #635:
- The runs listed at a commit include other pull requests' runs there, a
  fork's included. Promotion and refresh now count only runs from this
  repository on the candidate branch; checks of any other run are ignored.
- Stranded now covers every finished, unsuccessful newest run refresh will
  not re-run: results it does not re-run (action_required, skipped, stale),
  runs at the attempt cap, and runs past GitHub's 30-day re-run window.
- The candidate listing reads up to 1000 pull requests and refuses at the
  limit; every readiness change and close goes by number.
- On the waiting path a failed return to draft fails the run.

Tests: fakes project to the jq fields and fill in each run's branch and
repository; new cases for fork and other-branch runs, the wider stranded
rule, the listing limit, a refused re-run listed first and two candidates
at one commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… stranding

- The gh pr list fake returns only the --json fields asked for, so a call
  that stops asking for isCrossRepository fails a test.
- A run that passed on its last allowed attempt is not stranded while the
  candidate still waits on another run.
- The qualification runbook says when the promote job fails for a
  stranded candidate and the three ways to clear it.

All 22 mutants of the round-5 change (the round-5 survivors among them)
fail the promotion suites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Rs to main

Round 6 of #635:
- M1: a pull request from the candidate branch to another base runs the
  same workflows with the same commit, branch and repository as the
  candidate's own, and GitHub's runs do not record which pull request a
  run is for (the API's pull_requests field lists open pull requests
  matching the head, not the trigger). Promotion now lists the branch's
  pull requests in every state; while one to another base is open, or
  after one was closed or merged at the commit, the candidate is kept
  draft and the run fails with an error naming it (close it and
  requalify: the new candidate commit leaves its runs behind). Refresh
  re-runs none of that candidate's runs. An unreadable or full listing
  waits.
- M2: the candidate listing asks for pull requests to main and drops any
  other, so two pull requests from one branch cannot collapse into one
  entry and send a readiness change to the wrong one.
- The workflow's own lookups (prepare's return to draft, opening or
  editing the candidate pull request, the verdict edit) now find this
  repository's pull request to main and write by number.
- Coverage the reviewer found missing: the gh fakes run the code's jq
  with the real jq over API-shaped data (repository and check suite as
  objects), superseded candidates are closed by number, and both edges of
  the 30-day re-run window are pinned.
- The changelog and runbook say what is isolated and what is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…change

Round 7 of #635:
- M1: gh pr list --head matches the branch name in forks too and stops
  at 30, so newer forks' pull requests with the candidate branch's name
  could crowd the candidate's own out of the listing, and prepare would
  push a replacement head without returning it to draft. The workflow's
  lookups (prepare's return to draft, opening or editing the candidate
  pull request, the verdict edit) and the shared-branch check now use
  GitHub's REST listing filtered by head=<owner>:<branch>, which holds
  only this repository's pull requests, paginated in full.
- M2: a base change does not change what a pull request's earlier runs
  tested. Among pull requests from the branch whose runs can be at the
  commit, one now to main whose base was ever changed (GraphQL
  BaseRefChangedEvent; the candidate's own included) counts as sharing
  the candidate's runs, so promotion refuses it and refresh leaves its
  runs alone; an unreadable history waits.
- Tests: the REST and GraphQL fakes run the code's jq with the real jq
  over API-shaped responses; forty newer forks' pull requests no longer
  hide the candidate's in prepare or the shared-branch check; base-change
  cases for the candidate's own and closed pull requests. All 10 mutants
  of this change fail the suites.
- Changelog and runbook describe the base-change rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round 8 of #635: GitHub records a retarget it makes itself, when a pull
request's base branch is merged and deleted, as an
AutomaticBaseChangeSucceededEvent, which a query for
BASE_REF_CHANGED_EVENT alone misses (live: #244). The base-change query
now asks for both, so a pull request automatically retargeted to main is
counted like one a person retargeted. The GraphQL fake now answers only
for the event types a query asks for, and new cases cover an automatic
retarget of the candidate's own and of a closed pull request; dropping
either event type from the query fails a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis changed the base branch from w4/uk-candidate-qualification to main October 16, 2026 13:13
MaxGhenis and others added 2 commits October 16, 2026 09:23
…s base

Round 9 of #635: a base branch renamed into main records no timeline
event, so a pull request now to main proves nothing about the base its
runs at the commit tested (the reviewer showed a rename masking a failed
run with no event to see). Any other pull request from the candidate
branch that is open, or was closed or merged at the commit, now counts
whatever its base, and only the candidate's own pull request is checked
for recorded base changes. Since prepare opens the candidate's pull
request to main in the job that pushes the commit, and qualification
needs that job to succeed, a pull request renamed into main after running
at the commit always sits beside prepare's there, and one of them counts.
New cases: a closed or merged pull request to main at the commit is
refused, one at another commit is not; all 7 mutants of the rule fail the
suites. Changelog and runbook updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round 10 of #635 modelled an admin renaming the default branch in the
seconds between prepare's push and its pull request lookup, so that a run
against another base passes as the candidate's. Every round since 6 has
needed write or admin access here, and each confirmed that main only ever
becomes the exact qualified commit, on current main, after its own-tree
run passed. main is not protected (checked live: protected=false, no
rulesets), so people who can write here can push it directly; closing
this would need event-time provenance from pr_code_changes.yaml, which
W4 does not own. The _promote docstring and the runbook now say what the
gate guards against (mistakes by writers, anything non-writers can do)
and what it does not (deliberate disguise by writers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 2 commits October 16, 2026 10:18
Round 11 of #635 (approved; low): the helper docstring still said
prepare always opens a separate pull request, so a rename could not slip
by; round 10 showed prepare's lookup can adopt a renamed one. It now
says what the check cannot see and points to _promote's trust boundary.
That boundary, here and in the runbook, now also names pull requests
opened from the candidate branch by anyone who can read it (no write
access needed), which make promotion refuse rather than accept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round 12 of #635 (approved; low, wording): the trust boundary said pull
requests opened from the candidate branch make promotion refuse, which
holds for every other one but not for one to main that prepare's lookup
adopts as the candidate's own; that one is refused only if its base was
ever changed. The _promote docstring and the runbook now say so, in the
reviewer's wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 16, 2026 15:45
@MaxGhenis
MaxGhenis merged commit a569878 into main Oct 16, 2026
13 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Merged at reviewed head 918939b with a merge commit. Gates: gh pr checks exit 0 (13/13 pass), mergeable, no change requests. Independent review (GPT-6.1 Sol, 13 rounds): round 11 APPROVE of the substance at 69d6f1f under the stated trust boundary (main only ever becomes the exact qualified commit, on current main, after its own-tree run passed; writers' deliberate disguise is out of scope since main is unprotected), round 12 APPROVE at efd9d7c and round 13 APPROVE at 918939b (wording-only commits; syntax trees identical without docstrings). Automatic promotion is on under cos d1344 (delegated, coordinator agreed). Evidence: ~/reviews/w4-budget-ci-2026-10-11/pr635-round*-sol.md.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Turn on automatic UK promotion under d1344 (replay policy, known gaps, review bounds, liveness)

1 participant