Round-5 cold review: the post-write check counted only human `Review-verdict:` rows above the high-water mark, which is not sufficient under the run overlap this branch measured. Sequence, all inside that regime, runs A and B on the same exempt-classified sha: the human BLOCKED lands BELOW A's mark (so A cannot see it), B masks it with an exemption `success`, and only afterwards writes the sentinel. A then finds nothing human above its mark, does not repair, and posts its own `success` on top of the sentinel. The human rejection is permanently green and every later run re-derives it — the repair race failing toward SUCCESS, while the record states it fails toward `pending`. The filter now counts two row shapes above the mark: a human verdict (non-null creator, `Review-verdict:` description) OR a machine sentinel (null creator, description exactly $REPAIR_DESC). A then repairs and both runs converge on the fixed point. It cannot false-fire: a pre-existing sentinel would have been seen at the FIRST read and forced the pending path, and this block only runs after a `success`, so a sentinel above the mark can only have been written mid-flight by another run. Mutation-verified on both halves independently — dropping the sentinel alternation reddens the new test; dropping the human half reddens the original race-2 test — so neither can be removed without a test noticing. Refs #706
9.2 KiB
key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
| key | title | status | since | supersedes | superseded-by | rule | signals | mechanics |
|---|---|---|---|---|---|---|---|---|
| ci.verdict-write-retarget-fence | 2026-08-03 — the review-verdict job fences its write on the PR timeline's retarget COUNT, and verifies the exemption write afterwards (#706) | active | 2026-08-03 | none | none | The `review-verdict/h10` job counts `change_target_branch` events on the PR's issue timeline at run start and again immediately before its POST, and writes NOTHING if the count moved. The COUNT is the key because the branch NAME is ABA-vulnerable — `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a forged exemption — while the event count is monotonic and cannot alias. Abstaining is a handoff, not a stall, and that is the property the design rests on: every retarget fires `edited`, which is in this workflow's `types:`, so the event that makes a run abstain has already queued a successor whose window opens after it; the induction terminates when retargeting stops and the last run writes the final answer. `updated_at` was REJECTED as the key because it also moves for comments and labels, which fire none of this workflow's `types:` — a run could abstain with no successor coming, which is a real stall. The count is trusted only when paging reached a validated EMPTY page; an untrusted count (unreadable page, non-array body, non-numeric length, page cap hit) blocks the exemption `success` ONLY and still lets `pending` through, because `pending` cannot turn an unreviewed head green while withholding it would strand ordinary PRs for no safety gain. SEPARATELY, and for the human-verdict race the fence does nothing about: after posting an exemption `success` the job re-reads `/statuses/{sha}` and, if a human `Review-verdict:` row appeared with an id ABOVE a high-water mark taken just before the POST, overwrites its own status with `pending` and logs an error. The repair is `pending`, NEVER a copy of the human's state, since re-posting their `failure` under the machine credential would attribute a human verdict to the job; its description is a SENTINEL that the classification refuses to grant an exemption over AND re-writes verbatim on every later run, so the block is a FIXED POINT rather than decaying — writing the generic `pending` description there instead erases the marker and the exemption simply returns one event later. The mark is captured BEFORE the last-moment re-read, not merely before the POST — a later mark leaves a multi-round-trip blind gap in which a verdict is neither seen by the re-read nor repaired afterwards. The id comparison is load-bearing: a mere presence test would fire forever on a base-mismatched verdict that `read_existing_verdict` deliberately declines to honour, deadlocking that PR's exemption permanently. Finally, a run whose last-moment re-read finds a sentinel it did not see at its FIRST read ABSTAINS instead of posting: that can only mean an overlapping run repaired a raced verdict mid-flight, and this run's `success` — frozen at classification time, with the human row below its own mark, so neither the fence nor the post-write check would catch it — would otherwise bury the rejection. That is the one path in this design that failed toward SUCCESS rather than `pending`. The post-write check counts TWO row shapes above the mark, not one — a human `Review-verdict:` row AND a machine sentinel — because with two overlapping runs the human row can sit BELOW the second run's mark while the first masks it and only then writes the sentinel, leaving the second to post its own `success` on top; counting the sentinel converges both runs on the fixed point instead. | stale review-verdict run overwrites a fresher one, retarget ABA against the docs-only classifier, concurrency group does not serialize pull_request_target, gitea auto-cancel push vs pull_request_target, forged exemption restored after reclassification, human BLOCKED silently turned green, post-write status verification, change_target_branch timeline count, why does my PR post no verdict status after a retarget · paths: `.gitea/workflows/review-verdict.yml`, `scripts/tests/test_pr_changed_files.py` · issues: #706, #698, #672, #663, #622 | `count_retargets()` pages `GET /repos/{repo}/issues/{pr}/timeline?limit=50&page=N` (cap 20) setting `rt_count`/`rt_ok`, trusted only on a validated empty page; `retargets_before`/`retargets_before_ok` captured before enumeration, re-counted immediately before the POST; `max_id_before` from `GET /repos/{repo}/statuses/{sha}` (a BARE ARRAY, unlike the combined `/commits/{sha}/status` object); repair POST is `pending`; tests `test_a_RETARGET_DURING_the_run_posts_NOTHING`, `test_a_PR_retargeted_BEFORE_the_run_but_QUIET_during_it_is_STILL_exempt`, `test_an_UNTRUSTED_retarget_count_withholds_the_EXEMPTION`, `test_an_UNTRUSTED_retarget_count_STILL_LETS_PENDING_THROUGH`, `test_a_human_verdict_landing_AFTER_the_POST_is_repaired_to_pending`, `test_a_PRE_EXISTING_human_row_does_NOT_trigger_a_repair` |
ci.exemption-provenance closed three routes into the exemption path and left one residual it named:
status writes are not serialized, so a stale run can post over a fresher one. This record resolves it,
narrowing that record rather than superseding it.
Measured, not reasoned (Gitea 1.25.4, 2026-08-03)
pull_request_targetruns for one PR overlap, older finishing last. Probe PR #722: run 7520 (opened) completed at 18:30:42, twenty seconds after run 7521 (synchronize) began. Race 1's mechanism, observed rather than argued.- A non-cancelling concurrency group — #706's own proposal — does nothing. With it active, runs 7528/7529 still overlapped; 7528 ended 36s after 7529 started. Refuted, not declined.
- The control that saved it. A first probe with a group showed cancellations, which looked like
confirmation. The identical workflow with no
concurrency:key at all cancelled the same way: Gitea auto-cancels supersededpushruns by itself, and that does not extend topull_request_target. Without the control, a no-op would have shipped as a solution. cancel-in-progress: trueis deliberately untried — cancellation is precisely what this workflow's header refuses, since a cancelled run leaves an exempt PR statusless with nothing to re-trigger it.
Why the count, and why abstaining is safe
The timeline records each retarget as a change_target_branch event. Verified on the route-1
reproduction PR #703 (exactly two: main → probe698/base-S and back) against PR #717 as a
zero-control. The branch name aliases under main → S → main; the count cannot.
The standing objection to refuse-on-motion is that it strands the PR — fatal for updated_at,
harmless here, and not by degree: a retarget always fires edited, so the abstaining run is
guaranteed a successor. It defers rather than declines.
What cold review caught (both easy to reintroduce)
The mark must be taken BEFORE the last-moment re-read, not merely before the POST. "As late as
possible" is the safer-sounding instinct and is the opposite: a verdict landing between the re-read
and a late mark is invisible to the re-read (already done) and excluded from the post-write check
(id below a mark taken afterwards) — a gap spanning the whole retarget re-count, while the change
claimed one round-trip. Early costs nothing, since id > mark hides pre-existing rows either way.
Pinned structurally, as an order not an output: with the mark late the job still posts and still
repairs in every scenario a stub can pose, and only the arithmetic silently changes.
The repair must be a FIXED POINT or it merely decays more slowly. A repaired status is a machine
pending, indistinguishable to the next run — which re-derived it and posted success again. The
description is now a sentinel no exemption is granted over and is re-written verbatim by every later
run: the first attempt refused the exemption but wrote the GENERIC pending text, erasing its own
marker, so the exemption returned two events later instead of one. Only a re-posted verdict clears it.
What is NOT closed
- A retarget between the final timeline read and the POST. Gitea's status API has no conditional
write, so without compare-and-set this cannot reach zero. The magnitude changed: a permanent
forged green became a transient one of about one round-trip, and that retarget still fires
edited, so a later run re-derives it. - The repair is itself a read-then-write and can be raced; it fails toward
pending. A transport failure on its POST is retried once then fails the job loudly. A human re-posting a BASE-MISMATCHED verdict after a repair does bury the sentinel — that needs a user credential, so it is #697's. - No vocabulary tripwire. If an upgrade renames
change_target_branchor drops it, both counts read0, compare equal, are "trusted", and the protection evaporates silently. Accepted (an analogue of the jq--expectpin needs a live fixture PR), recorded so the silence is chosen. - A timeline over the 20-page cap can never be exempted —
rt_okstaysnoon every run, so only a human verdict clears it and comment-flooding becomes a fail-closed denial of exemption. Negligible at 1000 events; the log says so rather than promising a later run will fix it.