From e30702111fde20a82990d86dbb95d68ed943d2e0 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 29 Aug 2026 23:39:47 +0200 Subject: [PATCH 01/11] fix(849): verify every write, and mark a head nothing could verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate's post-write verification had five routes that all ended the same way — an exemption `success`, or a generic `pending` a later run turns into one, standing over a human `failure`. Two of these were attempted inside #742 and withdrawn, and the withdrawal is what shaped this change. That attempt withheld the exemption by writing a GENERIC `pending`, which is exactly what a later run re-derives into `success` — it moved which run posted the forged green rather than stopping it — and it had no retry path, because this workflow triggers only on `pull_request_target` types, so a transient failure on a PR's last event stalled an exempt PR until a human nudged it. The fix therefore needs two properties at once: sticky, so a later run cannot re-derive it, and reconcilable, so a blip does not cost a head its exemption permanently. Neither the repair sentinel nor a generic `pending` has both, which is why there is now a second sentinel rather than a reuse of the first. What changed: 1. No high-water mark => the exemption is WITHHELD before the POST and the head is marked with the new `UNVERIFIED_DESC` sentinel. Withholding before the write rather than posting and repairing matters because the defect is known in advance: publishing a green to take it back opens a window branch protection, and an already-scheduled auto-merge, can see. 2. Post-write verification runs after EVERY write, not only `success`. A generic `pending` masks a rejection landing in its own write window just as well, and carries no marker, so the next run re-derives it with the human's row now below THAT run's mark. 3. `.description` is type-tested before `startswith`. `(.description // "")` does not replace a NUMBER, so `startswith` hard-errors on one, killing the whole count — the genuine verdict beside the malformed row is lost with it. 4. The retarget count is re-taken AFTER the POST on the exemption path, closing the PERMANENT forged green `ci.verdict-write-retarget-fence` listed as its residual 1. The retarget axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. 5. An unreadable combined-status read retries once and then REPLACES the unknown state instead of declining to write. Declining protects a real verdict and leaves a FORGED one — an off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving it, and the job then went red on a status branch protection does not read. One defect this introduced and fixed on the way: widening the post-write gate to every write made the job match its OWN row, because the machine-sentinel arm selects on a null creator. A run taking the carry-forward path POSTed `$REPAIR_DESC`, then found "a sentinel above the mark", then repaired to the identical description. `--arg own "$desc"` excludes it, by description rather than by id — the id of the row just written is not knowable there. Reconciliation is what bounds the stall: a later run pages `/statuses/{sha}` in full and either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it upgrades to the repair sentinel, clearable only by a human — or finds none and clears it. It is sound because the two endpoints disagree: a masked verdict is invisible on the combined endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. Tests: each fix is paired with a `test_MUTATION_…` proof that restores the exact predecessor text through a new `_run_classify(mutate=…)` knob, whose count assertion is the binding — a clause that has since moved substitutes zero times and fails loudly rather than measuring the unmutated body. Two CHAINED tests feed run N's real output into run N+1, because both sentinels are fixed points and a single hop cannot assert a fixed point: the raced-`pending` repair must survive the run that would otherwise grant the exemption, and the unverified sentinel must not decay while it cannot be reconciled. Docs: new record `ci.verdict-unverified-write-sentinel`; the now-false guarantee prose in `ci.verdict-write-retarget-fence` (its `rule:` frontmatter, the "resolves it" opener, "the fence above closes", the truncating-block claim and residual 1), `ci.exemption-provenance`, `docs/ci-cd.md`, `docs/remote-state-inventory.md` and `CLAUDE.md` corrected by concept rather than by phrase, per the scope boundary recorded on the issue. fixes #849 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF --- .gitea/workflows/review-verdict.yml | 449 +++++++++++-- docs/ci-cd.md | 58 +- docs/decisions/README.md | 3 +- .../records/ci/exemption-provenance.md | 16 +- .../ci/verdict-unverified-write-sentinel.md | 99 +++ .../ci/verdict-write-retarget-fence.md | 31 +- docs/guard-inventory.md | 2 +- docs/remote-state-inventory.md | 4 +- scripts/tests/test_pr_changed_files.py | 630 +++++++++++++++++- 9 files changed, 1177 insertions(+), 115 deletions(-) create mode 100644 docs/decisions/records/ci/verdict-unverified-write-sentinel.md diff --git a/.gitea/workflows/review-verdict.yml b/.gitea/workflows/review-verdict.yml index 1e2cf37cd..7988be09e 100644 --- a/.gitea/workflows/review-verdict.yml +++ b/.gitea/workflows/review-verdict.yml @@ -253,6 +253,30 @@ jobs: # row, so the post-write check stayed silent and the rejection went green a second time. # Found by cold review. Refusing here can only ever withhold an exemption, never grant one. REPAIR_DESC="Human verdict raced this exemption write — re-post the verdict" + # THE SECOND SENTINEL, and the two are NOT interchangeable (ersatztv#849). `REPAIR_DESC` + # asserts a fact — a human verdict existed and this job's write buried it. Most of the + # paths that must withhold an exemption cannot assert that: they are the ones where the + # status history could not be READ, so what happened in the write window is unknown. Using + # `REPAIR_DESC` for those made the job state something it had not established, which is why + # its own `::error::` had to be softened to "no verdict was necessarily overwritten" — a + # message contradicting the description beside it. + # + # STICKY, exactly like `REPAIR_DESC`: `read_existing_verdict` recognises it and the + # classification refuses to grant an exemption over it. That is the property #742's + # withdrawn attempt lacked — it wrote a GENERIC `pending`, which is precisely what a later + # run re-derives into `success`, so it moved which run posted the forged green rather than + # stopping it. + # + # UNLIKE `REPAIR_DESC` IT IS RECONCILABLE, and that is the whole reason for a second state + # rather than reusing the first. `REPAIR_DESC` can only be cleared by a human re-posting, + # because the fact it records (a verdict was lost) stays true forever. "I could not read + # the history" does not: a later run that CAN read it either finds the buried verdict — in + # which case this upgrades to `REPAIR_DESC` — or finds none, in which case the uncertainty + # is genuinely resolved and the sentinel is cleared. That reconciliation is what makes the + # stall bounded instead of permanent (see RECONCILE below), and it is sound because + # `/statuses/{sha}` returns one row per POST: a verdict this job masked on the COMBINED + # endpoint is still in the per-POST history for a later run to find. + UNVERIFIED_DESC="Exemption write could not be verified — re-post the verdict" # WHOSE verdict may be INHERITED (ersatztv#742). Space-separated logins, compared exactly. # # The test this replaces was "the status has a non-null `.creator.login`", which only proves @@ -365,6 +389,30 @@ jobs: gh() { curl -sf -H "Authorization: token $GITEA_TOKEN" "$@"; } + # WRITE A STICKY `pending` SENTINEL, retrying once. Factored out because three call sites + # now need it (ersatztv#849) and each one runs at a moment where a silent failure is the + # worst outcome: an unreadable combined read, the post-write repair, and the post-POST + # retarget re-check. A failure returns non-zero rather than exiting, so every caller can + # say what was left standing — `set -e` would otherwise kill the step mid-repair, after a + # green was written and with nothing left to re-attempt. + # + # `>/dev/null 2>&1` on the POST: `gh` is `curl -sf`, whose stderr on an HTTP error is + # noise, and the caller's own message is the diagnostic. + repair_status_to() { # $1 = the sentinel description to write + local body + body=$(jq -n --arg c "$CONTEXT" --arg u "$PR_URL" --arg d "$1" \ + '{state:"pending", context:$c, description:$d, target_url:$u}') + if gh -X POST -H 'Content-Type: application/json' -d "$body" \ + "$BASE_URL/repos/$REPO/statuses/$SHA" >/dev/null 2>&1; then + return 0 + fi + if gh -X POST -H 'Content-Type: application/json' -d "$body" \ + "$BASE_URL/repos/$REPO/statuses/$SHA" >/dev/null 2>&1; then + return 0 + fi + return 1 + } + # DEFINED HERE, BEFORE ANY USE. An earlier round defined these AFTER the classification # chain that calls them, so `count_matching` was `command not found` on every run, the # PROTECTED branch silently never fired, and three "protected path" tests still passed — @@ -436,8 +484,7 @@ jobs: # (not per context) and pages at 50, so a head with a few CI reruns can push an earlier verdict # off the first page. read_existing_verdict() { - local json row rv - json=$(gh "$BASE_URL/repos/$REPO/commits/$SHA/status?limit=100") || json="" + local json row rv try # `.statuses` IS `null`, NOT `[]`, ON A HEAD WITH NO STATUSES YET — the same nil-slice # serialization as the timeline terminator, found by cold review of the fix for that one # (ersatztv#751). Measured on this instance: PR #739's head 5fa672e2 returns @@ -471,19 +518,70 @@ jobs: # context cannot recur on a later page. When the row is absent, page 2 is read: any rows # there mean the list is longer than one page and the verdict could be sitting beyond it, # so this refuses rather than concluding absence. Cap-independent by construction. - st_kind=$(printf '%s' "$json" | jq -r '.statuses | type' 2>/dev/null) || st_kind="" - # NUMBER, not `jq -r` text: `jq -r` renders the JSON number 0 and the JSON string "0" - # identically, so a schema-corrupted `"total_count": "0"` would satisfy a string compare - # (cold review reproduced this). Requiring the type as well pins the accept path to a real - # numeric zero. - st_total=$(printf '%s' "$json" | jq -r 'if (.total_count | type) == "number" then (.total_count | tostring) else "x" end' 2>/dev/null) || st_total="x" + # RETRIED ONCE (ersatztv#849 route 5), for the same reason `page_statuses` retries and + # with the same honesty about what that buys: a second immediate request absorbs a + # momentary blip, not an outage. It matters more here than it did before, because the + # branch below no longer merely declines to write — it now REPLACES the head's status, + # so a transient failure that used to cost nothing would cost a reviewed PR its verdict. + # The retry is what keeps that cost attached to a persistent failure only. + # + # The loop re-derives the validity triple on each attempt rather than only the body: + # a 502 HTML page and a schema-corrupt JSON body are both "cannot tell", and retrying + # one but not the other would make the direction depend on how the failure arrived. + json="" + st_kind="" + st_total="x" st_ok=no - case "$st_kind" in - array) st_ok=yes ;; - null) if [ "$st_total" = "0" ]; then st_ok=yes; fi ;; - esac + for try in 1 2; do + json=$(gh "$BASE_URL/repos/$REPO/commits/$SHA/status?limit=100") || json="" + st_kind=$(printf '%s' "$json" | jq -r '.statuses | type' 2>/dev/null) || st_kind="" + # NUMBER, not `jq -r` text: `jq -r` renders the JSON number 0 and the JSON string "0" + # identically, so a schema-corrupted `"total_count": "0"` would satisfy a string compare + # (cold review reproduced this). Requiring the type as well pins the accept path to a real + # numeric zero. + st_total=$(printf '%s' "$json" | jq -r 'if (.total_count | type) == "number" then (.total_count | tostring) else "x" end' 2>/dev/null) || st_total="x" + st_ok=no + case "$st_kind" in + array) st_ok=yes ;; + null) if [ "$st_total" = "0" ]; then st_ok=yes; fi ;; + esac + if [ -n "${json//[[:space:]]/}" ] && [ "$st_ok" = yes ]; then break; fi + # Only BETWEEN attempts — sleeping after the last one delays a failure nobody is + # waiting on. + if [ "$try" -eq 1 ]; then sleep 1; fi + done if [ -z "${json//[[:space:]]/}" ] || [ "$st_ok" != yes ]; then - echo "::error::Could not read existing commit statuses for ${SHA:0:7} (.statuses was '${st_kind:-unparseable}', total_count '${st_total}'). Refusing to post anything rather than risk overwriting an existing verdict." + # REPLACE THE UNKNOWN STATE, DO NOT JUST DECLINE TO WRITE (ersatztv#849 route 5). + # + # "Refusing to post anything" protects a verdict this job cannot see — which is right + # when the head carries a REAL verdict, and exactly wrong when it carries a FORGED one. + # An off-list credential that has already POSTed `review-verdict/h10=success` is the + # status #742 exists to revoke; revocation happens by re-deriving the row, which is the + # one thing an unreadable read stops. The job then went red — and its own job status is + # NOT a required check, so branch protection still saw the green. Uncertainty resolved + # toward SUCCESS, on the single path where that is never acceptable. + # + # So the unknown state is durably replaced with the sticky unverified sentinel. This is + # NOT the same trade as overwriting a verdict blindly: `/statuses/{sha}` keeps one row + # per POST, so a genuine verdict masked here is still in the history, and the + # RECONCILE step on the next run finds it and upgrades to the repair sentinel — which + # tells the reviewer to re-post rather than silently un-approving them. + # + # THE COST, stated rather than implied: a persistent combined-endpoint failure on a + # genuinely approved PR costs that head its verdict until a human re-posts it. That is + # a stall, clearable in one command, and it is the direction this whole job resolves + # uncertainty in everywhere else. + # + # SCOPED TO AN UNREADABLE BODY, deliberately. The page-2 completeness probe below also + # refuses, and is deliberately NOT routed here: it fires precisely when NO row for this + # context was on page 1, i.e. when there is no green of any provenance for this job to + # be leaving standing. Its refusal withholds a conclusion; this branch replaces a state. + echo "::error::Could not read existing commit statuses for ${SHA:0:7} (.statuses was '${st_kind:-unparseable}', total_count '${st_total}') after a retry, so any ${CONTEXT} already on this head — including one posted by an account this gate does not accept verdicts from — can neither be read nor re-derived." + if repair_status_to "$UNVERIFIED_DESC"; then + echo "::error::Replaced ${CONTEXT} on ${SHA:0:7} with the unverified-write sentinel rather than leaving an unreadable state standing. A later run reconciles this automatically; to clear it now, post a verdict: scripts/post-review-verdict.sh ${PR} " + else + echo "::error::COULD NOT WRITE THE UNVERIFIED SENTINEL to ${SHA:0:7}. Whatever ${CONTEXT} this head carries is standing unread. Post a verdict immediately: scripts/post-review-verdict.sh ${PR} " + fi exit 1 fi # `// []` so the null case cannot hard-error here under `set -e` once it is accepted above. @@ -556,9 +654,17 @@ jobs: ex_human=no ex_attributable=no ex_repair=no + ex_unverified=no case "$ex_desc" in "$REPAIR_DESC"*) ex_repair=yes ;; esac + # THE TWO SENTINELS ARE TESTED SEPARATELY, never as one "is it a sentinel" flag. They + # carry different facts and clear by different means (see UNVERIFIED_DESC above), and a + # single flag would let the reconciliation below clear the one that must never be cleared + # by anything but a human. + case "$ex_desc" in + "$UNVERIFIED_DESC"*) ex_unverified=yes ;; + esac case "$ex_desc" in "Review-verdict:"*) # ATTRIBUTABLE, not yet "inheritable" — the allow-list is applied below, after the @@ -1084,6 +1190,67 @@ jobs: echo "${CONTEXT} is '${ex_state}' on ${SHA:0:7} and is not being inherited as a reviewer verdict for this base (creator='${ex_creator:-null}', allow-list='${H10_REVIEWERS}', description='${ex_desc}') — re-deriving it from the PR's current state. If '${ex_creator:-that account}' is a reviewer, add them to H10_REVIEWERS in .gitea/workflows/review-verdict.yml." fi + # --- RECONCILE AN UNVERIFIED WRITE (ersatztv#849) ------------------------------------ + # This is what makes the unverified sentinel a BOUNDED stall rather than the permanent one + # that got #742's attempt withdrawn. The sentinel records "a write on this head could not + # be checked against the status history", and unlike the repair sentinel that fact expires: + # a run that CAN read the history settles the question either way. + # + # WHY THE PER-POST HISTORY ANSWERS IT AND THE COMBINED ENDPOINT CANNOT. The combined + # endpoint returns the latest row per context, and the latest row IS the sentinel — the + # verdict this job may have masked is underneath it and structurally invisible there. + # `/statuses/{sha}` returns one row per POST, so a masked verdict is still in that list. + # This is the same asymmetry the post-write check relies on, used for the opposite purpose. + # + # DELIBERATELY BROADER THAN THE INHERITANCE TEST, and broader than the base binding too: no + # `$H10_REVIEWERS` membership, no `(base: …)` match. The two errors are not symmetric. + # Counting a row that is not really a verdict for this base upgrades to the repair sentinel + # — a stall a human clears with one command. MISSING one clears the sentinel and lets a + # later run exempt a head that carries a buried human rejection, which is the permanent + # forged green this whole issue is about. Over-counting is the affordable error. + # + # ONLY A COMPLETE WALK MAY CLEAR IT. `ph_ok != yes` means the history is still unreadable, + # so the question the sentinel asks is still open and it is carried forward unchanged by the + # classification below. An unreadable reconciliation must never resolve to "nothing buried". + if [ "$ex_unverified" = yes ]; then + echo "${CONTEXT} on ${SHA:0:7} carries the unverified-write sentinel from an earlier run — reconciling it against the per-POST status history." + page_statuses + if [ "$ph_ok" != yes ]; then + echo "::warning::Could not read a complete status history for ${SHA:0:7}, so the unverified write from an earlier run still cannot be reconciled. Carrying the sentinel forward; ${CONTEXT} stays pending and no exemption is granted." + else + # `.description` IS TYPE-TESTED BEFORE `startswith`, exactly as the post-write filter + # does it and for the same reason: a row whose description is a number makes + # `startswith` hard-error, jq exits 5, and the count comes back unusable. Dropping the + # malformed row keeps a well-formed verdict beside it countable. + buried=$(printf '%s' "$ph_rows" | jq -r --arg c "$CONTEXT" \ + '[.[] | select(type == "object") + | select(.context? == $c) + | select(((.creator | type) == "object") and ((.creator.login // "") != "")) + | select(((.description | type) == "string") + and (.description | startswith("Review-verdict:")))] | length') || buried="" + case "$buried" in + ''|*[!0-9]*) + # AN UNUSABLE COUNT IS NOT ZERO. Same direction as every other "cannot tell" on + # this path: the sentinel stands. + echo "::warning::The reconciliation count for ${SHA:0:7} came back '${buried}' instead of a number, so the unverified write still cannot be reconciled. Carrying the sentinel forward." + ;; + 0) + ex_unverified=no + echo "Reconciled: the complete status history for ${SHA:0:7} carries no verdict row, so the earlier unverified write masked nothing. Clearing the sentinel and classifying normally." + ;; + *) + # UPGRADE, never clear. A verdict IS in the history and the sentinel is sitting on + # top of it, so the earlier unverified write did mask a human decision. That is now + # an established fact rather than an open question, which is exactly the repair + # sentinel's meaning — and unlike this one, it may only be cleared by a human. + ex_unverified=no + ex_repair=yes + echo "::error::Reconciled: the status history for ${SHA:0:7} carries ${buried} verdict row(s) underneath an unverified exemption write, so a human verdict was masked. Upgrading to the repair sentinel. Re-post it with: scripts/post-review-verdict.sh ${PR} " + ;; + esac + fi + fi + # --- Changed files: the SHARED enumeration, or no exemption. ------------------------- # `scripts/pr-changed-files.sh` (from the BASE checkout) owns every guard this job used to # carry inline and six it did not: CR/LF rejection, `..` rejection, a closed `.status` @@ -1205,6 +1372,12 @@ jobs: exempt=no reason="a human verdict raced a previous exemption write on this head and was overwritten — this head needs a re-posted verdict, not another exemption" fi + if [ "$ex_unverified" = yes ]; then + # Reached only when the reconciliation above could not read the history. Granting an + # exemption now would be granting it over a write nobody has ever been able to check. + exempt=no + reason="an earlier write on this head could not be verified against the status history, and this run could not reconcile it — no exemption until that history can be read" + fi if [ "$exempt" = yes ]; then state=success desc="Exempt: $reason" @@ -1222,6 +1395,17 @@ jobs: # classification. state=pending desc="$REPAIR_DESC" + elif [ "$ex_unverified" = yes ]; then + # CARRY THIS SENTINEL FORWARD TOO, and for the same fixed-point reason: writing the + # generic description here would erase the marker the refusal above depends on, and the + # next run would see an ordinary machine `pending` and re-derive it into `success`. That + # is precisely the defect that got #742's generic-`pending` attempt withdrawn. + # + # ORDERED AFTER `ex_repair`, never before it. The reconciliation can turn this state into + # that one but never the reverse, so when both are somehow set the stronger fact — a + # verdict was definitely lost, clearable only by a human — must be the one written. + state=pending + desc="$UNVERIFIED_DESC" else state=pending desc="Awaiting review verdict for ${SHA:0:7}" @@ -1302,17 +1486,43 @@ jobs: *) max_id_before=$mark ;; esac else - # NOT FATAL, BUT NOT HARMLESS EITHER, and an earlier comment here overstated it by - # calling the POST "still correct": without a mark the post-write race check is skipped - # entirely, so a verdict landing in the write window is neither detected nor repaired. - # Recorded so the degradation is visible in the log. A fix was attempted on this branch - # and WITHDRAWN — withholding the exemption writes a GENERIC `pending`, which a later run - # re-derives into `success` anyway, and this workflow has no retry trigger, so it stalls - # exempt PRs instead. It needs a sticky unverified-write sentinel: ersatztv#849. - echo "::warning::Could not establish a status high-water mark for ${SHA:0:7}; the post-write race check will be skipped." + # NO MARK MEANS NO POST-WRITE CHECK AT ALL, which is why the write itself changes below + # rather than this branch merely warning (ersatztv#849 route 1). Without a mark a verdict + # landing in the write window is neither detected nor repaired. + # + # THE #742 ATTEMPT AT THIS WAS WITHDRAWN and the reasons are what shaped the fix: it wrote + # a GENERIC `pending`, which is exactly what a later run re-derives into `success`, so it + # moved which run posted the forged green rather than stopping it; and this workflow + # triggers only on `pull_request_target` types — no `schedule`, no `workflow_dispatch` — + # so a transient failure on a PR's last event left an exempt PR stalled with no retry. + # Both are addressed by the sentinel being STICKY (a later run cannot re-derive it) and + # RECONCILABLE (a later run that can read the history clears it without a human). + echo "::warning::Could not establish a status high-water mark for ${SHA:0:7}; nothing can verify this run's write against the status history." max_id_before=-1 fi + # WITHHOLD THE EXEMPTION WHEN NOTHING CAN VERIFY IT (ersatztv#849 route 1). + # + # POSITIONED HERE, AFTER THE MARK, NOT WITH THE CLASSIFICATION. `$max_id_before` does not + # exist up there and referencing it early is an unbound variable under `set -u` — a job + # that dies before posting anything, on every PR. + # + # NOT POST-THEN-REPAIR, which is what the `success` path does when the mark DOES exist and + # the check then fires. Here the defect is known BEFORE the write, so there is no reason to + # publish a green and take it back: a repair leaves a window in which branch protection can + # see the exemption, and an already-scheduled auto-merge can fire inside it. + # + # ONLY `success` IS DOWNGRADED. A `pending` write cannot be verified either, and that is + # handled where it belongs — by the post-write check now covering every write (route 2). + # Rewriting a generic `pending` into the sentinel HERE would make every ordinary PR sticky + # whenever this endpoint blinks, which withholds nothing (an unreviewed PR is blocked + # already) and costs the next docs-only run its exemption for no gain. + if [ "$state" = "success" ] && [ "$max_id_before" -lt 0 ]; then + echo "::error::No status high-water mark could be established for ${SHA:0:7}, so an exemption posted now could not be checked for a human verdict landing in the write window. Withholding it and writing the unverified-write sentinel instead. A later run reconciles this automatically once the history can be read; to clear it now, post a verdict: scripts/post-review-verdict.sh ${PR} " + state=pending + desc="$UNVERIFIED_DESC" + fi + # LAST-MOMENT RE-READ (ersatztv#706). Classification takes several API round-trips, and a # reviewer can post a verdict during them — most dangerously a `failure`, which this job would # then overwrite with an exemption `success`, turning an explicit human rejection green. The @@ -1361,6 +1571,26 @@ jobs: exit 0 fi + # THE SAME RULE FOR THE UNVERIFIED SENTINEL (ersatztv#849), with one clause the repair + # guard above does not need. + # + # `[ "$ex_desc" != "$pre_desc" ]` IS LOAD-BEARING, and leaving it out deadlocks the PR. The + # repair guard gets its "arrived DURING this run" test for free: a repair sentinel present + # at the FIRST read forces `desc="$REPAIR_DESC"`, so the guard cannot fire on a + # pre-existing one. That is NOT true here — the reconciliation above deliberately CLEARS a + # pre-existing unverified sentinel and lets this run overwrite it, so a presence-only test + # would abstain on the very row this run just decided to replace, on this run and on every + # later one. Comparing against the snapshot is what separates "already there, and being + # replaced on purpose" from "written by an overlapping run while we classified". + # + # A run whose own write IS a sentinel is exempt from the guard: replacing a sentinel with a + # sentinel loses nothing, and the repair sentinel outranks this one. + if [ "$ex_unverified" = yes ] && [ "$ex_desc" != "$pre_desc" ] \ + && [ "$desc" != "$REPAIR_DESC" ] && [ "$desc" != "$UNVERIFIED_DESC" ]; then + echo "::notice::An unverified-write sentinel was written on ${SHA:0:7} while this job was classifying, so another run posted something it could not check. This run would overwrite that record with an unmarked status a later run could re-derive into an exemption — posting NOTHING and leaving the sentinel standing." + exit 0 + fi + # THE FENCE ITSELF (ersatztv#706 race 1; head axis ersatztv#803/#664). Re-count BOTH # mutation axes as late as possible and refuse to write anything if either moved since this # run began — the PR was retargeted, or its head branch was pushed. See the long note at @@ -1423,15 +1653,19 @@ jobs: # — turning an explicit human REJECTION green, which is the worst outcome this gate can # produce and strictly worse than any stall. # - # So verify AFTERWARDS and repair in the safe direction. This runs ONLY on the `success` - # path — and THAT RESTRICTION IS A KNOWN HOLE, not the optimisation an earlier comment here - # claimed. The claim was "`pending` cannot turn a rejection green, so the only write that - # can cause the damage is the exemption `success`". False: a GENERIC `pending` (the one a - # run writes after a transient enumeration failure) masks a raced rejection just as well, - # and because it is not the sticky sentinel, the NEXT run re-derives it into an exemption - # `success` — with the human's row now below that run's high-water mark, invisible. The - # damage arrives one event later instead of immediately. Tracked as ersatztv#849; the fix - # is to verify every write, not to widen this condition alone. + # So verify AFTERWARDS and repair in the safe direction. THIS RUNS FOR EVERY WRITE, not + # only the exemption `success` (ersatztv#849 route 2). It was `success`-only, on the claim + # that "`pending` cannot turn a rejection green, so the only write that can cause the + # damage is the exemption `success`". That claim is false, and the counter-example is + # ordinary: a docs-only PR hits a transient enumeration failure, so the run writes the + # GENERIC `pending` — which masks a rejection landing in its own write window exactly as a + # `success` would, and got no verification because of the state test. The next run then + # sees an ordinary machine `pending`, re-derives it into an exemption `success`, and the + # human's row is now BELOW that run's high-water mark and invisible. The damage arrives one + # event later rather than immediately, which is not the same thing as not arriving. + # + # THE COST IS ONE EXTRA WALK OF `/statuses/{sha}` ON EVERY RUN, which is two requests on an + # ordinary head. Gating it on `success` to save that is what the paragraph above is about. # # WHY A DIFFERENT ENDPOINT. Everywhere else this job reads the COMBINED endpoint # (`/commits/{sha}/status`), which returns the LATEST status per context — and that is now @@ -1479,7 +1713,7 @@ jobs: # change nothing about the gate's state while turning a routine API hiccup into a red run. # It is reported loudly and REPAIRED (ersatztv#763) — the old treatment left it alone, which # is the wrong direction on this path; see below. - if [ "$state" = "success" ] && [ "$max_id_before" -ge 0 ]; then + if [ "$max_id_before" -ge 0 ]; then page_statuses # AN EMPTY HISTORY IS IMPOSSIBLE HERE, so it is not evidence. This job has just POSTed to # this sha, and `/statuses/{sha}` returns one row per POST, so a well-formed response @@ -1507,10 +1741,20 @@ jobs: # through "could not be read completely" would print that beside a walk that DID complete, # on a validated terminator — the answer was impossible, not unreadable, and an operator # reading a sticky sentinel needs to know which. + # WHICH SENTINEL THE REPAIR WRITES IS PART OF THE ANSWER, not a detail (ersatztv#849). + # `REPAIR_DESC` asserts that a human verdict existed and was buried; only the arm that + # actually COUNTED such a row may assert it. Every "I could not check" arm writes the + # unverified sentinel instead — which blocks the merge just as hard, is just as sticky + # against a later re-derivation, and unlike the repair sentinel can be reconciled away by + # the next run instead of requiring a human. Before this the uncertainty arms wrote + # `REPAIR_DESC` and then had to soften their own message to "no verdict was necessarily + # overwritten", i.e. the description and the log contradicted each other. raced_why=human + repair_desc="$REPAIR_DESC" if [ "$ph_ok" = yes ] && [ "$ph_len_after" -eq 0 ]; then echo "::warning::The status history for ${SHA:0:7} came back empty after this job posted to it, which cannot be true, so a raced verdict could not be ruled out. Repairing ${CONTEXT} to pending." raced_why="the status history came back empty after this job posted to it" + repair_desc="$UNVERIFIED_DESC" raced=1 elif [ "$ph_ok" != yes ]; then # FAIL CLOSED, AND THAT IS A BEHAVIOUR CHANGE (ersatztv#763). This used to warn and leave @@ -1525,8 +1769,9 @@ jobs: # would have cost the exemption on every busy PR. Now "could not establish" means a # genuine API failure that survived a retry, or a history past the page cap (950 rows, # since the twentieth request must be the empty terminator). - echo "::warning::Could not establish a complete status history for ${SHA:0:7} after posting, so a human verdict landing during the write window could not be ruled out. Repairing ${CONTEXT} to pending rather than leaving an exemption green on an unverified head." + echo "::warning::Could not establish a complete status history for ${SHA:0:7} after posting, so a human verdict landing during the write window could not be ruled out. Repairing ${CONTEXT} to pending rather than leaving an unverified write standing." raced_why="the status history could not be read completely" + repair_desc="$UNVERIFIED_DESC" raced=1 else # `.id > $since` is what confines this to the write window. Our OWN row is excluded twice @@ -1579,14 +1824,29 @@ jobs: # rejection. Reproduced (ersatztv#763). `(.creator | type) == "object"` short-circuits jq's # `and` before the index, so a malformed row is dropped from the count instead of # killing the job, and a well-formed verdict beside it is still counted. - raced=$(printf '%s' "$ph_rows" | jq -r --arg c "$CONTEXT" --argjson since "$max_id_before" --arg rd "$REPAIR_DESC" \ + # `$own` EXCLUDES THIS JOB'S OWN ROW FROM THE SENTINEL ARM (ersatztv#849). The human + # arm never needed it — an Actions-token POST records `creator: null`, measured — but + # the sentinel arm matches on a NULL creator by design, so once this block started + # running for every write it began matching the write it is checking: a run taking the + # carry-forward path POSTs `$REPAIR_DESC`, then found "a sentinel above the mark", + # then repaired to the identical description. Harmless in state, wrong in the log, and + # a second POST on every repaired head. + # + # EXCLUDING BY DESCRIPTION, NOT BY ID, because the id of the row just written is not + # knowable here — the POST response is discarded and a re-read cannot distinguish our + # row from an identical one. Excluding by description also drops another run that wrote + # the SAME sentinel, and that is correct rather than merely tolerable: both runs + # converged on the same fixed point, so there is nothing to repair. + raced=$(printf '%s' "$ph_rows" | jq -r --arg c "$CONTEXT" --argjson since "$max_id_before" --arg rd "$REPAIR_DESC" --arg own "$desc" \ '[.[] | select(type == "object") | select(.context? == $c) | select(((.id | numbers) // 0) > $since) | select( (((.creator | type) == "object") and ((.creator.login // "") != "") - and (((.description // "") | startswith("Review-verdict:")))) - or ((.creator == null) and ((.description // "") == $rd)) + and ((.description | type) == "string") + and (.description | startswith("Review-verdict:"))) + or ((.creator == null) and ((.description // "") == $rd) + and ((.description // "") != $own)) )] | length') || raced="" # NO "CURRENCY WITNESS" HERE, AND THAT IS A DELIBERATE WITHDRAWAL (ersatztv#763). # A draft of this change also asserted that the post-write read must show at least one @@ -1622,40 +1882,127 @@ jobs: # in combination (disarming it alone reddens nothing — measured, not assumed). echo "::warning::Post-write verification for ${SHA:0:7} returned '${raced}' instead of a count, so a raced verdict could not be ruled out; repairing ${CONTEXT} to pending." raced_why="the post-write verification did not return a count" + repair_desc="$UNVERIFIED_DESC" raced=1 ;; esac + + # THE OTHER RUN'S UNVERIFIED SENTINEL (ersatztv#849), the exact analogue of the repair + # sentinel already counted above and reachable the same way: two runs overlap, B writes + # the unverified sentinel because IT could not check its own write, and A — whose mark was + # taken earlier — then posts straight over it. A's count above finds nothing, because the + # unverified sentinel is neither a `Review-verdict:` row nor `$REPAIR_DESC`, so A leaves an + # unmarked status where a run had recorded that something on this head is unchecked. A + # later run re-derives that into an exemption. + # + # PROBED ONLY WHEN NOTHING STRONGER RACED. If a human row or a repair sentinel is above + # the mark, that fact already forces `REPAIR_DESC`, which outranks this one; asking would + # cost a jq call and could only weaken the answer. + # + # It cannot false-fire on a sentinel this run itself is replacing: one present at the + # FIRST read is either cleared by the reconciliation (and then it is BELOW the mark, which + # is taken afterwards) or carried forward as this run's own description. Either way it is + # not a row above the mark that this run did not expect. + if [ "$raced" -eq 0 ]; then + raced_unverified=$(printf '%s' "$ph_rows" | jq -r --arg c "$CONTEXT" --argjson since "$max_id_before" --arg ud "$UNVERIFIED_DESC" --arg own "$desc" \ + '[.[] | select(type == "object") + | select(.context? == $c) + | select(((.id | numbers) // 0) > $since) + | select((.creator == null) and ((.description // "") == $ud) + and ((.description // "") != $own))] | length') || raced_unverified="" + case "$raced_unverified" in + ''|*[!0-9]*) + echo "::warning::The unverified-sentinel probe for ${SHA:0:7} returned '${raced_unverified}' instead of a count, so it could not be ruled out that this write replaced another run's sentinel; repairing ${CONTEXT} to pending." + raced_why="the unverified-sentinel probe did not return a count" + repair_desc="$UNVERIFIED_DESC" + raced=1 ;; + 0) ;; + *) + raced_why="another run recorded an unverified write on this head and this run posted over it" + repair_desc="$UNVERIFIED_DESC" + raced=1 ;; + esac + fi # NORMALISE FIRST, THEN ACT ONCE. Setting `raced=1` inside a `case` arm and expecting the # repair to happen in a SIBLING arm does not work — the case has already dispatched. That # is why the unusable-count branch above sets the flag and the decision lives out here, # where every path that concluded "raced" reaches the same single writer. if [ "$raced" -gt 0 ]; then # The last-moment re-read found no human verdict, so any row present now was - # written during the window and has just been masked by the exemption above. + # written during the window and has just been masked by this job's write. # DO NOT ASSERT AN OVERWRITE THAT MAY NOT HAVE HAPPENED (ersatztv#763). This # message used to be unconditional, so when the old page-2 probe repaired on mere # uncertainty it reported a human verdict as overwritten when none existed — which # is what made the live PR #761 stall undiagnosable from the status alone. + # + # STATE-NEUTRAL WORDING since ersatztv#849: this block now also runs after a `pending` + # write, so a message naming "this exemption write" would be wrong on the very path + # that was added, and wrong in the direction of understating what happened. if [ "$raced_why" = human ]; then - echo "::error::A human ${CONTEXT} verdict landed on ${SHA:0:7} while this job was writing its exemption, and was overwritten. Downgrading to 'pending' so an explicit human decision cannot be silently green. Re-post it with: scripts/post-review-verdict.sh ${PR} " + echo "::error::A human ${CONTEXT} verdict landed on ${SHA:0:7} while this job was writing its '${state}' status, and was overwritten. Downgrading to 'pending' so an explicit human decision cannot be silently green, and marking the head so a later run cannot re-derive it. Re-post it with: scripts/post-review-verdict.sh ${PR} " else - echo "::error::Could not verify that no human ${CONTEXT} verdict raced this exemption write on ${SHA:0:7} — ${raced_why}. Downgrading to 'pending' rather than leaving an unverified exemption green; no verdict was necessarily overwritten. Clear it with: scripts/post-review-verdict.sh ${PR} " + echo "::error::Could not verify that no human ${CONTEXT} verdict raced this job's '${state}' write on ${SHA:0:7} — ${raced_why}. Downgrading to 'pending' rather than leaving an unverified write standing; no verdict was necessarily overwritten. Clear it with: scripts/post-review-verdict.sh ${PR} " fi - repair=$(jq -n --arg c "$CONTEXT" --arg u "$PR_URL" --arg d "$REPAIR_DESC" \ - '{state:"pending", context:$c, description:$d, target_url:$u}') - # A failure HERE leaves the forged green standing, so it is retried once and then - # screams. `set -e` would otherwise kill the job silently, after the success was - # written and with nothing left to re-attempt. - if ! gh -X POST -H 'Content-Type: application/json' -d "$repair" \ - "$BASE_URL/repos/$REPO/statuses/$SHA" >/dev/null 2>&1; then - if ! gh -X POST -H 'Content-Type: application/json' -d "$repair" \ - "$BASE_URL/repos/$REPO/statuses/$SHA" >/dev/null 2>&1; then - echo "::error::COULD NOT REPAIR ${CONTEXT} on ${SHA:0:7}. An exemption 'success' is standing on a head whose write window could not be cleared (${raced_why}). Post a verdict immediately: scripts/post-review-verdict.sh ${PR} " - exit 1 - fi + # A failure HERE leaves the unverified write standing, so `repair_status_to` retries + # once and this screams if both attempts fail. `set -e` would otherwise kill the job + # silently, after the write and with nothing left to re-attempt. + if ! repair_status_to "$repair_desc"; then + echo "::error::COULD NOT REPAIR ${CONTEXT} on ${SHA:0:7}. A '${state}' status this job could not verify is standing on a head whose write window could not be cleared (${raced_why}). Post a verdict immediately: scripts/post-review-verdict.sh ${PR} " + exit 1 fi - echo "Repaired ${CONTEXT} to pending on ${SHA:0:7}." + echo "Repaired ${CONTEXT} to pending on ${SHA:0:7} (${repair_desc})." state=pending + desc="$repair_desc" + fi + fi + + # --- THE POST-POST RETARGET RE-CHECK (ersatztv#849 route 3) -------------------------- + # The fence before the POST narrows "writing while overtaken". It does nothing about being + # overtaken AFTER writing, and that second window is the one that leaves a PERMANENT forged + # green rather than a transient one: + # + # `main` carries a real `failure` for head H. The PR is retargeted to a scratch base where + # H is docs-only; run S classifies, derives `success`, and passes its final fence check. + # The PR is retargeted BACK to `main` while S is paused before its POST. The successor run + # M — fired by that retarget — sees the base-matching `failure`, short-circuits, and posts + # nothing. S then resumes and posts its stale `success`. The rejection predates S's + # high-water mark, so the post-write check above cannot see it, and NO EVENT REMAINS to + # reclassify. `ci.verdict-write-retarget-fence` described the residual as the + # sub-round-trip window no API without compare-and-set can close; this is wider than that. + # + # RE-COUNTING AFTER THE POST closes the induction. A retarget landing after this check + # necessarily queues a successor that STARTS after the stale `success` already exists — and + # a machine-written `success` is re-derived, not inherited, so that successor changes the + # answer. A retarget landing before it is caught here and the green never stands. + # + # ONLY WHEN AN EXEMPTION `success` IS WHAT STANDS. A `pending` cannot be a forged green, and + # `$state` is re-read after the repair above precisely so a write already downgraded is not + # re-examined. This also bounds the cost: the third timeline walk is paid by exempt PRs only. + # + # THE RETARGET AXIS ONLY — the push axis is deliberately NOT re-checked here, and that is + # not an oversight. A push after the POST moves the head, so this status is no longer on the + # PR's head and cannot gate its merge; a retarget changes the effective DIFF while the sha + # stays, which is the whole reason the status remains authoritative for a diff it no longer + # describes. Fencing pushes here would instead punish the ordinary case — a contributor + # pushing right after the run — by stranding a sentinel on a sha that later becomes the head + # again after a revert. + # + # AN UNTRUSTED COUNT REPAIRS TOO. The pre-POST fence already refuses to post `success` on an + # untrusted count, so reaching here with `success` means both earlier counts were trusted; a + # third read that cannot be trusted is a fresh failure, and "I cannot tell whether the base + # moved" must not resolve to leaving a green. It resolves to the RECONCILABLE sentinel, so a + # transient timeline failure costs the exemption only until the next run. + if [ "$state" = "success" ]; then + count_pr_mutations + if [ "$rt_ok" != yes ] || [ "$rt_count" -ne "$retargets_before" ]; then + echo "::error::PR #${PR} was retargeted, or its retarget count became unreadable, AFTER this job posted ${CONTEXT}=success on ${SHA:0:7} (${retargets_before} -> ${rt_count}, trusted=${rt_ok}). The exemption was computed against a base the PR may no longer target, and a status is per-sha, so it would stand over a diff it does not describe. Replacing it with the unverified-write sentinel." + if ! repair_status_to "$UNVERIFIED_DESC"; then + echo "::error::COULD NOT REPLACE ${CONTEXT} on ${SHA:0:7} after a post-write retarget. An exemption 'success' is standing on a head whose base may have changed. Post a verdict immediately: scripts/post-review-verdict.sh ${PR} " + exit 1 + fi + echo "Replaced ${CONTEXT} with the unverified-write sentinel on ${SHA:0:7}." + state=pending + desc="$UNVERIFIED_DESC" fi fi diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 6009f6fb0..8879d72c9 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -1593,8 +1593,9 @@ membership). falls through to re-derivation. - The **last-moment re-read** reads `ex_attributable`, because it asks the opposite question — "did a reviewer post a verdict while we were classifying". Narrowing it makes the job stop abstaining and - post its exemption over the row, and the post-write repair does not cover that: it is skipped - whenever the high-water mark could not be established. It additionally requires the + post its exemption over the row, and the post-write repair would not cover that — it is skipped + whenever the high-water mark could not be established, which since ersatztv#849 is also the case in + which no exemption is posted at all. It additionally requires the state/creator/description triple to have *changed* since the first read, because the two calls no longer compute an identical predicate and "changed" can no longer be inferred from "fired". - The **post-write raced check** stays broad on `creator != null`. Not because narrowing it would let @@ -1700,9 +1701,19 @@ its successor. If the count can't be established (unreadable timeline, paging that never reached a validated empty page), only the exemption `success` is withheld; `pending` still posts, since it blocks the merge immediately and withholding it would strand ordinary PRs whenever the timeline is unreadable. That is -the right trade but not a free one — a generic `pending` can mask a rejection landing in its own write -window and be re-derived into an exemption `success` later (ersatztv#849). **If an exempt PR is -unexpectedly missing its status after a retarget, this is why** — the job log names the counts. +the right trade but not a free one — a generic `pending` still masks a rejection landing in its own +write window. What made that durable is gone: since ersatztv#849 the post-write check runs after +**every** write, so such a write is repaired to the sticky sentinel instead of being re-derived into +an exemption by a later run. **If an exempt PR is unexpectedly missing its status after a retarget, +this is why** — the job log names the counts. + +**The count is also re-taken AFTER the POST, on the exemption path only** (ersatztv#849, +`ci.verdict-unverified-write-sentinel`). The pre-write fence covers *writing while overtaken*; it never +covered *being overtaken after writing*, which was the worse of the two — a retarget landing after the +final pre-write count left a stale `success` with the `edited` event already consumed by a successor +that short-circuited, so nothing remained to reclassify. The retarget axis only: a push after the POST +moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with +the sha unchanged. Worth knowing before reaching for the obvious alternative: **a concurrency group does not work here**, measured rather than assumed. Gitea 1.25.4 auto-cancels superseded `push` runs on a branch, but *not* @@ -1731,12 +1742,37 @@ arranging two real merges, or adding a throwaway trigger; both cost more than th because nothing branches on it. The `pull_request_target` half the fence actually relies on was **not** re-measured either and is 1.25.4-dated too. -Separately, after posting an exemption `success` the job re-reads the per-POST status history and, if -a human `Review-verdict:` row appeared during the write window, overwrites its own status with -`pending` and logs an error. **That check runs only for a `success` write**, so it does not extend to -a generic `pending` write: that write is not verified, and a rejection it masks is re-derived green -by a later run (ersatztv#849). The repair is -`pending`, never a copy of the human's verdict, which would attribute a human decision to the job. +Separately, after posting **any** status the job re-reads the per-POST status history and, if a human +`Review-verdict:` row appeared during the write window, overwrites its own status with `pending` and +logs an error. It ran only for a `success` write until ersatztv#849, on the claim that a `pending` +cannot turn a rejection green — false, since a generic `pending` masks a rejection just as well and, +carrying no marker, is re-derived into an exemption by the next run with the human's row now below +THAT run's high-water mark. The repair is `pending`, never a copy of the human's verdict, which would +attribute a human decision to the job. + +**Which sentinel the repair writes is part of the answer.** There are two, and they are not +interchangeable (`ci.verdict-unverified-write-sentinel`): + +- `Human verdict raced this exemption write — re-post the verdict` **asserts** that a verdict existed + and a write buried it. Only the arm that actually *counted* such a row may claim it, and only a + human re-posting clears it. +- `Exemption write could not be verified — re-post the verdict` states only what was established — + that the write could not be checked. Every "could not check" arm writes this one: an unreadable or + over-cap history, an impossible empty history, an unusable count, no high-water mark at all, a + post-POST retarget, and an unreadable combined-status read. It is equally sticky, and additionally + **reconcilable**: a later run pages `/statuses/{sha}` in full and either finds a verdict underneath + it — upgrading to the repair sentinel — or finds none and clears it, so a transient API failure does + not cost a head its exemption permanently. + +**No high-water mark means no exemption**, withheld before the POST rather than posted and repaired: +the defect is known in advance, and publishing a green to take it back opens a window branch +protection — and an already-scheduled auto-merge — can see. + +**An unreadable combined-status read replaces the unknown state** rather than merely declining to +write. Declining protects a real verdict and leaves a *forged* one standing, which is what an off-list +`success` is; the job went red on a status branch protection does not read. The read is retried once +first, and nothing is destroyed — `/statuses/{sha}` keeps one row per POST, so the next run's +reconciliation finds a masked verdict and tells the reviewer to re-post. **Both `/statuses/{sha}` reads are PAGED** (ersatztv#763). The history is read twice — before the write for the high-water mark, after it for the race check — and `limit` clamps to `MAX_RESPONSE_ITEMS` diff --git a/docs/decisions/README.md b/docs/decisions/README.md index a4cad78f5..bfbecaf08 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -68,7 +68,8 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `ci.small-lane-git-only` | `runs-on: small` is defined by what a job does (git-only), not its usual runtime; the two `docker build` jobs (docker-build.yml, ci-image.yml) move to `ubuntu-latest` because their worst-case memory, not median runtime, was pinning the small lane's per-slot cap. | 2026-07-20 | [link](records/ci/small-lane-git-only.md) | | `ci.toolchain-image-publish-is-a-dispatch` | A `push` trigger reachable from any ref other than `main` — that is `branches:` AND `tags:`, judged by the ref class it admits and never by which keyword is present — executes ref-supplied YAML, because Gitea resolves a `push` workflow's definition from the pushed ref — so `ci-image.yml` is `branches: [main]`, and publishing a toolchain image from a feature branch is a deliberate `workflow_dispatch` on that branch rather than a side effect of pushing. Be precise about what a `branches:` filter buys: it is loaded from the pushed ref like the rest of the file, so a branch that deletes it re-enables the route — this removes the DRIVE-BY case, and is not a boundary against a writer who intends to run their own YAML. The self-referencing trigger path `.gitea/workflows/ci-image.yml` came out of BOTH its own `paths:` and `ci-image-pin`'s `expected` in the same change — a DECIDED tradeoff, not a necessity: keeping it is workable via the branch dispatch, but prices every edit to that file, comments included, at a ~2GB publish plus a five-pin bump, redone after every rebase. The cost is stated, not assumed away — a change to HOW the image is built that lives only in `ci-image.yml` no longer republishes, and the ONLY remedy is to make it alongside a `docker/ci/**` edit: publishing after the merge and then pinning cannot work, because `expected` is the last `docker/ci` commit and would reject that pin. This closes the push route INTO THIS FILE, not the class: `docker-build.yml` remains reachable from an arbitrary ref by a `v*` tag push and by `pull_request`, and four workflows carry an unrestricted `workflow_dispatch`. The DISPATCH THIRD of that is settled — #853 probed it and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`): no ref restriction exists at Gitea 1.27.1, and restricting it would close nothing anyway, because the head-resolved `pull_request:` route runs attacker-authored YAML that reaches every secret in the store. The `v*` tag push and `pull_request:` rows are NOT settled and remain open in #885. Do not re-derive any of this. | 2026-08-27 | [link](records/ci/toolchain-image-publish-is-a-dispatch.md) | | `ci.ui-e2e-harness` | The UI-interactive E2E flows run as headless Playwright specs (`web/e2e/*.spec.ts`, driven by `scripts/e2e-ui.sh`) in a **second step of the existing advisory `functional-e2e` job**, never their own job; the browser is `chromium-headless-shell` **baked into the CI toolchain image** (`docker/ci/Dockerfile`, `PLAYWRIGHT_VERSION` kept equal to `web/package.json`'s EXACT `@playwright/test` pin), never installed per run; specs are `serial` with `retries: 0` and assert only contracts the curl harness structurally cannot reach. | 2026-07-25 | [link](records/ci/ui-e2e-harness.md) | -| `ci.verdict-write-retarget-fence` | The `review-verdict/h10` job counts BOTH `change_target_branch` AND `pull_push` events on the PR's issue timeline at run start and again immediately before its POST, and writes NOTHING if EITHER count moved. The COUNT is the key on both axes because the underlying VALUE is ABA-vulnerable — `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a forged exemption, and a force-push `H1 -> H2 -> H1` leaves `.head.sha` equal at both ends while the middle pages of `scripts/pr-changed-files.sh`'s enumeration came from `H2` (#803/#664) — while an event count is monotonic and cannot alias. ONE walk certifies BOTH counts, so an unreadable page abandons both; the two tallies are separate so the diagnostic names the axis that actually moved. Every push is counted and `is_force_push` is deliberately NOT read: an ordinary push also invalidates a mid-flight enumeration, and an `H1 -> H2 -> H1` restoration can have its second push non-forced when `H1` is an ancestor. The head fence does NOT abstain on its own triggering push — established FROM THE v1.27.1 SOURCE, since that would be a permanent stall rather than a fence: the push comment is created BEFORE the synchronize notification is emitted, so a run always sees its own causative event, and retries add no new event. The 26-102s margin measured across 20 triggered pairs on PRs #802/#834/#761 corroborates it and is a lower bound (the job runs a checkout first; 69s end to end on run 2385) — it is NOT the basis of the claim, which an earlier draft said it was. This NARROWS the residual, it does not resolve it — a retarget between the FINAL pre-write count and the POST still yields a PERMANENT forged green, because the successor run can consume the `edited` event and exit before the stale run posts last (corrected 2026-08-27, #849). Abstaining is a handoff, not a stall, and that is the property the design rests on — for a run that ABSTAINS; it says nothing about one that already passed its final count and then posts (see the residual, corrected #849): 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 — but ONLY over runs that ABSTAIN. A run that already passed its final count is outside it: it writes whenever it gets there, so the run that writes LAST is not necessarily the one that classified last, and that is the permanent residual (#849). `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` blocks the merge immediately while withholding it would strand ordinary PRs whenever the timeline is unreadable — the right trade, but NOT a free one ("for no safety gain" retracted 2026-08-27): a GENERIC `pending` masks a rejection landing in its own write window, post-write verification does not run for it, and a later run re-derives it into an exemption `success` (#849). SEPARATELY, and for the human-verdict race the fence does nothing about: after posting an exemption `success` the job re-reads `/statuses/{sha}` IN FULL (PAGED since #763) 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. BOTH `/statuses/{sha}` reads PAGE to a validated EMPTY page since #763 — `[]` on this endpoint, a THIRD terminator shape distinct from the timeline's bare `null` and the combined endpoint's `{"statuses": null}`, so each terminator is MEASURED per endpoint — never terminating on a SHORT page, retrying each page once, under a 20-page cap that validates at most 950 rows (the 20th request must be the empty terminator); correctness does not depend on the measured cap of 50, because any page size pages correctly. WHAT THE PAGING BUYS IS NOT WHAT #763 CLAIMED: under the DESC default page 1 already held the true maximum id AND every row newer than the mark, so a single-page read missed a raced verdict only if more than 50 rows were created INSIDE the write window — not merely on a head over 50 rows. What removed #761's stall is retiring the probe, not the walk; the walk's value is that the gate's one fail-toward-SUCCESS path no longer depends on an UNDOCUMENTED ordering the server honours only coarsely (page 1 came back `114,112,113,111,110`). This RETIRED #751's conservative page-2 probe, which treated "there are rows I did not read" as "assume raced" and so repaired every head that outgrew one page: it fired on Renovate PR #761, reporting a human verdict as overwritten when none existed and then, the sentinel being sticky, refusing to re-exempt that head on every later run. The two directions are NOT symmetric. POST-WRITE, uncertainty fails CLOSED — an unreadable history, an over-cap history, or a count that is not a number all repair to `pending`; previously an unreadable history warned and left the exemption green while the page-2 probe repaired on the same uncertainty, one check disagreeing with itself. PRE-WRITE, a PARTIAL list still yields a mark, because the mark gates the post-write check entirely and refusing one SKIPS that check, which is itself the fail-open; this rests on the DESC DEFAULT — the newest row, carrying the maximum id, is on page 1, so a walk that fails later still saw it — while a VALIDATED empty history is NOT abandoned (it yields a mark of 0, correct for a first run, since every later row is newer) — what abandons the mark is a read that both FAILED and returned nothing (the pre-existing #849 gap, unchanged) — and a NON-EMPTY history carrying no numeric id is reported unusable rather than collapsed to 0. BOTH id comparisons are NUMERIC-ONLY: jq orders strings above every number, so one `"id": "99999"` inflates the mark until nothing looks newer, and `.id > $since` reads any string id as newer than any mark — making a PRE-EXISTING base-mismatched verdict look raced on every run, a permanent per-sha stall (the twin was live on `main`). An EMPTY post-write history is REJECTED: the walk terminates on an empty page, which is correct before the write and impossible after it (one row per POST), and a well-formed "no statuses exist" is not retried — so accepting it would conclude `raced=0` from a list that cannot be real, silently. That is NOT the withdrawn currency witness, which asked whether ANY row sat above the mark and was satisfied by an unrelated newer row; this asks only whether the list is EMPTY, a state no unrelated row can produce. The `::error::` now names its own cause, of which there are THREE — a verdict actually FOUND, a read that could not be COMPLETED, and a read that completed but returned an IMPOSSIBLE answer (the third is not a variety of the second) — while the sentinel DESCRIPTION stays generic because the classification recognises it as a fixed point. `.creator` is TYPE-TESTED before it is indexed: `.creator != null and .creator.login` hard-errors on a non-object creator, jq exits 5, and under `set -e` that took the step down AFTER the exemption was posted and BEFORE the repair — a forged green reported as an infrastructure error. | 2026-08-03 | [link](records/ci/verdict-write-retarget-fence.md) | +| `ci.verdict-unverified-write-sentinel` | The `review-verdict/h10` job runs its post-write race check after EVERY status write, not only an exemption `success`: a GENERIC `pending` masks a rejection landing in its own write window exactly as a `success` does, and because it carries no marker a later run re-derives it into an exemption with the human row now below THAT run's high-water mark — the damage arrives one event later, not never. Where a write cannot be checked at all the job writes a SECOND sentinel, `UNVERIFIED_DESC`, distinct from the repair sentinel and never interchangeable with it: the repair sentinel ASSERTS that a verdict existed and was buried, which only the arm that actually COUNTED such a row may claim, while every "could not check" arm — an unreadable or over-cap history, an impossible empty history, an unusable count — states only what it established. Both are STICKY (the classification refuses to grant an exemption over either, and re-writes its own description verbatim, so each is a FIXED POINT a later run cannot re-derive into `success`); only the unverified one is RECONCILABLE. Reconciliation is what bounds the stall that got the #742 attempt withdrawn: a later run pages `/statuses/{sha}` IN FULL and either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it UPGRADES to the repair sentinel, clearable only by a human — or finds none, resolving the uncertainty and clearing it so the PR is classified normally. The reconciliation is sound because the two endpoints differ: a verdict this job masked is invisible on the COMBINED endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. It is deliberately BROADER than the inheritance test — no `H10_REVIEWERS` membership, no `(base: …)` match — because over-counting upgrades to a stall a human clears in one command while MISSING one re-exempts a head carrying a buried rejection. ONLY a COMPLETE walk may clear it; `ph_ok != yes` carries it forward. When NO high-water mark can be established the exemption is withheld BEFORE the POST rather than posted and repaired, since the defect is known in advance and publishing a green to take it back opens a window branch protection — and an already-scheduled auto-merge — can see. Only `success` is downgraded there: rewriting a generic `pending` into the sentinel would make every ordinary PR sticky whenever the endpoint blinks, withholding nothing (an unreviewed PR is blocked already) and costing the next docs-only run its exemption. SEPARATELY, the retarget count is re-taken AFTER the POST on the exemption path, which closes the PERMANENT forged green `ci.verdict-write-retarget-fence` recorded as its residual: a retarget landing after the final pre-write count leaves a stale `success` with the `edited` event already consumed by a successor that short-circuited, so nothing remains to reclassify. Re-counting afterwards means a retarget later than the re-check necessarily queues a successor that STARTS after the stale `success` exists, and a machine-written `success` is re-derived rather than inherited. The RETARGET axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. An untrusted post-POST count repairs too — reaching that point with `success` means both earlier counts were trusted, so a third unreadable read is a fresh failure and "I cannot tell whether the base moved" must not resolve to leaving a green. FINALLY, an UNREADABLE combined-status read no longer merely declines to write: it retries once and then REPLACES the unknown state with the unverified sentinel. Declining protects a real verdict and leaves a FORGED one — an off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving it, and an unreadable read is the one thing that stops it while the job goes red on a status branch protection does not read. Nothing is destroyed: the per-POST history keeps the masked verdict and the next run's reconciliation upgrades to the repair sentinel, telling the reviewer to re-post rather than silently un-approving them. That branch is scoped to an UNREADABLE BODY only — the page-2 completeness probe still merely refuses, because it fires when NO row for this context was on page 1, i.e. when there is no green of any provenance to leave standing. | 2026-08-29 | [link](records/ci/verdict-unverified-write-sentinel.md) | +| `ci.verdict-write-retarget-fence` | The `review-verdict/h10` job counts BOTH `change_target_branch` AND `pull_push` events on the PR's issue timeline at run start and again immediately before its POST, and writes NOTHING if EITHER count moved. The COUNT is the key on both axes because the underlying VALUE is ABA-vulnerable — `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a forged exemption, and a force-push `H1 -> H2 -> H1` leaves `.head.sha` equal at both ends while the middle pages of `scripts/pr-changed-files.sh`'s enumeration came from `H2` (#803/#664) — while an event count is monotonic and cannot alias. ONE walk certifies BOTH counts, so an unreadable page abandons both; the two tallies are separate so the diagnostic names the axis that actually moved. Every push is counted and `is_force_push` is deliberately NOT read: an ordinary push also invalidates a mid-flight enumeration, and an `H1 -> H2 -> H1` restoration can have its second push non-forced when `H1` is an ancestor. The head fence does NOT abstain on its own triggering push — established FROM THE v1.27.1 SOURCE, since that would be a permanent stall rather than a fence: the push comment is created BEFORE the synchronize notification is emitted, so a run always sees its own causative event, and retries add no new event. The 26-102s margin measured across 20 triggered pairs on PRs #802/#834/#761 corroborates it and is a lower bound (the job runs a checkout first; 69s end to end on run 2385) — it is NOT the basis of the claim, which an earlier draft said it was. This NARROWS the residual rather than resolving it, and what CLOSED the permanent case is a separate mechanism recorded at `ci.verdict-unverified-write-sentinel`: the retarget count is re-taken AFTER the POST, so a retarget between the final pre-write count and the POST — which used to leave a PERMANENT forged green, the successor having consumed the `edited` event and exited before the stale run posted last — is now caught by the writing run itself, and one landing after the re-check necessarily queues a successor that starts with the stale `success` already visible and re-derivable (corrected 2026-08-27, closed 2026-08-29, #849). Abstaining is a handoff, not a stall, and that is the property the design rests on — for a run that ABSTAINS; it says nothing about one that already passed its final count and then posts (see the residual, corrected #849): 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 — but ONLY over runs that ABSTAIN. A run that already passed its final count is outside it: it writes whenever it gets there, so the run that writes LAST is not necessarily the one that classified last. That was the permanent residual until #849 gave the writing run a post-POST re-count of its own (`ci.verdict-unverified-write-sentinel`), which puts such a run back inside the induction — it either withdraws its own stale write or leaves a green a guaranteed successor re-derives. `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` blocks the merge immediately while withholding it would strand ordinary PRs whenever the timeline is unreadable — the right trade, but NOT a free one ("for no safety gain" retracted 2026-08-27): a GENERIC `pending` masks a rejection landing in its own write window just as a `success` does. What made that DURABLE — post-write verification skipping it, so a later run re-derived it into an exemption `success` — is closed since 2026-08-29: the check now runs after EVERY write (`ci.verdict-unverified-write-sentinel`), so such a write is repaired to the sticky repair sentinel and cannot be re-derived. The masking itself is still a cost, and a write the mark cannot cover is still unverified; that is what the second sentinel is for. SEPARATELY, and for the human-verdict race the fence does nothing about: after posting ANY status — every write since #849, not only an exemption `success` — the job re-reads `/statuses/{sha}` IN FULL (PAGED since #763) 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. BOTH `/statuses/{sha}` reads PAGE to a validated EMPTY page since #763 — `[]` on this endpoint, a THIRD terminator shape distinct from the timeline's bare `null` and the combined endpoint's `{"statuses": null}`, so each terminator is MEASURED per endpoint — never terminating on a SHORT page, retrying each page once, under a 20-page cap that validates at most 950 rows (the 20th request must be the empty terminator); correctness does not depend on the measured cap of 50, because any page size pages correctly. WHAT THE PAGING BUYS IS NOT WHAT #763 CLAIMED: under the DESC default page 1 already held the true maximum id AND every row newer than the mark, so a single-page read missed a raced verdict only if more than 50 rows were created INSIDE the write window — not merely on a head over 50 rows. What removed #761's stall is retiring the probe, not the walk; the walk's value is that the gate's one fail-toward-SUCCESS path no longer depends on an UNDOCUMENTED ordering the server honours only coarsely (page 1 came back `114,112,113,111,110`). This RETIRED #751's conservative page-2 probe, which treated "there are rows I did not read" as "assume raced" and so repaired every head that outgrew one page: it fired on Renovate PR #761, reporting a human verdict as overwritten when none existed and then, the sentinel being sticky, refusing to re-exempt that head on every later run. The two directions are NOT symmetric. POST-WRITE, uncertainty fails CLOSED — an unreadable history, an over-cap history, or a count that is not a number all repair to `pending`; previously an unreadable history warned and left the exemption green while the page-2 probe repaired on the same uncertainty, one check disagreeing with itself. PRE-WRITE, a PARTIAL list still yields a mark, because the mark gates the post-write check entirely and refusing one SKIPS that check, which is itself the fail-open; this rests on the DESC DEFAULT — the newest row, carrying the maximum id, is on page 1, so a walk that fails later still saw it — while a VALIDATED empty history is NOT abandoned (it yields a mark of 0, correct for a first run, since every later row is newer) — what abandons the mark is a read that both FAILED and returned nothing — which since 2026-08-29 WITHHOLDS the exemption before the POST and marks the head with the reconcilable sentinel, rather than posting a green nothing can check (#849) — and a NON-EMPTY history carrying no numeric id is reported unusable rather than collapsed to 0. BOTH id comparisons are NUMERIC-ONLY: jq orders strings above every number, so one `"id": "99999"` inflates the mark until nothing looks newer, and `.id > $since` reads any string id as newer than any mark — making a PRE-EXISTING base-mismatched verdict look raced on every run, a permanent per-sha stall (the twin was live on `main`). An EMPTY post-write history is REJECTED: the walk terminates on an empty page, which is correct before the write and impossible after it (one row per POST), and a well-formed "no statuses exist" is not retried — so accepting it would conclude `raced=0` from a list that cannot be real, silently. That is NOT the withdrawn currency witness, which asked whether ANY row sat above the mark and was satisfied by an unrelated newer row; this asks only whether the list is EMPTY, a state no unrelated row can produce. The `::error::` now names its own cause, of which there are THREE — a verdict actually FOUND, a read that could not be COMPLETED, and a read that completed but returned an IMPOSSIBLE answer (the third is not a variety of the second) — while WHICH sentinel description is written is itself part of the answer since #849: only an arm that actually COUNTED a verdict row may write the repair sentinel, and every "could not check" arm writes the reconcilable one (`ci.verdict-unverified-write-sentinel`). Both are fixed points the classification recognises. `.creator` is TYPE-TESTED before it is indexed: `.creator != null and .creator.login` hard-errors on a non-object creator, jq exits 5, and under `set -e` that took the step down AFTER the exemption was posted and BEFORE the repair — a forged green reported as an infrastructure error. | 2026-08-03 | [link](records/ci/verdict-write-retarget-fence.md) | | `ci.verify-locally-ci-confirms` | Treat the local build/verify/review pass as the decision point and CI as confirmation — don't idle waiting on a run you have no reason to doubt. | 2026-07-21 | [link](records/ci/verify-locally-ci-confirms.md) | | `ci.web-test-per-test-timeouts` | Give heavy-render web tests an explicit per-test vitest timeout (e.g. 15s); never raise the global default to fix one slow test. | 2026-07-21 | [link](records/ci/web-test-per-test-timeouts.md) | | `ci.workflow-dispatch-ref-unrestricted` | Gitea 1.27.1 offers NO mechanism to restrict `workflow_dispatch` by ref, and has no protected-environment concept at all — PROBED across the REST API, the loaded config and the CLI, not assumed (the WEB UI was not swept; the body says why that is acceptable here and where it would matter). The dispatch body schema `CreateActionWorkflowDispatch` makes `ref` a required free-form string with no allow-list or pattern field; zero of the 308 documented API paths contain "environment", and Actions secrets exist only at org/repo/user scope with no per-ref or per-environment gate; `/api/v1/settings/actions` 404s; the config file the running server actually loads (`/etc/gitea/app.ini`, named by its own `--config`) sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`; and the `gitea` CLI exposes exactly ONE Actions subcommand, `gitea actions generate-runner-token`, which registers a runner and restricts nothing. Treat the VERSION, not the `stale-after` date, as the real trigger to re-probe: an upgrade past 1.27.1 invalidates every capability claim here the day it lands, months before the date fires. The four unrestricted dispatches (`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml`, `renovate.yml`) are therefore ACCEPTED — but the operative reason is NOT "repository write access is the boundary", which is the argument to avoid because it is unfalsifiable and it hides the real route. The operative reason is that **dispatch is not the cheapest route to ANY of it**: `docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that route executes ATTACKER-AUTHORED YAML — and such YAML can name any secret in the repo store, not merely the ones the committed workflows happen to reference (`ci.gate-trigger-base-resolved`, verbatim: "any PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store"). Label that step honestly: it is INFERRED from the repo-scoped secret model plus that record, NOT measured here, because the measurement would print a live credential into a run log. That generalizing step is what makes the argument cover all four rather than just the registry pair: `renovate.yml`'s `RENOVATE_TOKEN`/`GH_COM_TOKEN` are reachable from a PR without dispatching `renovate.yml` at all, and `dependency-scan.yml` references no `secrets.` whatever — which corrects #853's own table row for it. On the registry credential as it stands, SIX jobs in `docker-build.yml` hold `REGISTRY_PASSWORD` and run on the PR route (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them — `test` and `migrations` — branch-protection required contexts per `.gitea/required-status-contexts.json`; carry that as the INVARIANT "every job on the PR route that NAMES `secrets.REGISTRY_PASSWORD`", never as the six-name list, because a remediation scoped to a stale list misses whatever lands next. Resist the tempting "every `container:` job" — `toolchain-preflight` is deliberately container-free and takes the credential through `ETV_REGISTRY_AUTH`, so that predicate names five of the six and reproduces on day one the exact staleness it was written to prevent. "Deliberate act" throughout carries `ci.toolchain-image-publish-is-a-dispatch`'s sense — an act OUTSIDE the ordinary contribution flow, not a raw step count: opening a PR costs zero such acts and a dispatch costs one. Restricting dispatch would therefore close the more visible route and change nothing. The residuals worth tracking are the PR route AND the `v*` tag push — a single act, explicitly outside `release.main-direct-push-disabled` — both in #885, not dispatch. | 2026-08-30 | [link](records/ci/workflow-dispatch-ref-unrestricted.md) | diff --git a/docs/decisions/records/ci/exemption-provenance.md b/docs/decisions/records/ci/exemption-provenance.md index 61355a7e1..286ae286b 100644 --- a/docs/decisions/records/ci/exemption-provenance.md +++ b/docs/decisions/records/ci/exemption-provenance.md @@ -214,10 +214,18 @@ the post-write history was unreadable. Re-review found both wanting, on grounds reviewer posting inside the write window, for a stall that needs only the read failure, is not obviously the safe direction, and it was described as a "one-run cost" when it is not. -So the whole of post-write verification stays in #849, where it can be designed once: every write -verified when a mark exists, a sticky sentinel when it does not, and a CHAINED test feeding run N's -real output into run N+1. Two lines inside a provenance change was the wrong size for it — the attempt -is recorded because the next reader will otherwise re-attempt it the same way. +So the whole of post-write verification went to #849, where it was designed once and landed on +2026-08-29 as `ci.verdict-unverified-write-sentinel`: every write verified when a mark exists, a +sticky **and reconcilable** sentinel when it does not, and a CHAINED test feeding run N's real output +into run N+1. Two lines inside a provenance change was the wrong size for it — the attempt is recorded +because the next reader will otherwise re-attempt it the same way, and because the withdrawal is what +established that "sticky" alone is not enough. + +**One residual named here was closed there too.** An UNREADABLE combined-status read used to leave a +pre-existing off-list `success` standing: `read_existing_verdict` exited without replacing it, the job +went red, and this workflow's own job status is not a required check, so branch protection still saw +the green — the exact status this record exists to revoke, surviving a read failure. That read is now +retried once and, on a persistent failure, the unknown state is REPLACED with the unverified sentinel. **The writer/gate coupling, ASSERTED at the writer since #845.** `post-review-verdict.sh` posts with whatever account owns `ETV_GITEA_TOKEN`/`ETV_GITEA_BASICAUTH`. It used to never ask whose that was, and diff --git a/docs/decisions/records/ci/verdict-unverified-write-sentinel.md b/docs/decisions/records/ci/verdict-unverified-write-sentinel.md new file mode 100644 index 000000000..f7dbc1964 --- /dev/null +++ b/docs/decisions/records/ci/verdict-unverified-write-sentinel.md @@ -0,0 +1,99 @@ +--- +key: ci.verdict-unverified-write-sentinel +title: '2026-08-29 — the review-verdict job verifies EVERY write, withholds an exemption it cannot verify, and marks the head with a RECONCILABLE sticky sentinel (#849)' +status: active +since: '2026-08-29' +supersedes: none +superseded-by: none +rule: 'The `review-verdict/h10` job runs its post-write race check after EVERY status write, not only an exemption `success`: a GENERIC `pending` masks a rejection landing in its own write window exactly as a `success` does, and because it carries no marker a later run re-derives it into an exemption with the human row now below THAT run''s high-water mark — the damage arrives one event later, not never. Where a write cannot be checked at all the job writes a SECOND sentinel, `UNVERIFIED_DESC`, distinct from the repair sentinel and never interchangeable with it: the repair sentinel ASSERTS that a verdict existed and was buried, which only the arm that actually COUNTED such a row may claim, while every "could not check" arm — an unreadable or over-cap history, an impossible empty history, an unusable count — states only what it established. Both are STICKY (the classification refuses to grant an exemption over either, and re-writes its own description verbatim, so each is a FIXED POINT a later run cannot re-derive into `success`); only the unverified one is RECONCILABLE. Reconciliation is what bounds the stall that got the #742 attempt withdrawn: a later run pages `/statuses/{sha}` IN FULL and either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it UPGRADES to the repair sentinel, clearable only by a human — or finds none, resolving the uncertainty and clearing it so the PR is classified normally. The reconciliation is sound because the two endpoints differ: a verdict this job masked is invisible on the COMBINED endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. It is deliberately BROADER than the inheritance test — no `H10_REVIEWERS` membership, no `(base: …)` match — because over-counting upgrades to a stall a human clears in one command while MISSING one re-exempts a head carrying a buried rejection. ONLY a COMPLETE walk may clear it; `ph_ok != yes` carries it forward. When NO high-water mark can be established the exemption is withheld BEFORE the POST rather than posted and repaired, since the defect is known in advance and publishing a green to take it back opens a window branch protection — and an already-scheduled auto-merge — can see. Only `success` is downgraded there: rewriting a generic `pending` into the sentinel would make every ordinary PR sticky whenever the endpoint blinks, withholding nothing (an unreviewed PR is blocked already) and costing the next docs-only run its exemption. SEPARATELY, the retarget count is re-taken AFTER the POST on the exemption path, which closes the PERMANENT forged green `ci.verdict-write-retarget-fence` recorded as its residual: a retarget landing after the final pre-write count leaves a stale `success` with the `edited` event already consumed by a successor that short-circuited, so nothing remains to reclassify. Re-counting afterwards means a retarget later than the re-check necessarily queues a successor that STARTS after the stale `success` exists, and a machine-written `success` is re-derived rather than inherited. The RETARGET axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. An untrusted post-POST count repairs too — reaching that point with `success` means both earlier counts were trusted, so a third unreadable read is a fresh failure and "I cannot tell whether the base moved" must not resolve to leaving a green. FINALLY, an UNREADABLE combined-status read no longer merely declines to write: it retries once and then REPLACES the unknown state with the unverified sentinel. Declining protects a real verdict and leaves a FORGED one — an off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving it, and an unreadable read is the one thing that stops it while the job goes red on a status branch protection does not read. Nothing is destroyed: the per-POST history keeps the masked verdict and the next run''s reconciliation upgrades to the repair sentinel, telling the reviewer to re-post rather than silently un-approving them. That branch is scoped to an UNREADABLE BODY only — the page-2 completeness probe still merely refuses, because it fires when NO row for this context was on page 1, i.e. when there is no green of any provenance to leave standing.' +signals: 'exemption success standing over a human failure, generic pending re-derived into an exemption, unverified write sentinel, reconcilable sentinel vs repair sentinel, no high-water mark could be established, post-write verification runs for every write, retarget after the POST leaves a permanent forged green, unreadable combined status read leaves an off-list green, why did my docs-only PR lose its exemption, why does the gate say the write could not be verified · paths: `.gitea/workflows/review-verdict.yml`, `scripts/tests/test_pr_changed_files.py` · issues: #849, #742, #763, #706, #803' +mechanics: '`UNVERIFIED_DESC` ("Exemption write could not be verified — re-post the verdict") beside `REPAIR_DESC`; `read_existing_verdict()` sets `ex_unverified` from a prefix match, retries the combined read once and on persistent failure calls `repair_status_to "$UNVERIFIED_DESC"` before `exit 1`; the RECONCILE block runs `page_statuses` and counts `Review-verdict:` rows with a non-null creator, clearing `ex_unverified`, upgrading to `ex_repair`, or carrying the sentinel forward on `ph_ok != yes`; the classification chain is `exempt` → `ex_repair` → `ex_unverified` → generic, so the repair sentinel outranks the unverified one; the no-mark downgrade sits AFTER the mark (referencing `$max_id_before` earlier is an unbound variable under `set -u`); the mid-run guard adds `[ "$ex_desc" != "$pre_desc" ]`, which the repair guard does not need because a pre-existing repair sentinel forces its own description while a pre-existing unverified one is deliberately replaced after reconciliation; the post-write gate is `if [ "$max_id_before" -ge 0 ]`; `--arg own "$desc"` excludes the job''s OWN row from both machine-sentinel selectors, which became necessary the moment the block started running for every write; `.description` is TYPE-TESTED before `startswith`, since `(.description // "")` does not replace a NUMBER and `startswith` then hard-errors, killing the whole count and losing a genuine verdict beside the malformed row; `repair_status_to()` is the shared retry-once writer for all three repair sites; tests `test_a_verdict_racing_a_PENDING_write_is_repaired_to_the_repair_sentinel`, `test_a_raced_PENDING_write_repairs_and_the_repair_SURVIVES_the_next_run`, `test_the_unverified_sentinel_is_a_FIXED_POINT_while_it_cannot_be_reconciled`, `test_the_unverified_sentinel_is_RECONCILED_AWAY_once_the_history_can_be_read`, `test_the_reconciliation_UPGRADES_to_the_repair_sentinel_when_a_verdict_is_BURIED`, `test_a_RETARGET_AFTER_the_POST_replaces_the_exemption`, `test_an_UNREADABLE_retarget_count_AFTER_the_POST_also_replaces_the_exemption`, `test_a_MALFORMED_description_row_does_not_kill_the_post_write_count`, `test_a_transport_failure_on_the_STATUS_READ_REPLACES_the_unknown_state`, each paired with a `test_MUTATION_…` proof that restores the exact predecessor text through `_run_classify(mutate=…)`.' +--- + +`ci.verdict-write-retarget-fence` fenced the write and verified it afterwards. Five routes survived +that, all reaching the same outcome — an exemption `success`, or a generic `pending` a later run turns +into one, standing over a human `failure`. Two were attempted inside #742 and withdrawn; the reasons +for the withdrawal are what shaped this record, so they are stated before the design. + +## Why the #742 attempt was withdrawn, and what it forced + +That attempt withheld the exemption when no mark could be established, and repaired to the sentinel +when the post-write history was unreadable. Both were withdrawn on grounds that generalise: + +- **The withheld exemption wrote a GENERIC `pending`**, which is exactly what a later run re-derives + into `success`. It moved which run posted the forged green rather than stopping it. +- **That `pending` had no retry path.** The workflow triggers only on `pull_request_target` types — + no `schedule`, no `workflow_dispatch` — so a transient failure on a PR's *last* event stalls an + exempt PR until a human nudges it. Trading a race that needs BOTH a read failure and a reviewer + posting inside the write window for a stall that needs only the read failure is not obviously the + safe direction. + +So the fix needs two properties at once: **sticky**, so a later run cannot re-derive it, and +**reconcilable**, so a transient failure does not cost a head its exemption permanently. Neither the +repair sentinel nor a generic `pending` has both. That is why there is a second sentinel rather than a +reuse of the first. + +## Why two sentinels and not one flag + +They record different things and clear by different means, and collapsing them is a fail-open in one +direction and a permanent stall in the other: + +| | records | cleared by | +| --- | --- | --- | +| `REPAIR_DESC` | a human verdict existed on this sha and a write buried it | a human re-posting | +| `UNVERIFIED_DESC` | a write on this sha could not be checked against the status history | reconciliation, or a human | + +The first is a fact that stays true, so nothing automatic may clear it. The second is an open +question, and a run that can read the history closes it — upward to `REPAIR_DESC` when a verdict is +found, or away entirely when none is. `read_existing_verdict` therefore tests the two descriptions +separately; a single "is it a sentinel" flag would let reconciliation clear the one that must never be +cleared by anything but a human. + +The ordering in the classification chain follows from the table: `ex_repair` is written before +`ex_unverified`, because reconciliation can turn the second into the first but never the reverse, and +when both are somehow set the stronger fact must be the one written. + +## The reconciliation is sound because the two endpoints disagree + +The combined endpoint (`/commits/{sha}/status`) returns the latest row per context. Once a sentinel is +written that row IS the sentinel, so a verdict underneath it is structurally invisible there — which +is also why the post-write check reads the other endpoint. `/statuses/{sha}` returns one row per POST, +so a verdict this job masked is still in that list for a later run to find. The same asymmetry that +makes the post-write check possible is what makes reconciliation possible. + +It is deliberately **broader** than the inheritance test — no `H10_REVIEWERS` membership and no +`(base: …)` binding. The two errors are not symmetric: counting a row that is not really a verdict for +this base upgrades to a stall a human clears with one command, while missing one clears the sentinel +and lets a later run exempt a head that carries a buried rejection. Over-counting is the affordable +error, and this is the same reasoning the post-write raced check already applies to `creator`. + +## What the post-POST retarget re-check buys, precisely + +`ci.verdict-write-retarget-fence` stated its residual as a PERMANENT forged green: a run passes its +final pre-write count, the PR is retargeted back to `main`, the successor consumes the `edited` event +and short-circuits on the base-matching `failure`, and the stale run then posts last with no event +remaining. Re-counting after the POST closes the induction rather than narrowing the window again — a +retarget later than the re-check necessarily queues a successor that starts *after* the stale +`success` exists, and a machine-written `success` is re-derived, not inherited. + +**The retarget axis only.** A push after the POST moves the head, so the status is no longer on the +PR's head and cannot gate its merge; a retarget changes the effective diff while the sha stays, which +is why the status remains authoritative for a diff it no longer describes. Fencing pushes here would +punish the ordinary case — a contributor pushing straight after the run — by stranding a sentinel on a +sha that becomes the head again after a revert. + +## Residuals, stated rather than implied + +1. **A retarget landing between the post-POST count and the end of the job** is not caught by this + run. It IS caught by the successor, for the reason above, so it is a bounded transient rather than + the permanent green it was — but the successor is what closes it, not this check. +2. **Both endpoints failing at once** still leaves an off-list `success` standing: the replacement + write itself needs the API. It is reported as an `::error::` naming the head and the command to + clear it. +3. **A PR whose last event coincides with an unreconcilable failure** stalls until a human posts a + verdict. Reconciliation bounds the stall only when another event arrives; the workflow has no + `schedule` or `workflow_dispatch` trigger to create one, and adding a trigger that re-runs the gate + on a timer was not attempted here. +4. **The reconciliation reads the same paged endpoint the rest of the job does**, so it inherits the + 20-page cap: a history past 950 rows cannot be reconciled and the sentinel stands until a human + clears it. diff --git a/docs/decisions/records/ci/verdict-write-retarget-fence.md b/docs/decisions/records/ci/verdict-write-retarget-fence.md index b73f2316e..e384b5994 100644 --- a/docs/decisions/records/ci/verdict-write-retarget-fence.md +++ b/docs/decisions/records/ci/verdict-write-retarget-fence.md @@ -5,14 +5,16 @@ status: active since: '2026-08-03' supersedes: none superseded-by: none -rule: 'The `review-verdict/h10` job counts BOTH `change_target_branch` AND `pull_push` events on the PR''s issue timeline at run start and again immediately before its POST, and writes NOTHING if EITHER count moved. The COUNT is the key on both axes because the underlying VALUE is ABA-vulnerable — `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a forged exemption, and a force-push `H1 -> H2 -> H1` leaves `.head.sha` equal at both ends while the middle pages of `scripts/pr-changed-files.sh`''s enumeration came from `H2` (#803/#664) — while an event count is monotonic and cannot alias. ONE walk certifies BOTH counts, so an unreadable page abandons both; the two tallies are separate so the diagnostic names the axis that actually moved. Every push is counted and `is_force_push` is deliberately NOT read: an ordinary push also invalidates a mid-flight enumeration, and an `H1 -> H2 -> H1` restoration can have its second push non-forced when `H1` is an ancestor. The head fence does NOT abstain on its own triggering push — established FROM THE v1.27.1 SOURCE, since that would be a permanent stall rather than a fence: the push comment is created BEFORE the synchronize notification is emitted, so a run always sees its own causative event, and retries add no new event. The 26-102s margin measured across 20 triggered pairs on PRs #802/#834/#761 corroborates it and is a lower bound (the job runs a checkout first; 69s end to end on run 2385) — it is NOT the basis of the claim, which an earlier draft said it was. This NARROWS the residual, it does not resolve it — a retarget between the FINAL pre-write count and the POST still yields a PERMANENT forged green, because the successor run can consume the `edited` event and exit before the stale run posts last (corrected 2026-08-27, #849). Abstaining is a handoff, not a stall, and that is the property the design rests on — for a run that ABSTAINS; it says nothing about one that already passed its final count and then posts (see the residual, corrected #849): 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 — but ONLY over runs that ABSTAIN. A run that already passed its final count is outside it: it writes whenever it gets there, so the run that writes LAST is not necessarily the one that classified last, and that is the permanent residual (#849). `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` blocks the merge immediately while withholding it would strand ordinary PRs whenever the timeline is unreadable — the right trade, but NOT a free one ("for no safety gain" retracted 2026-08-27): a GENERIC `pending` masks a rejection landing in its own write window, post-write verification does not run for it, and a later run re-derives it into an exemption `success` (#849). SEPARATELY, and for the human-verdict race the fence does nothing about: after posting an exemption `success` the job re-reads `/statuses/{sha}` IN FULL (PAGED since #763) 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. BOTH `/statuses/{sha}` reads PAGE to a validated EMPTY page since #763 — `[]` on this endpoint, a THIRD terminator shape distinct from the timeline''s bare `null` and the combined endpoint''s `{"statuses": null}`, so each terminator is MEASURED per endpoint — never terminating on a SHORT page, retrying each page once, under a 20-page cap that validates at most 950 rows (the 20th request must be the empty terminator); correctness does not depend on the measured cap of 50, because any page size pages correctly. WHAT THE PAGING BUYS IS NOT WHAT #763 CLAIMED: under the DESC default page 1 already held the true maximum id AND every row newer than the mark, so a single-page read missed a raced verdict only if more than 50 rows were created INSIDE the write window — not merely on a head over 50 rows. What removed #761''s stall is retiring the probe, not the walk; the walk''s value is that the gate''s one fail-toward-SUCCESS path no longer depends on an UNDOCUMENTED ordering the server honours only coarsely (page 1 came back `114,112,113,111,110`). This RETIRED #751''s conservative page-2 probe, which treated "there are rows I did not read" as "assume raced" and so repaired every head that outgrew one page: it fired on Renovate PR #761, reporting a human verdict as overwritten when none existed and then, the sentinel being sticky, refusing to re-exempt that head on every later run. The two directions are NOT symmetric. POST-WRITE, uncertainty fails CLOSED — an unreadable history, an over-cap history, or a count that is not a number all repair to `pending`; previously an unreadable history warned and left the exemption green while the page-2 probe repaired on the same uncertainty, one check disagreeing with itself. PRE-WRITE, a PARTIAL list still yields a mark, because the mark gates the post-write check entirely and refusing one SKIPS that check, which is itself the fail-open; this rests on the DESC DEFAULT — the newest row, carrying the maximum id, is on page 1, so a walk that fails later still saw it — while a VALIDATED empty history is NOT abandoned (it yields a mark of 0, correct for a first run, since every later row is newer) — what abandons the mark is a read that both FAILED and returned nothing (the pre-existing #849 gap, unchanged) — and a NON-EMPTY history carrying no numeric id is reported unusable rather than collapsed to 0. BOTH id comparisons are NUMERIC-ONLY: jq orders strings above every number, so one `"id": "99999"` inflates the mark until nothing looks newer, and `.id > $since` reads any string id as newer than any mark — making a PRE-EXISTING base-mismatched verdict look raced on every run, a permanent per-sha stall (the twin was live on `main`). An EMPTY post-write history is REJECTED: the walk terminates on an empty page, which is correct before the write and impossible after it (one row per POST), and a well-formed "no statuses exist" is not retried — so accepting it would conclude `raced=0` from a list that cannot be real, silently. That is NOT the withdrawn currency witness, which asked whether ANY row sat above the mark and was satisfied by an unrelated newer row; this asks only whether the list is EMPTY, a state no unrelated row can produce. The `::error::` now names its own cause, of which there are THREE — a verdict actually FOUND, a read that could not be COMPLETED, and a read that completed but returned an IMPOSSIBLE answer (the third is not a variety of the second) — while the sentinel DESCRIPTION stays generic because the classification recognises it as a fixed point. `.creator` is TYPE-TESTED before it is indexed: `.creator != null and .creator.login` hard-errors on a non-object creator, jq exits 5, and under `set -e` that took the step down AFTER the exemption was posted and BEFORE the repair — a forged green reported as an infrastructure error.' -signals: 'stale review-verdict run overwrites a fresher one, retarget ABA against the docs-only classifier, head ABA force-push H1 H2 H1 during paging, pull_push timeline event count, force-push during changed-file enumeration, docs-only exemption from a file list spanning two heads, 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, #803, #664' +rule: 'The `review-verdict/h10` job counts BOTH `change_target_branch` AND `pull_push` events on the PR''s issue timeline at run start and again immediately before its POST, and writes NOTHING if EITHER count moved. The COUNT is the key on both axes because the underlying VALUE is ABA-vulnerable — `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a forged exemption, and a force-push `H1 -> H2 -> H1` leaves `.head.sha` equal at both ends while the middle pages of `scripts/pr-changed-files.sh`''s enumeration came from `H2` (#803/#664) — while an event count is monotonic and cannot alias. ONE walk certifies BOTH counts, so an unreadable page abandons both; the two tallies are separate so the diagnostic names the axis that actually moved. Every push is counted and `is_force_push` is deliberately NOT read: an ordinary push also invalidates a mid-flight enumeration, and an `H1 -> H2 -> H1` restoration can have its second push non-forced when `H1` is an ancestor. The head fence does NOT abstain on its own triggering push — established FROM THE v1.27.1 SOURCE, since that would be a permanent stall rather than a fence: the push comment is created BEFORE the synchronize notification is emitted, so a run always sees its own causative event, and retries add no new event. The 26-102s margin measured across 20 triggered pairs on PRs #802/#834/#761 corroborates it and is a lower bound (the job runs a checkout first; 69s end to end on run 2385) — it is NOT the basis of the claim, which an earlier draft said it was. This NARROWS the residual rather than resolving it, and what CLOSED the permanent case is a separate mechanism recorded at `ci.verdict-unverified-write-sentinel`: the retarget count is re-taken AFTER the POST, so a retarget between the final pre-write count and the POST — which used to leave a PERMANENT forged green, the successor having consumed the `edited` event and exited before the stale run posted last — is now caught by the writing run itself, and one landing after the re-check necessarily queues a successor that starts with the stale `success` already visible and re-derivable (corrected 2026-08-27, closed 2026-08-29, #849). Abstaining is a handoff, not a stall, and that is the property the design rests on — for a run that ABSTAINS; it says nothing about one that already passed its final count and then posts (see the residual, corrected #849): 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 — but ONLY over runs that ABSTAIN. A run that already passed its final count is outside it: it writes whenever it gets there, so the run that writes LAST is not necessarily the one that classified last. That was the permanent residual until #849 gave the writing run a post-POST re-count of its own (`ci.verdict-unverified-write-sentinel`), which puts such a run back inside the induction — it either withdraws its own stale write or leaves a green a guaranteed successor re-derives. `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` blocks the merge immediately while withholding it would strand ordinary PRs whenever the timeline is unreadable — the right trade, but NOT a free one ("for no safety gain" retracted 2026-08-27): a GENERIC `pending` masks a rejection landing in its own write window just as a `success` does. What made that DURABLE — post-write verification skipping it, so a later run re-derived it into an exemption `success` — is closed since 2026-08-29: the check now runs after EVERY write (`ci.verdict-unverified-write-sentinel`), so such a write is repaired to the sticky repair sentinel and cannot be re-derived. The masking itself is still a cost, and a write the mark cannot cover is still unverified; that is what the second sentinel is for. SEPARATELY, and for the human-verdict race the fence does nothing about: after posting ANY status — every write since #849, not only an exemption `success` — the job re-reads `/statuses/{sha}` IN FULL (PAGED since #763) 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. BOTH `/statuses/{sha}` reads PAGE to a validated EMPTY page since #763 — `[]` on this endpoint, a THIRD terminator shape distinct from the timeline''s bare `null` and the combined endpoint''s `{"statuses": null}`, so each terminator is MEASURED per endpoint — never terminating on a SHORT page, retrying each page once, under a 20-page cap that validates at most 950 rows (the 20th request must be the empty terminator); correctness does not depend on the measured cap of 50, because any page size pages correctly. WHAT THE PAGING BUYS IS NOT WHAT #763 CLAIMED: under the DESC default page 1 already held the true maximum id AND every row newer than the mark, so a single-page read missed a raced verdict only if more than 50 rows were created INSIDE the write window — not merely on a head over 50 rows. What removed #761''s stall is retiring the probe, not the walk; the walk''s value is that the gate''s one fail-toward-SUCCESS path no longer depends on an UNDOCUMENTED ordering the server honours only coarsely (page 1 came back `114,112,113,111,110`). This RETIRED #751''s conservative page-2 probe, which treated "there are rows I did not read" as "assume raced" and so repaired every head that outgrew one page: it fired on Renovate PR #761, reporting a human verdict as overwritten when none existed and then, the sentinel being sticky, refusing to re-exempt that head on every later run. The two directions are NOT symmetric. POST-WRITE, uncertainty fails CLOSED — an unreadable history, an over-cap history, or a count that is not a number all repair to `pending`; previously an unreadable history warned and left the exemption green while the page-2 probe repaired on the same uncertainty, one check disagreeing with itself. PRE-WRITE, a PARTIAL list still yields a mark, because the mark gates the post-write check entirely and refusing one SKIPS that check, which is itself the fail-open; this rests on the DESC DEFAULT — the newest row, carrying the maximum id, is on page 1, so a walk that fails later still saw it — while a VALIDATED empty history is NOT abandoned (it yields a mark of 0, correct for a first run, since every later row is newer) — what abandons the mark is a read that both FAILED and returned nothing — which since 2026-08-29 WITHHOLDS the exemption before the POST and marks the head with the reconcilable sentinel, rather than posting a green nothing can check (#849) — and a NON-EMPTY history carrying no numeric id is reported unusable rather than collapsed to 0. BOTH id comparisons are NUMERIC-ONLY: jq orders strings above every number, so one `"id": "99999"` inflates the mark until nothing looks newer, and `.id > $since` reads any string id as newer than any mark — making a PRE-EXISTING base-mismatched verdict look raced on every run, a permanent per-sha stall (the twin was live on `main`). An EMPTY post-write history is REJECTED: the walk terminates on an empty page, which is correct before the write and impossible after it (one row per POST), and a well-formed "no statuses exist" is not retried — so accepting it would conclude `raced=0` from a list that cannot be real, silently. That is NOT the withdrawn currency witness, which asked whether ANY row sat above the mark and was satisfied by an unrelated newer row; this asks only whether the list is EMPTY, a state no unrelated row can produce. The `::error::` now names its own cause, of which there are THREE — a verdict actually FOUND, a read that could not be COMPLETED, and a read that completed but returned an IMPOSSIBLE answer (the third is not a variety of the second) — while WHICH sentinel description is written is itself part of the answer since #849: only an arm that actually COUNTED a verdict row may write the repair sentinel, and every "could not check" arm writes the reconcilable one (`ci.verdict-unverified-write-sentinel`). Both are fixed points the classification recognises. `.creator` is TYPE-TESTED before it is indexed: `.creator != null and .creator.login` hard-errors on a non-object creator, jq exits 5, and under `set -e` that took the step down AFTER the exemption was posted and BEFORE the repair — a forged green reported as an infrastructure error.' +signals: 'stale review-verdict run overwrites a fresher one, retarget ABA against the docs-only classifier, head ABA force-push H1 H2 H1 during paging, pull_push timeline event count, force-push during changed-file enumeration, docs-only exemption from a file list spanning two heads, 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, #803, #664, #849' mechanics: '`count_pr_mutations()` (named `count_retargets()` until #803 put the head axis on the same walk) pages `GET /repos/{repo}/issues/{pr}/timeline?limit=50&page=N` (cap 20) setting `rt_count` (`change_target_branch`), `hp_count` (`pull_push`) and the SHARED trust flag `rt_ok`, trusted only on a validated empty page, which is a page of EITHER `null` (what this endpoint really returns past the end) or `[]` — an `array`-only type gate read the real terminator as unreadable and withheld every exemption (#751); `retargets_before`/`pushes_before`/`retargets_before_ok` captured before enumeration, re-counted immediately before the POST, with a SEPARATE fence arm and diagnostic per axis (the base arm is evaluated first); the run log line is `Mutation fence: N retarget event(s) and M push event(s) ... (trusted=...)`, renamed from `Retarget fence: ...` by #803; `page_statuses()` pages `GET /repos/{repo}/statuses/{sha}?limit=50&page=N` (cap 20) setting `ph_rows`/`ph_ok`, trusted only on a validated EMPTY ARRAY page (this endpoint''s terminator, measured 2026-08-28 on PR #761''s 114-row head: pages 1-2 return 50, page 3 returns 14, page 4 is `[]`), retrying each page once with a 1s backoff; the server default `created_unix DESC` is KEPT, so a row inserted MID-WALK can be missed (it lands at position 0, on a page already read) — accepted and bounded, since a row arriving after this job''s POST is not one the job overwrote and wins on the combined endpoint anyway. `sort=highestindex` (index ASC, measured) closes that gap and was WITHDRAWN because ASC puts the OLDEST rows on page 1, which inverts the partial-mark fallback into a spurious-STICKY-repair engine — the #761 failure, re-introduced to close a smaller gap; `max_id_before` is the max over NUMERIC ids only in the paged list (jq orders strings above every number, so one `"id": "99999"` would otherwise pass the numeric gate and inflate the mark until nothing looked newer than it — a fail-open); this endpoint is a BARE ARRAY, unlike the combined `/commits/{sha}/status` object; repair POST is `pending`; tests `test_a_HEAD_ABA_DURING_the_run_posts_NOTHING`, `test_the_head_fence_holds_under_EITHER_terminator_shape`, `test_the_head_arm_DEFERS_to_the_trust_guard_when_the_second_count_fails`, `test_a_push_landing_BEYOND_page_1_is_still_counted`, `test_positive_control_a_MULTI_PAGE_quiet_timeline_still_exempts`, `test_an_EMPTY_FIRST_page_is_untrusted_WHICHEVER_empty_shape_it_is`, `test_a_timeline_row_with_NO_READABLE_TYPE_is_not_counted_as_nothing`, `test_a_SINGLE_head_push_during_the_run_also_posts_NOTHING`, `test_a_PR_PUSHED_BEFORE_the_run_but_QUIET_during_it_is_STILL_exempt`, `test_the_head_and_base_axes_are_counted_SEPARATELY`, `test_the_head_fence_reports_the_OBSERVED_counts_in_its_notice`, `test_a_PROTECTED_pr_pushed_mid_run_ALSO_posts_nothing`, `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`, `test_a_status_history_RUNNING_PAST_PAGE_1_is_PAGED_and_the_exemption_STANDS`, `test_a_raced_verdict_on_PAGE_2_is_detected_and_repaired`, `test_a_verdict_BEYOND_A_SHORT_PAGE_is_still_found`, `test_a_PRE_WRITE_paging_failure_still_yields_a_usable_high_water_mark`, `test_a_TRANSIENT_page_failure_is_retried_and_the_walk_still_completes`, `test_a_STRING_id_cannot_inflate_the_high_water_mark`, `test_a_MALFORMED_creator_row_does_not_kill_the_job_after_the_green_is_posted`, `test_a_STRING_id_on_a_PRE_EXISTING_row_is_not_read_as_raced`, `test_a_partial_mark_is_SAFE_because_the_newest_rows_are_on_page_1`, `test_an_EMPTY_post_write_history_is_not_evidence_that_nothing_raced`, `test_a_NULL_page_terminates_the_walk_as_an_empty_one`, `test_the_walk_does_not_request_a_SORT_order`' --- `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. +status writes are not serialized, so a stale run can post over a fresher one. This record **narrows** +that residual rather than resolving it, and narrows that record rather than superseding it. What +closes the permanent case is the post-POST re-count recorded at `ci.verdict-unverified-write-sentinel` +(#849). ## Measured, not reasoned (Gitea 1.25.4, 2026-08-03) @@ -77,11 +79,15 @@ marker, so the exemption returned two events later instead of one. Only a re-pos base S′ and passes its final fence; the PR is retargeted back to `main`; successor M starts, sees the base-matching `failure`, short-circuits and writes NOTHING; **S then resumes and posts its stale `success` last**. The `edited` event has already been consumed by M, so no event - remains — the forged green is permanent, not transient. The fence narrows *writing while - overtaken*; it does nothing about *being overtaken after the final pre-write count*, because it - never re-counts - after the POST. That is the shape of the fix (#849), and until it lands this residual is a - permanent green, stated as such. + remains — the forged green was permanent, not transient. The fence narrows *writing while + overtaken*; it does nothing about *being overtaken after the final pre-write count*. + + **Closed 2026-08-29 by re-counting after the POST** (#849, + `ci.verdict-unverified-write-sentinel`): the writing run withdraws its own stale `success`, and a + retarget arriving later than that re-check necessarily queues a successor that starts with the + stale `success` already present — which, being machine-written, is re-derived rather than + inherited. What remains is bounded and stated there rather than here: the window after the + re-check is covered by the SUCCESSOR, not by the writing run. 2. 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. @@ -122,7 +128,7 @@ the write are separate events and only the write is what a merge reads. ## The head axis (2026-08-28, ersatztv#803 / ersatztv#664) -The fence above closes `main -> S -> main`. The HEAD aliases identically and was fenced by nothing: +The fence above narrows `main -> S -> main` — it does not close it on its own; see residual 1, and `ci.verdict-unverified-write-sentinel` for the post-POST re-count that does. The HEAD aliases identically and was fenced by nothing: a force-push `H1 -> H2 -> H1` spanning `scripts/pr-changed-files.sh`'s paging leaves that script's final `.head.sha` comparison equal while the middle pages were enumerated against `H2`, so a mixed file list can yield a docs-only exemption no single head ever justified. Two issues found it @@ -245,8 +251,9 @@ to every row it extracts since #643, and the walk whose counts gate the write di PR — truncate both walks at the same place, both counts then agree, the sha comparison agrees, and the ABA is unseen. - What the fence closes is therefore the ABA on a PR whose timeline carries no such truncating - block, which is every PR this repo has but is not a property anyone enforces. Said here rather + What the fence narrows is therefore the ABA on a PR whose timeline carries no such truncating + block, which is every PR this repo has but is not a property anyone enforces. Narrows, not closes, + even for those PRs: residual 1 below applies to them too. Said here rather than left in the issue because the whole of #803 is that a contract asserting more than its code does is worse than no contract — and a fence advertised as closing the head ABA would have been the fourth such contract, added by the change that removed three. diff --git a/docs/guard-inventory.md b/docs/guard-inventory.md index 85abde7c7..10940f412 100644 --- a/docs/guard-inventory.md +++ b/docs/guard-inventory.md @@ -331,7 +331,7 @@ job it covers, because a proof reference that covers a fraction must not read as | `pr-checks.yml::decisions-guard` | a PR whose decision records fail lifecycle validation, whose catalog is stale, or whose kickoff file drifted | GUARD | `scripts/decisions_validate.py`, `scripts/build_decisions_catalog.py`, `scripts/check-kickoff-guard.sh` | those scripts' rows above | | `pr-checks.yml::prove-fix` | a PR whose `Proves:` trailer names a test that passes without the fix | GUARD | inline + `scripts/prove-fix.sh` | that script's row above | | `pr-checks.yml::script-tests` | a PR failing ruff or the `scripts/tests` suite — this job is the RUNNER for every `scripts/tests/` row above | GUARD | inline (the ruff population guards) + pytest | the suite it runs | -| `review-verdict.yml::set-verdict-status` | a MERGE, by withholding the branch-protection-required `review-verdict/h10` status | GUARD | inline | `scripts/tests/test_pr_changed_files.py` guards its changed-file derivation | +| `review-verdict.yml::set-verdict-status` | a MERGE, by withholding the branch-protection-required `review-verdict/h10` status | GUARD | inline | `scripts/tests/test_pr_changed_files.py`, which EXECUTES the shipped `run:` body: it guards the changed-file derivation, the exemption classification, the timeline fence and — since #849 — the post-write verification state machine, each `test_MUTATION_…` there restoring the exact predecessor clause through `_run_classify(mutate=…)`. It does NOT cover the runner's step wiring, which no local test can reach | ### The four jobs with no dropped-step guard, decided per job (ersatztv#786) diff --git a/docs/remote-state-inventory.md b/docs/remote-state-inventory.md index 472f4c2f6..4fc2060b2 100644 --- a/docs/remote-state-inventory.md +++ b/docs/remote-state-inventory.md @@ -145,9 +145,9 @@ classifications differ; otherwise the strictest applies and the Note names the e | Site | Class | Note | |---|---|---| -| `.gitea/workflows/review-verdict.yml` — status read → status POST | `UNSAFE-KNOWN` | The residual this whole class reduces to. Gitea's status API has no ETag, no If-Match and no expected-previous-state, so read and write cannot be made one operation. Narrowed rather than claimed closed: a monotonic **event-count** fence refuses to write if the PR timeline's `change_target_branch` OR `pull_push` count moved (`ci.verdict-write-retarget-fence`; counts are used because the branch *name* and the head *sha* are both ABA-vulnerable — the head axis added by #803), and a high-water-mark re-read repairs a `success` posted over a human verdict back to `pending`. The file states the residual window explicitly rather than asserting safety. | +| `.gitea/workflows/review-verdict.yml` — status read → status POST | `UNSAFE-KNOWN` | The residual this whole class reduces to. Gitea's status API has no ETag, no If-Match and no expected-previous-state, so read and write cannot be made one operation. Narrowed rather than claimed closed: a monotonic **event-count** fence refuses to write if the PR timeline's `change_target_branch` OR `pull_push` count moved (`ci.verdict-write-retarget-fence`; counts are used because the branch *name* and the head *sha* are both ABA-vulnerable — the head axis added by #803), and a high-water-mark re-read repairs a status posted over a human verdict back to `pending` — after EVERY write since #849, not only an exemption `success`, because a generic `pending` masks a rejection just as well and was then re-derived green by the next run. Since #849 the write side is also fenced on the far end: the retarget count is re-taken AFTER the POST on the exemption path (closing the permanent forged green that record listed as residual 1), an exemption no high-water mark can cover is withheld rather than posted, and an unreadable combined-status read REPLACES the unknown state instead of leaving a possibly-forged green standing (`ci.verdict-unverified-write-sentinel`). The file states the residual window explicitly rather than asserting safety. | | `.gitea/workflows/review-verdict.yml` — base-ref checkout | `PINNED` | `ref: ${{ github.event.pull_request.base.sha }}` — a full sha from the fixed event payload, so the PR head cannot supply the workflow definition that judges it. | -| `.gitea/workflows/review-verdict.yml` — changed-file enumeration | `UNSAFE-KNOWN` | Delegates to `scripts/pr-changed-files.sh` with the head sha and expected base, and therefore **inherits that row's residual, not a pin** — a cross-reference to another row's GRADE goes stale the moment that row is regraded, so this one names what it inherits. Accepted on better terms than the enumerator alone — this is the one caller that also runs the monotonic event-count fence (`ci.verdict-write-retarget-fence`), and since #803 that fence covers BOTH axes: `change_target_branch` for the base alias and `pull_push` for the HEAD alias described in the enumerator's row (`H1 -> H2 -> H1` during pagination). What remains is the residual the fence shares with the base axis — a mutation landing between the final pre-write count and the POST (#849) — not an unwatched axis. | +| `.gitea/workflows/review-verdict.yml` — changed-file enumeration | `UNSAFE-KNOWN` | Delegates to `scripts/pr-changed-files.sh` with the head sha and expected base, and therefore **inherits that row's residual, not a pin** — a cross-reference to another row's GRADE goes stale the moment that row is regraded, so this one names what it inherits. Accepted on better terms than the enumerator alone — this is the one caller that also runs the monotonic event-count fence (`ci.verdict-write-retarget-fence`), and since #803 that fence covers BOTH axes: `change_target_branch` for the base alias and `pull_push` for the HEAD alias described in the enumerator's row (`H1 -> H2 -> H1` during pagination). What remains is the residual the fence shares with the base axis — a mutation landing between the final pre-write count and the POST — not an unwatched axis, and since #849 the RETARGET half of it is caught by a post-POST re-count while the push half is deliberately not (a push moves the head, so the status no longer gates that PR). | | `.gitea/workflows/docker-build.yml` — CI toolchain image | `UNSAFE-KNOWN` | This file's own definition of `PINNED` names an image **digest**, and `ersatztv-ci:` is a mutable **tag**. A registry tag can be repointed after `ci-image-pin` verifies it and before a job pulls it, and a 7-hex short sha is additionally collision-prone. Accepted rather than fixed here because the exposure needs write access to our own LAN registry — i.e. an attacker already inside the trust boundary — and two controls bound it: jobs never consume `:latest`, and `pr-checks.yml`'s `ci-image-pin` fails the build if the tag drifts from the last commit touching `docker/ci/`. Consuming `image@sha256:…` is the real fix and is the natural companion to **#772**, which already covers the availability half of this tag's weakness. | | `.gitea/workflows/docker-build.yml` — release smoke pull | `UNSAFE-KNOWN` | Pulls `${IMAGE}:${SMOKE_SHORT_SHA}`, the tag this same job pushed moments earlier. The job's `concurrency` group does not make that a pin: the group is **per-ref**, so a branch build and a tag build of the same commit sit in DIFFERENT groups and can publish the same `:` — the smoke step can therefore pull the other run's image. Accepted rather than fixed here because the fix is the same one-line change as the row above (pull `image@sha256:…`, propagated from the push step) and belongs with it; until then no registry row in this file claims to be pinned. | | `.gitea/workflows/docker-build.yml` — `api-docs` / `format` base fetch | `UNSAFE-KNOWN` | Fetches the live base tip to diff generated artifacts, with nothing pinning it. Grading this `N/A` on "advisory" is too quick: these are not branch-protection-required contexts, but the merge-consent hook reads Gitea's **combined** status, and a combined state that is not `success` blocks the auto-grant — so a wrong answer here does participate in merge consent. Accepted because the failure direction is benign: a base that advanced mid-job makes a generated artifact look stale and FAILS the job, costing a re-run, rather than passing something it should not. | diff --git a/scripts/tests/test_pr_changed_files.py b/scripts/tests/test_pr_changed_files.py index 0a26e52c8..367e1eca4 100644 --- a/scripts/tests/test_pr_changed_files.py +++ b/scripts/tests/test_pr_changed_files.py @@ -766,6 +766,26 @@ def _classify_step(): raise AssertionError("no step in review-verdict.yml posts review-verdict/h10") +def _sentinel(name: str) -> str: + """A sentinel description, read from the SHIPPED step body rather than copied into this file. + + Both sentinels are FIXED POINTS: the classification recognises its own previous output and + refuses to grant an exemption over it. A test carrying its own copy of the literal would keep + passing after the workflow's copy was reworded, while the real chain silently broke — the exact + shape of the round-3 defect the fixed-point test exists to catch. Reading it from the body binds + the two, and the count assertion means a renamed or duplicated assignment is a loud failure + rather than a wrong string. + """ + body = _classify_step()["run"] + found = re.findall(rf'^\s*{name}="([^"]*)"$', body, flags=re.MULTILINE) + assert len(found) == 1, f"expected exactly one {name} assignment in the step body, found {len(found)}" + return found[0] + + +REPAIR_DESC = _sentinel("REPAIR_DESC") +UNVERIFIED_DESC = _sentinel("UNVERIFIED_DESC") + + def _workflow_triggers(): """The `on:` block, tolerating YAML 1.1's `on` -> True coercion. @@ -976,6 +996,22 @@ if "/timeline" in url: if mode == "unreadable": print("502 Bad Gateway") sys.exit(0) + if mode == "unreadable-after-post": + # THE THIRD WALK ONLY (ersatztv#849 route 3). The job walks this endpoint three times on the + # exemption path — before classifying, at the pre-POST fence, and again after the POST — and + # this mode has to leave the first two trusted or the pre-POST fence refuses and there is no + # POST to re-check. + # + # THE THRESHOLD IS DERIVED FROM WHERE THE COUNTER MOVES, not picked. `timeline_reads.txt` is + # written on the LAST-REAL-PAGE branch below, i.e. once per walk at page 1, so the value READ + # at the top of this handler runs 0,1 (walk 1), 1,2 (walk 2), 2,3 (walk 3). `>= 3` is + # therefore first true at walk 3's terminator request and at no earlier one. `>= 2` — which is + # what `trusted-then-unreadable` uses for its own two-walk purpose — would break walk 2. + ctr0 = out / "timeline_reads.txt" + seen0 = int(ctr0.read_text()) if ctr0.exists() else 0 + if seen0 >= 3: + print("502 Bad Gateway") + sys.exit(0) if mode == "trusted-then-unreadable": # The FIRST count succeeds, the SECOND cannot be established. `timeline_reads.txt` is # incremented only on the LAST-REAL-PAGE branch below, after the `page > pages` and @@ -1034,30 +1070,32 @@ if "/timeline" in url: print(json.dumps([{"id": 100 + i, "type": "comment"} for i in range(50)])) sys.exit(0) - n_before, n_after = 0, 0 + # ONE VALUE PER WALK, CLAMPED — not a fixed before/after pair. The job walks this endpoint a + # THIRD time after the POST on the exemption path (ersatztv#849 route 3), so a two-element knob + # can no longer address every count the job takes. "moves:A,B" keeps its old meaning exactly + # (walk 3 clamps to B, and the two-walk fixtures that use it abstain before walk 3 anyway); + # "moves:A,B,C" reaches the post-POST walk, which is the arrangement that separates "retargeted + # while classifying" — caught by the pre-POST fence — from "retargeted after the write landed". + n_seq = [0] if mode.startswith("stable:"): - n_before = n_after = int(mode.split(":", 1)[1]) + n_seq = [int(mode.split(":", 1)[1])] elif mode.startswith("moves:"): - # "moves:A,B" — A retarget events on the fence's FIRST count, B on the re-count taken just - # before the POST. This is the race-1 window: the PR was retargeted while the job classified. - a, b = mode.split(":", 1)[1].split(",") - n_before, n_after = int(a), int(b) + n_seq = [int(x) for x in mode.split(":", 1)[1].split(",")] # THE PUSH AXIS IS INDEPENDENT OF THE RETARGET AXIS (ersatztv#803/#664), and it has to be, or the # head-fence tests could not distinguish which fence fired. Both counts come off the SAME page — # the job makes one walk and tallies two `.type` values — so the stub serves them from one # response, but the two `moves:`/`stable:` knobs are separate. pmode = os.environ.get("STUB_PUSH_MODE", "none") - p_before, p_after = 0, 0 + p_seq = [0] if pmode.startswith("stable:"): - p_before = p_after = int(pmode.split(":", 1)[1]) + p_seq = [int(pmode.split(":", 1)[1])] elif pmode.startswith("moves:"): - a, b = pmode.split(":", 1)[1].split(",") - p_before, p_after = int(a), int(b) + p_seq = [int(x) for x in pmode.split(":", 1)[1].split(",")] ctr = out / "timeline_reads.txt" seen = int(ctr.read_text()) if ctr.exists() else 0 ctr.write_text(str(seen + 1)) - n = n_before if seen == 0 else n_after - np = p_before if seen == 0 else p_after + n = n_seq[min(seen, len(n_seq) - 1)] + np = p_seq[min(seen, len(p_seq) - 1)] # `is_force_push` is emitted because the real endpoint emits it (measured on PR #761), NOT # because the job reads it — the fence counts rows by `.type` and never parses the body. A stub # that omitted it would leave a reader thinking the field is unused by contract rather than by @@ -1293,6 +1331,19 @@ if "/statuses/" in url: rows = [{"id": 8500, "context": "review-verdict/h10", "status": "failure", "creator": 7, "description": "Review-verdict: BLOCKED @ a9e3e23 (base: main)"}, dict(raced_row)] + elif mode == "malformed-description-beside-verdict": + # THE TWIN OF `malformed-creator-beside-verdict`, on the other field the filter reads. + # `description` is a NUMBER, which `(.description // "") | startswith(...)` does NOT + # protect against — `//` replaces only `null` and `false` — so `startswith` hard-errors on + # it and jq exits 5. The genuine verdict beside it is what makes the consequence visible: + # the whole count dies, so a real raced verdict is reported as uncertainty instead. + ctr = out / "history_reads.txt" + seen = int(ctr.read_text()) if ctr.exists() else 0 + ctr.write_text(str(seen + 1)) + if seen > 0: + rows = [{"id": 8600, "context": "review-verdict/h10", "status": "failure", + "creator": {"login": "timothy"}, "description": 7}, + dict(raced_row)] elif mode == "verdict-after-short-page": # DELIBERATELY UNFAITHFUL, and that is the point. Page 2 is SHORT (10 rows) and yet page 3 # still carries rows — a shape the measured server does not produce, but exactly what a @@ -1574,6 +1625,7 @@ def _run_classify( reviewers: str | None = None, timeline_terminator: str = "null", status_empty_shape: str = "null", + mutate: tuple[str, str] | None = None, ): """Execute the workflow's classify `run:` block with a stubbed enumeration script. @@ -1647,6 +1699,25 @@ def _run_classify( flags=re.MULTILINE, ) assert n == 1, f"expected exactly one H10_REVIEWERS assignment to override, substituted {n}" + if mutate is not None: + # DISARM A CLAUSE OF THE SHIPPED BODY, for the mutation proofs (ersatztv#849). + # + # THE MUTANT IS THE REAL PREDECESSOR, not a hand-written stand-in: every use below restores + # the exact text this branch replaced, so a proof shows the fix — rather than showing that + # some plausible-looking wrong version would fail. A hand-written mutant proves a test can go + # red, which is not the same claim. + # + # The count assertion is the binding. Without it a clause that has since been reworded or + # moved substitutes ZERO times, the "mutant" is the unmutated body, and the proof asserts the + # unmutated behaviour while reporting success — a mutation claim that measures nothing. + old_clause, new_clause = mutate + occurrences = body.count(old_clause) + assert occurrences == 1, ( + f"the mutation target appears {occurrences} times in the shipped step body, expected 1. " + f"It has been reworded, moved or duplicated, so this proof is no longer bound to the " + f"clause it names: {old_clause!r}" + ) + body = body.replace(old_clause, new_clause, 1) script.write_text(body) r = subprocess.run(["bash", str(script)], cwd=tmp_path, env=env, capture_output=True, text=True) # Assert the WIRING, not only the classification. The stub accepts every POST, so a status aimed @@ -1762,20 +1833,40 @@ def test_bot_positive_control_a_plain_bot_pr_IS_exempt(tmp_path): # survived the full suite without them, including re-introducing the literal ersatztv#647 fail-open. -def test_a_transport_failure_on_the_STATUS_READ_posts_NOTHING(tmp_path): +def test_a_transport_failure_on_the_STATUS_READ_REPLACES_the_unknown_state(tmp_path): """`gh()` is `curl -sf`, so an HTTP error yields exit 22 and EMPTY stdout. Reading that as "no - verdict exists" would let the job post over a real human verdict. It must fail WITHOUT posting. + verdict exists" would let the job post over a real human verdict, so the exemption is still + withheld — but "withheld" now means REPLACED, not merely not-written (ersatztv#849 route 5). + + This test used to assert `posted is None`, on the reasoning that declining to write protects a + verdict this job cannot see. True when the head carries a REAL verdict; exactly wrong when it + carries a FORGED one. An off-list credential's `review-verdict/h10=success` is the status #742 + exists to revoke, revocation happens by re-deriving the row, and an unreadable read is the one + thing that stops it. The job went red — and its own job status is not a required check, so + branch protection still saw the green. Uncertainty resolved toward SUCCESS. + + So the unknown state is durably replaced with the sticky unverified sentinel. Nothing is lost + that cannot be recovered: `/statuses/{sha}` keeps one row per POST, so a genuine verdict masked + here is still in the history and the reconciliation on the next run finds it. """ posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode="transport-error") - assert r.returncode != 0, "an unreadable status read must fail the job" - assert posted is None, "nothing may be posted when the existing verdict state is unknown" + assert r.returncode != 0, "an unreadable status read must still fail the job" + assert posted is not None, ( + f"an unreadable status read left whatever this head carries standing unread:\n{r.stdout[-900:]}" + ) + assert posted["state"] == "pending" and posted["description"] == UNVERIFIED_DESC, ( + f"expected the unverified-write sentinel, got {posted}\n{r.stdout[-900:]}" + ) -def test_a_GARBAGE_status_response_posts_NOTHING(tmp_path): - """A proxy error page is a 200 with a non-JSON body — not an absent verdict.""" +def test_a_GARBAGE_status_response_REPLACES_the_unknown_state(tmp_path): + """A proxy error page is a 200 with a non-JSON body — not an absent verdict, and not a state this + job may leave standing unread. Same treatment as the transport failure above.""" posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode="garbage") assert r.returncode != 0 - assert posted is None + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + f"expected the unverified-write sentinel, got {posted}\n{r.stdout[-900:]}" + ) # Two mutations to this read are NOT covered, and both are behaviourally equivalent rather than gaps: @@ -1871,7 +1962,14 @@ def test_a_transport_failure_under_jq_1_6_STILL_posts_nothing(tmp_path): assert r.returncode != 0, ( "under jq 1.6 an unreadable status read must still fail the job — this is the exact ersatztv#647 fail-open" ) - assert posted is None, "nothing may be posted when the existing verdict state is unknown" + # THE MUTANT THIS CATCHES IS THE EXEMPTION, not the absence of a write (ersatztv#849 route 5 + # changed what "withhold" means here — see the transport-failure test above). Drop the emptiness + # check and jq 1.6 reads "" as "no verdict exists", so the job posts `Exempt: docs-only change`. + # The sentinel and that exemption are the two distinguishable outcomes. + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + f"under jq 1.6 an empty body was read as 'no verdict exists' and classified normally: {posted}" + f"\n{r.stdout[-900:]}" + ) def test_DOCS_ONLY_anchors_the_START_of_the_path_too(tmp_path): @@ -3375,6 +3473,13 @@ def test_a_human_verdict_landing_AFTER_the_POST_is_repaired_to_pending(tmp_path) assert seq[0]["state"] == "success" assert seq[1]["state"] == "pending", f"the raced exemption was not repaired: {seq}" assert posted is not None and posted["state"] == "pending" + # WHICH SENTINEL, not merely `pending` (ersatztv#849). A verdict was COUNTED above the mark, so + # this arm may assert the strong fact — a human verdict existed and was buried — which only a + # human can clear. The uncertainty arms write the reconcilable sentinel instead, and a test that + # checked only the state could not tell the two apart. + assert seq[1]["description"] == REPAIR_DESC, ( + f"a counted human verdict was recorded with the wrong sentinel: {seq}\n{r.stdout[-900:]}" + ) def test_a_PRE_EXISTING_human_row_does_NOT_trigger_a_repair(tmp_path): @@ -4279,9 +4384,9 @@ def test_a_STRING_total_count_is_not_accepted_as_numeric_zero(tmp_path): The accept path requires the TYPE to be number. """ posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode="total-count-string") - assert posted is None, ( + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( f"a string total_count was accepted as numeric zero, so a body that merely lost its statuses " - f"array reads as 'no verdict exists': {posted}\n{r.stdout[-800:]}" + f"array read as 'no verdict exists' and was classified normally: {posted}\n{r.stdout[-800:]}" ) @@ -4451,6 +4556,14 @@ def test_an_EMPTY_post_write_history_is_not_evidence_that_nothing_raced(tmp_path assert seq[0]["state"] == "success" and seq[1]["state"] == "pending", ( f"expected an exemption then a repair to pending; got {seq}" ) + # THE RECONCILABLE SENTINEL, NOT THE REPAIR ONE (ersatztv#849). Nothing here established that a + # verdict was buried — the response was impossible, not informative — so claiming the repair + # sentinel's meaning would be the same overclaim the `::error::` below is careful to avoid. It + # also matters operationally: the repair sentinel is clearable only by a human, this one by the + # next run that can read the history. + assert seq[1]["description"] == UNVERIFIED_DESC, ( + f"an unverifiable read was recorded as a buried human verdict: {seq}\n{r.stdout[-900:]}" + ) assert "came back empty" in log, ( f"repaired, but the impossible empty read was not reported as the reason:\n{r.stdout[-900:]}" ) @@ -4539,8 +4652,12 @@ def test_a_history_with_NO_numeric_ids_SKIPS_the_check_instead_of_marking_zero(t f"degradation is invisible in the log:\n{r.stdout[-900:]}" ) seq = _posted_sequence(tmp_path) - assert len(seq) == 1 and seq[0]["state"] == "success", ( - f"expected the exemption to be posted with the race check skipped; got {seq}" + # THE SECOND HALF OF THE SAME ANSWER (ersatztv#849 route 1). Refusing to collapse the mark to 0 + # is only half of it: with no mark, nothing verifies the write, so the exemption is withheld and + # the head is marked with the sticky sentinel rather than greened unverified. + assert len(seq) == 1, f"expected exactly one write, not a green plus a repair; got {seq}" + assert seq[0]["state"] == "pending" and seq[0]["description"] == UNVERIFIED_DESC, ( + f"an unreadable id schema skipped the check AND kept the exemption: {seq}\n{r.stdout[-900:]}" ) @@ -4642,16 +4759,22 @@ def test_a_TRANSIENT_page_failure_is_retried_and_the_walk_still_completes(tmp_pa assert seq[0]["state"] == "success", f"expected the exemption to stand; got {seq}" -def test_a_PRE_WRITE_read_that_returns_NOTHING_abandons_the_mark_and_says_so(tmp_path): - """The boundary of the partial-list fallback, pinned so the remaining fail-open is explicit. +def test_a_PRE_WRITE_read_that_returns_NOTHING_WITHHOLDS_the_exemption(tmp_path): + """The boundary of the partial-list fallback, and what happens past it (ersatztv#849 route 1). A partial list still yields a usable mark. A read that returns NO rows at all cannot: there is - nothing to take a maximum over, so `max_id_before` stays -1 and the post-write race check is - skipped entirely — the exemption is posted with nothing verifying it afterwards. + nothing to take a maximum over, so `max_id_before` stays -1 and NOTHING can check the write + afterwards. This used to post the exemption anyway, with the race check skipped — a green on a + head whose write window was never inspected, which is the fail-toward-SUCCESS direction on the + one path where it is never acceptable. - That is the pre-existing gap tracked as ersatztv#849, deliberately unchanged here, and it is - asserted rather than left implicit so the degradation is visible in the log and a future change - that widens or closes it has to come past this test. + The exemption is now withheld BEFORE the write rather than posted and repaired: the defect is + known before the POST, so publishing a green and taking it back would only open a window for + branch protection — and an already-scheduled auto-merge — to see it. + + THE SENTINEL, NOT A GENERIC `pending`. That distinction is the whole reason #742's attempt at + this was withdrawn: a generic `pending` is precisely what a later run re-derives into `success`. + Asserting the description here is asserting the fix, not its packaging. """ posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), history_mode="premark-page1-error") assert r.returncode == 0, r.stderr @@ -4663,8 +4786,9 @@ def test_a_PRE_WRITE_read_that_returns_NOTHING_abandons_the_mark_and_says_so(tmp f"a mark was salvaged from an empty read, which there is nothing to compute:\n{r.stdout[-900:]}" ) seq = _posted_sequence(tmp_path) - assert len(seq) == 1 and seq[0]["state"] == "success", ( - f"expected the exemption to be posted with the race check skipped; got {seq}" + assert len(seq) == 1, f"expected exactly one write — the sentinel, not a green followed by a repair; got {seq}" + assert seq[0]["state"] == "pending" and seq[0]["description"] == UNVERIFIED_DESC, ( + f"an exemption was posted with nothing able to verify it: {seq}\n{r.stdout[-900:]}" ) @@ -4800,6 +4924,446 @@ def test_an_UNREADABLE_history_page_2_also_repairs_rather_than_leaving_green(tmp ) +# --- ersatztv#849: post-write verification, the five routes ------------------------------------ +# +# Each route left an exemption `success` — or a generic `pending` a later run re-derives into one — +# standing over a human `failure`. The tests below pair a behavioural assertion with a MUTATION that +# restores the exact predecessor text, so each proof shows the shipped clause is what produces the +# outcome rather than showing that some plausible wrong version would fail. + + +def test_a_verdict_racing_a_PENDING_write_is_repaired_to_the_repair_sentinel(tmp_path): + """Route 2. The post-write check used to run only after an exemption `success`. + + The reasoning was "a `pending` cannot turn a rejection green, so the only write that can cause + the damage is the exemption". The counter-example is ordinary: a docs-only PR hits a transient + enumeration failure, so the run writes the GENERIC `pending` — which masks a rejection landing in + its own write window exactly as a `success` would, and got no verification because of the state + test. The next run sees an ordinary machine `pending`, re-derives it into an exemption, and the + human's row is now below THAT run's high-water mark and invisible. + + A failing enumeration is what selects the generic-`pending` path, which is why the stub exits 1. + """ + posted, r = _run_classify( + tmp_path, + "#!/usr/bin/env bash\n" + DOCS_ONLY + "exit 1\n", + history_mode="human-after-post", + ) + assert r.returncode == 0, r.stderr + seq = _posted_sequence(tmp_path) + assert len(seq) == 2, ( + "a human verdict landed in the write window of a generic `pending` and nothing verified it, " + f"so a later run can re-derive that pending into an exemption. Posts: {seq}\n{r.stdout[-900:]}" + ) + assert seq[0]["state"] == "pending" and seq[0]["description"] != REPAIR_DESC, ( + f"expected the run's own generic pending first; got {seq}" + ) + assert seq[1]["description"] == REPAIR_DESC, ( + f"the raced pending was not repaired to the sticky sentinel: {seq}\n{r.stdout[-900:]}" + ) + + +def test_MUTATION_a_SUCCESS_only_post_write_gate_leaves_a_raced_PENDING_unrepaired(tmp_path): + """The predecessor restored: `main`'s `state = success` conjunct on the post-write gate. + + Without the pairing this is the shape ersatztv#787 catalogues as a test that passes for the wrong + reason — the fixture reaches the repair, but nothing shows the WIDENED gate is what took it there + rather than some other clause. + """ + _run_classify( + tmp_path / "mutant", + "#!/usr/bin/env bash\n" + DOCS_ONLY + "exit 1\n", + history_mode="human-after-post", + mutate=( + 'if [ "$max_id_before" -ge 0 ]; then', + 'if [ "$state" = "success" ] && [ "$max_id_before" -ge 0 ]; then', + ), + ) + seq = _posted_sequence(tmp_path / "mutant") + assert len(seq) == 1, ( + "the mutant repaired anyway, so this fixture does not reach the repair through the widened " + f"gate and the test above proves nothing about it: {seq}" + ) + assert seq[0]["state"] == "pending" and seq[0]["description"] != REPAIR_DESC, ( + f"expected the unrepaired generic pending under the mutant; got {seq}" + ) + + +def test_a_raced_PENDING_write_repairs_and_the_repair_SURVIVES_the_next_run(tmp_path): + """The CHAINED form, which is the only shape that can assert a fixed point. + + A single-hop test shows run N repairs. It cannot show the repair SURVIVES, and that is exactly + where route 2's damage lives — the burial arrives one event later, in run N+1, when a machine + `pending` is re-derived into an exemption. So run N's real output is fed in as run N+1's existing + status, with run N+1's enumeration now succeeding as a clean docs-only PR: the run that would + have granted the exemption is the one that must refuse. + """ + first, r1 = _run_classify( + tmp_path / "run1", + "#!/usr/bin/env bash\n" + DOCS_ONLY + "exit 1\n", + history_mode="human-after-post", + ) + seq1 = _posted_sequence(tmp_path / "run1") + assert seq1 and seq1[-1]["description"] == REPAIR_DESC, ( + f"run 1 did not repair, so there is no chain to test: {seq1}\n{r1.stdout[-900:]}" + ) + + second, r2 = _run_classify( + tmp_path / "run2", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=seq1[-1]["description"], # <-- the chain: run N's own output + ) + assert second is not None, f"run 2 posted nothing: {r2.stderr[-800:]}" + assert second["state"] == "pending", ( + "run 2 re-derived run 1's repair into an exemption, burying the human verdict one event " + f"later than the write that raced it: {second}\n{r2.stdout[-900:]}" + ) + assert second["description"] == seq1[-1]["description"], ( + f"the repair is not a fixed point across the chain: {seq1[-1]['description']!r} -> {second['description']!r}" + ) + + +def test_the_unverified_sentinel_is_a_FIXED_POINT_while_it_cannot_be_reconciled(tmp_path): + """Route 1, chained. Withholding the exemption is only half the fix; the mark has to STICK. + + #742's attempt withheld the exemption by writing a GENERIC `pending`, and a generic `pending` is + precisely what a later run re-derives into `success`. It moved which run posted the forged green + rather than stopping it, which is why it was withdrawn. So the property is a fixed point and only + a chain can assert it: run 1 fails to establish a mark and writes the sentinel; run 2 carries + run 1's own description, STILL cannot read the history to reconcile it, and must write the same + thing again rather than exempting a head nothing has ever verified. + """ + first, r1 = _run_classify(tmp_path / "run1", _emitting("docs/a.md"), history_mode="premark-page1-error") + assert first is not None and first["description"] == UNVERIFIED_DESC, ( + f"run 1 did not write the sentinel: {first}\n{r1.stdout[-900:]}" + ) + + second, r2 = _run_classify( + tmp_path / "run2", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=first["description"], # <-- the chain + history_mode="premark-page1-error", # and still unreadable, so it cannot be reconciled away + ) + assert second is not None, f"run 2 posted nothing: {r2.stderr[-800:]}" + assert second["state"] == "pending" and second["description"] == first["description"], ( + "the unverified sentinel decayed: run 1's own output did not re-trigger the refusal, so run 2 " + f"exempted a head whose write was never verified ({first['description']!r} -> {second})" + f"\n{r2.stdout[-900:]}" + ) + + +def test_MUTATION_a_GENERIC_pending_for_an_unverifiable_write_is_re_derived_into_an_exemption(tmp_path): + """The predecessor restored: #742's withdrawn attempt, which wrote the GENERIC description. + + This is the proof that the SENTINEL is the fix and not merely its packaging, and it needs both + arms of a chain because run 1 is indistinguishable either way — the mutant and the shipped code + post the same `pending` STATE, differing only in a description nothing has read yet. + + Run 2 is where they diverge, and the fixture is the case that matters: a head that DOES carry a + buried verdict, below whatever high-water mark run 2 takes. The shipped chain recognises its own + sentinel, reconciles, finds the verdict and refuses. The mutant chain sees an ordinary machine + `pending`, re-derives it into a docs-only exemption, and the post-write check cannot save it — + the verdict predates the mark, so it is invisible and the green stands over it. That is route 1's + damage arriving one event later, which is exactly what "sticky" prevents and "generic" does not. + """ + generic = ( + ' state=pending\n desc="$UNVERIFIED_DESC"\nfi\n\n# LAST-MOMENT RE-READ', + ' state=pending\n desc="Awaiting review verdict for ${SHA:0:7}"\nfi\n\n# LAST-MOMENT RE-READ', + ) + + # --- the mutant chain --- + m1, rm1 = _run_classify( + tmp_path / "mutant1", + _emitting("docs/a.md"), + history_mode="premark-page1-error", + mutate=generic, + ) + assert m1 is not None and m1["state"] == "pending", ( + f"the mutant did not withhold the exemption either, so this is not the chain under test: {m1}" + f"\n{rm1.stdout[-900:]}" + ) + assert m1["description"] != UNVERIFIED_DESC, f"the mutation did not take — run 1 still wrote the sentinel: {m1}" + m2, rm2 = _run_classify( + tmp_path / "mutant2", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=m1["description"], # <-- the chain + history_mode="stale-human-already-present", # a verdict IS buried on this head + ) + assert m2 is not None and m2["state"] == "success", ( + "the generic-`pending` mutant was NOT re-derived into an exemption, so the shipped arm below " + f"is not discriminating on the description: {m2}\n{rm2.stdout[-900:]}" + ) + + # --- the shipped chain, same fixture --- + f1, rf1 = _run_classify(tmp_path / "fixed1", _emitting("docs/a.md"), history_mode="premark-page1-error") + assert f1 is not None and f1["description"] == UNVERIFIED_DESC, f"run 1: {f1}" + f2, rf2 = _run_classify( + tmp_path / "fixed2", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=f1["description"], # <-- the chain + history_mode="stale-human-already-present", + ) + assert f2 is not None and f2["state"] == "pending" and f2["description"] == REPAIR_DESC, ( + "the shipped chain re-derived its own sentinel into an exemption over a buried verdict: " + f"{f2}\n{rf2.stdout[-900:]}" + ) + + +def test_the_unverified_sentinel_is_RECONCILED_AWAY_once_the_history_can_be_read(tmp_path): + """The bound on the stall, and the reason this is a second sentinel rather than the repair one. + + The repair sentinel records a fact that stays true — a verdict existed and was buried — so only a + human can clear it. "I could not read the history" expires: a run that CAN read it settles the + question. Without this the fix would trade route 1's fail-open for a permanent stall on a + transient API failure, which is the trade #742 was withdrawn for making. + + Here the history is readable and carries no verdict row, so nothing was masked and the sentinel is + cleared — the docs-only exemption is granted on its own merits. + """ + posted, r = _run_classify( + tmp_path, + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=UNVERIFIED_DESC, + ) + assert posted is not None, f"nothing was posted: {r.stderr[-800:]}" + assert posted["state"] == "success", ( + "a readable history carrying no verdict left the sentinel standing, so a transient failure " + f"costs a head its exemption permanently: {posted}\n{r.stdout[-900:]}" + ) + assert "Reconciled" in (r.stdout + r.stderr), ( + f"the exemption was granted without the reconciliation running:\n{r.stdout[-900:]}" + ) + + +def test_the_reconciliation_UPGRADES_to_the_repair_sentinel_when_a_verdict_is_BURIED(tmp_path): + """The other direction, and the one that must never resolve to an exemption. + + A verdict masked by an unverified write is invisible on the COMBINED endpoint — that returns the + latest row per context, and the latest row is the sentinel. It is still in `/statuses/{sha}`, + which returns one row per POST, and that asymmetry is what makes reconciliation possible at all. + + Finding one turns an open question into an established fact, so it upgrades to the repair + sentinel — clearable only by a human — rather than clearing. + """ + posted, r = _run_classify( + tmp_path, + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=UNVERIFIED_DESC, + history_mode="stale-human-already-present", + ) + assert posted is not None, f"nothing was posted: {r.stderr[-800:]}" + assert posted["state"] == "pending" and posted["description"] == REPAIR_DESC, ( + f"a verdict buried under an unverified write was reconciled into an exemption: {posted}\n{r.stdout[-900:]}" + ) + assert "verdict row(s) underneath an unverified" in (r.stdout + r.stderr), ( + f"the upgrade happened without saying why:\n{r.stdout[-900:]}" + ) + + +def test_MUTATION_reconciling_on_an_UNREADABLE_history_re_exempts_a_head(tmp_path): + """The clause that must not be relaxed: only a COMPLETE walk may clear the sentinel. + + An unreadable reconciliation is the same "cannot tell" every other branch here resolves to + pending. Treating it as "nothing buried" is the fail-open, and it is a tempting simplification + because the happy path looks identical. + + The mutant inverts the trust test. The fixture makes the reconciliation walk fail while a verdict + IS present in the history, so the shipped code carries the sentinel forward and the mutant + exempts the head over a buried verdict. + """ + posted, r = _run_classify( + tmp_path / "fixed", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=UNVERIFIED_DESC, + history_mode="premark-page1-error", + ) + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + f"an unreadable reconciliation did not carry the sentinel forward: {posted}\n{r.stdout[-900:]}" + ) + + mutant, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=UNVERIFIED_DESC, + history_mode="premark-page1-error", + mutate=('\n if [ "$ph_ok" != yes ]; then\n', "\n if false; then\n"), + ) + assert mutant is not None and mutant["state"] == "success", ( + "the mutant did not re-exempt, so the trust test is not what holds the sentinel and the " + f"assertion above proves nothing about it: {mutant}\n{rm.stdout[-900:]}" + ) + + +def test_a_MALFORMED_description_row_does_not_kill_the_post_write_count(tmp_path): + """Route 4, the twin of the malformed-`creator` row already covered above. + + `(.description // "") | startswith(...)` does not protect anything: `//` replaces `null` and + `false`, so a NUMERIC description survives it and `startswith` hard-errors on a number. jq exits + 5, the count comes back unusable, and while the surrounding code now fails closed, the cost is + the whole count — a genuine verdict on another row is lost with it. Type-testing drops the + malformed row and keeps the real one countable, which is exactly what the `creator` guard beside + it already does. + """ + posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), history_mode="malformed-description-beside-verdict") + assert r.returncode == 0, r.stderr + seq = _posted_sequence(tmp_path) + assert len(seq) == 2 and seq[1]["state"] == "pending", ( + f"the raced verdict beside a malformed-description row was not repaired: {seq}\n{r.stdout[-900:]}" + ) + assert seq[1]["description"] == REPAIR_DESC, ( + "the genuine verdict was lost with the malformed row, so the repair reports uncertainty " + f"rather than the verdict it should have counted: {seq}\n{r.stdout[-900:]}" + ) + + +def test_MUTATION_restoring_the_default_operator_on_the_description_loses_the_verdict(tmp_path): + """The predecessor restored. `//` looks like a guard and is not one for a non-null wrong type. + + The mutant still repairs — the surrounding unusable-count branch fails closed — so the STATE is + identical and only the reported reason separates them. That is the discriminator this proof + turns on: the shipped code counts the verdict, the mutant cannot see it and repairs on + uncertainty instead, which is the difference between telling a reviewer their verdict was + overwritten and telling them nothing could be checked. + """ + _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + history_mode="malformed-description-beside-verdict", + mutate=( + ' and ((.description | type) == "string")\n' + ' and (.description | startswith("Review-verdict:")))', + ' and (((.description // "") | startswith("Review-verdict:"))))', + ), + ) + seq = _posted_sequence(tmp_path / "mutant") + assert len(seq) == 2 and seq[1]["description"] != REPAIR_DESC, ( + "the mutant still counted the verdict, so the type test is not what makes it countable and " + f"the test above proves nothing about it: {seq}" + ) + + +def test_a_RETARGET_AFTER_the_POST_replaces_the_exemption(tmp_path): + """Route 3. The fence narrows "writing while overtaken"; it never covered "overtaken after + writing", and that second window is the one that leaves a PERMANENT forged green. + + `main` carries a real `failure` for head H. The PR is retargeted to a scratch base where H is + docs-only; run S classifies, derives `success`, and passes its final fence check. The PR is + retargeted BACK to `main` while S is paused before its POST. The successor run sees the + base-matching `failure`, short-circuits, and posts nothing. S resumes and posts its stale + `success`, which predates its own high-water mark — so the post-write check cannot see it — and + NO EVENT REMAINS to reclassify. + + `moves:0,0,1` is that arrangement: quiet through classification and the pre-POST fence, moved by + the time the write has landed. + """ + posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), timeline_mode="moves:0,0,1") + assert r.returncode == 0, r.stderr + seq = _posted_sequence(tmp_path) + assert len(seq) == 2, ( + "a retarget after the POST left the exemption standing over a diff it no longer describes, " + f"with no event left to correct it. Posts: {seq}\n{r.stdout[-900:]}" + ) + assert seq[0]["state"] == "success" + assert seq[1]["state"] == "pending" and seq[1]["description"] == UNVERIFIED_DESC, ( + f"expected the exemption to be replaced by the reconcilable sentinel; got {seq}" + ) + assert "retargeted, or its retarget count became unreadable, AFTER" in (r.stdout + r.stderr), ( + f"the replacement happened without naming the post-write retarget:\n{r.stdout[-900:]}" + ) + + +def test_an_UNREADABLE_retarget_count_AFTER_the_POST_also_replaces_the_exemption(tmp_path): + """ "I cannot tell whether the base moved" must not resolve to leaving a green. + + Reaching this point with a `success` means both earlier counts were TRUSTED — the pre-POST fence + refuses an exemption otherwise — so a third read that cannot be trusted is a fresh failure, not + the same one seen twice. The cost is bounded by the sentinel being reconcilable: the next run + that can read the history restores the exemption without a human. + """ + posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), timeline_mode="unreadable-after-post") + seq = _posted_sequence(tmp_path) + assert len(seq) == 2, ( + f"an unreadable post-write retarget count left the exemption green. Posts: {seq}\n{r.stdout[-900:]}" + ) + assert seq[0]["state"] == "success" + assert seq[1]["description"] == UNVERIFIED_DESC, f"got {seq}" + + +def test_positive_control_a_QUIET_timeline_after_the_POST_leaves_the_exemption_alone(tmp_path): + """The re-check must not fire on the ordinary path, or every exempt PR loses its exemption. + + Stated as its own test rather than inferred from the other exemption tests passing: those would + also pass if the re-check ran and found nothing, and would NOT distinguish that from the re-check + being skipped. The single POST is what says it ran and stayed quiet. + """ + posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), timeline_mode="stable:2") + seq = _posted_sequence(tmp_path) + assert len(seq) == 1 and seq[0]["state"] == "success", ( + f"a quiet timeline cost the PR its exemption: {seq}\n{r.stdout[-900:]}" + ) + + +def test_MUTATION_deleting_the_POST_POST_retarget_check_leaves_the_stale_green(tmp_path): + """The predecessor restored: `main` had no post-POST re-check at all. + + Disarming the re-check's own condition is the precise mutation — the walk still happens, so this + isolates the DECISION rather than the round-trip. + """ + _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + timeline_mode="moves:0,0,1", + mutate=( + ' if [ "$rt_ok" != yes ] || [ "$rt_count" -ne "$retargets_before" ]; then', + " if false; then", + ), + ) + seq = _posted_sequence(tmp_path / "mutant") + assert len(seq) == 1 and seq[0]["state"] == "success", ( + "the mutant did not leave the stale green, so the fixture reaches the replacement by some " + f"route other than the post-POST re-check: {seq}" + ) + + +def test_MUTATION_declining_to_replace_an_unreadable_combined_read_leaves_the_forged_green(tmp_path): + """Route 5's predecessor restored: `exit 1` with nothing posted. + + The behavioural half is asserted by the transport-failure and garbage tests above. This is what + binds those to the shipped clause: with the POST removed the job is red and silent, which is + exactly `main`'s behaviour and exactly what leaves an off-list `success` standing. + """ + posted, r = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="transport-error", + mutate=( + ' if repair_status_to "$UNVERIFIED_DESC"; then', + " if false; then", + ), + ) + assert r.returncode != 0 + assert posted is None, ( + "the mutant still posted, so the replacement does not go through `repair_status_to` and the " + f"route-5 tests are not bound to it: {posted}" + ) + + # --- Workflow token scope (ersatztv#748) ------------------------------------------------------- # # Both assertions guard a property whose violation is SILENT and, for the first, unrecoverable. -- 2.47.3 From 957a328f335d8c4b97e649eed4e38a894c65c2eb Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 30 Aug 2026 00:26:32 +0200 Subject: [PATCH 02/11] =?UTF-8?q?fix(849):=20round=202=20=E2=80=94=20the?= =?UTF-8?q?=20uncertainty=20paths=20that=20still=20resolved=20toward=20suc?= =?UTF-8?q?cess?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent cold reviews (Codex/GPT-5.6 cross-family, and a cold Claude reviewer in its own worktree) converged on the same class: paths where "this job cannot establish what is on the head" still resolved by leaving the head alone, which protects a real verdict and leaves a forged one. Behaviour: 1. The four page-2 completeness refusals now replace the unknown state too. They were excluded on the reasoning that the probe fires when NO row for this context was on page 1, so there is no green of any provenance to leave standing — self-contradictory, since the only reason page 2 is read is that the row may be beyond page 1, which the probe's own message says. Accepted cost, stated in the record: a head with more CONTEXTS than the 50-row cap stalls every run; measured 2026-08-29, this repo puts 8 on a `main` head, and that case already stalled with an ABSENT check. 2. The no-mark downgrade covers every re-derivable write, not only `success`. Restricting it analysed the wrong PR: the damaging case is one that IS exemptible and got the generic `pending` only from a transient enumeration failure. That description carries no marker, nothing verifies it without a mark, and the next run re-derives it into the exemption with the human row below its own mark — route 2's damage through route 1's condition. `$REPAIR_DESC` stays exempt, being stronger and not re-derivable. 3. The fence branch that cannot trust its retarget count while holding a derived `success` writes the sentinel instead of abstaining. It is reached only after the classification DECLINED to inherit the row the head carries, so posting nothing left that row current; the message said the context "stays absent", true only of a head that had none. 4. Reconciliation needs a WITNESS: it may clear only over a complete history containing the sentinel's own row. `ex_unverified` means the combined endpoint just returned that row and `/statuses/{sha}` keeps one per POST, so a complete-but-empty history contradicts a write that demonstrably happened — and `page_statuses` accepts an empty page 1 as complete, which is what made it reachable. Both reviewers reproduced the clear-then-exempt outcome. The shipped positive test used exactly that impossible fixture, so it was pinning the defect; it now seeds the sentinel row, and an impossible-empty negative plus a witness mutation proof were added. 5. The mid-run "did this row change" comparison now includes the row ID. The two sentinels are byte-identical by design, so a mid-run replacement of one by another was invisible to a state/creator/description triple. Measured 2026-08-29 (Gitea 1.27.1, head 736649b3): the COMBINED endpoint carries `id` on every row, ids 14..30 ascending — the job had only ever read ids from `/statuses/{sha}`. Where a server omits it both sides are empty and the comparison degrades to the pre-existing text test. 6. The repair has a FLOOR — it may never write a description weaker than the one this run decided — and is skipped when it would rewrite what is already there. Widening the gate to every write meant a transient post-write read could rewrite a correct `$REPAIR_DESC` carry-forward with the machine-clearable sentinel, reversing the ordering rule the classification chain states. Writing the sentinel and failing the job are separate decisions, which is why `replace_unknown_state` and `replace_unknown_and_die` are two functions: the read refusals were already non-zero exits on `main` and stay red; the fence branch exited 0 there and still does, because an unreadable timeline is an ordinary hiccup and reddening every one is noise this file elsewhere refuses to add. Prose corrected where it now overclaimed: "the green never stands" after the post-POST re-check is wrong — it is live between the POST and the repair, so the check makes a permanent green TRANSIENT; "a later run reconciles this automatically" is wrong in the one case where the replacement costs anything, since finding a masked verdict UPGRADES to the human-only sentinel; and the mutation-proof framing claimed every mutant restores the exact predecessor, when two do, one restores the shape #742 withdrew, and the rest disarm clauses that have no predecessor. The quiet-timeline positive control now counts timeline walks, because a single POST is also what a skipped re-check produces. refs #849 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF --- .gitea/workflows/review-verdict.yml | 180 ++++++++++++--- docs/ci-cd.md | 36 ++- docs/decisions/README.md | 2 +- .../ci/verdict-unverified-write-sentinel.md | 19 +- .../ci/verdict-write-retarget-fence.md | 15 +- docs/remote-state-inventory.md | 2 +- scripts/tests/test_pr_changed_files.py | 214 ++++++++++++++---- 7 files changed, 380 insertions(+), 88 deletions(-) diff --git a/.gitea/workflows/review-verdict.yml b/.gitea/workflows/review-verdict.yml index 7988be09e..c45447a85 100644 --- a/.gitea/workflows/review-verdict.yml +++ b/.gitea/workflows/review-verdict.yml @@ -413,6 +413,44 @@ jobs: return 1 } + # EVERY "this job cannot establish what is on the head" EXIT GOES THROUGH HERE + # (ersatztv#849). Declining to write protects a real verdict and leaves a FORGED one — an + # off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving + # it, and an unreadable read is the one thing that stops it while this job goes red on a + # status branch protection does not read. + # + # Factored rather than repeated because the four page-2 refusals below used to be the + # exception, on the reasoning that the probe "fires when NO row for this context was on + # page 1, so there is no green of any provenance to leave standing". That is + # self-contradictory: the ONLY reason page 2 is read at all is that the row MAY be beyond + # page 1, which the probe's own message says. Whatever a future reader concludes about + # reachability, the two branches must not resolve the same uncertainty in opposite + # directions. + # + # ACCEPTED COST, stated rather than discovered later: a head carrying more CONTEXTS than + # the server-wide page cap (measured 50) stalls on every run, because the probe refuses + # every time. Measured 2026-08-29, this repo puts 8 contexts on a `main` head. That case + # already stalled before this change — with an ABSENT required check, which reads as "not + # reviewed yet" — so what changes is that the stall now says why. + replace_unknown_state() { # $1 = the ::error:: naming what could not be established + echo "::error::$1" + if repair_status_to "$UNVERIFIED_DESC"; then + echo "::error::Replaced ${CONTEXT} on ${SHA:0:7} with the unverified-write sentinel rather than leaving a state this job could not read standing. A later run will either clear it, or — if it finds a verdict underneath — ask you to re-post that verdict. To settle it now: scripts/post-review-verdict.sh ${PR} " + else + echo "::error::COULD NOT WRITE THE UNVERIFIED SENTINEL to ${SHA:0:7}. Whatever ${CONTEXT} this head carries is standing unread. Post a verdict immediately: scripts/post-review-verdict.sh ${PR} " + fi + } + # WRITING THE SENTINEL AND FAILING THE JOB ARE SEPARATE DECISIONS, and only the read paths + # take both. `read_existing_verdict`'s refusals already exited non-zero on `main`, so + # keeping them red changes nothing about how often this job is red. The FENCE's untrusted + # branch exited 0 there — an unreadable timeline is an ordinary API hiccup, and turning + # every one of them into a red run is the noise this file elsewhere refuses to add. It + # calls `replace_unknown_state` and returns cleanly. + replace_unknown_and_die() { + replace_unknown_state "$1" + exit 1 + } + # DEFINED HERE, BEFORE ANY USE. An earlier round defined these AFTER the classification # chain that calls them, so `count_matching` was `command not found` on every run, the # PROTECTED branch silently never fired, and three "protected path" tests still passed — @@ -572,17 +610,7 @@ jobs: # a stall, clearable in one command, and it is the direction this whole job resolves # uncertainty in everywhere else. # - # SCOPED TO AN UNREADABLE BODY, deliberately. The page-2 completeness probe below also - # refuses, and is deliberately NOT routed here: it fires precisely when NO row for this - # context was on page 1, i.e. when there is no green of any provenance for this job to - # be leaving standing. Its refusal withholds a conclusion; this branch replaces a state. - echo "::error::Could not read existing commit statuses for ${SHA:0:7} (.statuses was '${st_kind:-unparseable}', total_count '${st_total}') after a retry, so any ${CONTEXT} already on this head — including one posted by an account this gate does not accept verdicts from — can neither be read nor re-derived." - if repair_status_to "$UNVERIFIED_DESC"; then - echo "::error::Replaced ${CONTEXT} on ${SHA:0:7} with the unverified-write sentinel rather than leaving an unreadable state standing. A later run reconciles this automatically; to clear it now, post a verdict: scripts/post-review-verdict.sh ${PR} " - else - echo "::error::COULD NOT WRITE THE UNVERIFIED SENTINEL to ${SHA:0:7}. Whatever ${CONTEXT} this head carries is standing unread. Post a verdict immediately: scripts/post-review-verdict.sh ${PR} " - fi - exit 1 + replace_unknown_and_die "Could not read existing commit statuses for ${SHA:0:7} (.statuses was '${st_kind:-unparseable}', total_count '${st_total}') after a retry, so any ${CONTEXT} already on this head — including one posted by an account this gate does not accept verdicts from — can neither be read nor re-derived." fi # `// []` so the null case cannot hard-error here under `set -e` once it is accepted above. row=$(printf '%s' "$json" | jq -r --arg c "$CONTEXT" '[(.statuses // [])[] | select(.context == $c)] | first // {}') @@ -593,8 +621,7 @@ jobs: if [ "$(printf '%s' "$row" | jq -r '.context // ""')" = "" ]; then more=$(gh "$BASE_URL/repos/$REPO/commits/$SHA/status?limit=100&page=2") || more="" if [ -z "${more//[[:space:]]/}" ]; then - echo "::error::Could not read page 2 of the commit statuses for ${SHA:0:7}, so 'no verdict exists' cannot be established. Refusing to post anything." - exit 1 + replace_unknown_and_die "Could not read page 2 of the commit statuses for ${SHA:0:7}, so 'no verdict exists' cannot be established." fi more_kind=$(printf '%s' "$more" | jq -r '.statuses | type' 2>/dev/null) || more_kind="" more_len=$(printf '%s' "$more" | jq -r '(.statuses // []) | length' 2>/dev/null) || more_len="" @@ -603,18 +630,28 @@ jobs: array) case "$more_len" in ''|*[!0-9]*) - echo "::error::Page 2 of the commit statuses for ${SHA:0:7} had a non-numeric length; refusing to conclude that no verdict exists." - exit 1 ;; + replace_unknown_and_die "Page 2 of the commit statuses for ${SHA:0:7} had a non-numeric length, so it cannot be concluded that no verdict exists." ;; 0) ;; *) - echo "::error::${CONTEXT} was not on page 1 of the statuses for ${SHA:0:7}, but page 2 carries ${more_len} more row(s) — the list is longer than one page and an existing verdict may be beyond it. Refusing to post anything rather than overwrite a verdict this job cannot see. A human verdict clears this: scripts/post-review-verdict.sh ${PR} MERGEABLE." - exit 1 ;; + replace_unknown_and_die "${CONTEXT} was not on page 1 of the statuses for ${SHA:0:7}, but page 2 carries ${more_len} more row(s) — the list is longer than one page, so a verdict of ANY provenance may be sitting beyond it where this job cannot read it." ;; esac ;; *) - echo "::error::Page 2 of the commit statuses for ${SHA:0:7} was '${more_kind:-unparseable}'; refusing to conclude that no verdict exists." - exit 1 ;; + replace_unknown_and_die "Page 2 of the commit statuses for ${SHA:0:7} was '${more_kind:-unparseable}', so it cannot be concluded that no verdict exists." ;; esac fi + # THE ROW ID, so "did this row change" can be asked about IDENTITY rather than about + # TEXT (ersatztv#849). Two sentinel POSTs are byte-identical by design — that is what + # makes them fixed points — so a mid-run REPLACEMENT of one sentinel by another is + # invisible to a state/creator/description comparison, and the run then overwrites a row + # another run had just written. The same blind spot applies to any replacement whose + # triple happens to match. + # + # MEASURED 2026-08-29 on this instance (Gitea 1.27.1), because the rest of this job reads + # ids only from `/statuses/{sha}`: the COMBINED endpoint's rows carry `id` too — head + # 736649b3 returned 8 rows keyed id/context/status/creator/description/created_at/ + # updated_at/url, ids 14..30 ascending. `// ""` so a server that ever stopped sending it + # degrades to the pre-existing text comparison rather than to a false "changed". + ex_id=$(printf '%s' "$row" | jq -r 'if (.id | type) == "number" then (.id | tostring) else "" end') ex_state=$(printf '%s' "$row" | jq -r '.status // ""') ex_creator=$(printf '%s' "$row" | jq -r '.creator.login // ""') ex_desc=$(printf '%s' "$row" | jq -r '.description // ""') @@ -1064,6 +1101,7 @@ jobs: } ex_repair=no + ex_unverified=no count_pr_mutations retargets_before=$rt_count pushes_before=$hp_count @@ -1172,6 +1210,12 @@ jobs: pre_state=$ex_state pre_creator=$ex_creator pre_desc=$ex_desc + # THE ID IS PART OF THE SNAPSHOT (ersatztv#849, round 2). The triple cannot see a + # REPLACEMENT whose text matches, and the two sentinels are byte-identical by design, so + # "another run replaced this row while we classified" was invisible for exactly the rows + # where it matters most. Where the server does not send an id both sides are empty and the + # comparison falls back to the triple, which is the pre-existing behaviour. + pre_id=$ex_id if [ -n "$ex_state" ]; then # STATE-NEUTRAL WORDING. Control also reaches here for an ALLOW-LISTED creator whose row is # in some state other than `success`/`failure`, so this must not assert the row is @@ -1215,8 +1259,27 @@ jobs: if [ "$ex_unverified" = yes ]; then echo "${CONTEXT} on ${SHA:0:7} carries the unverified-write sentinel from an earlier run — reconciling it against the per-POST status history." page_statuses - if [ "$ph_ok" != yes ]; then - echo "::warning::Could not read a complete status history for ${SHA:0:7}, so the unverified write from an earlier run still cannot be reconciled. Carrying the sentinel forward; ${CONTEXT} stays pending and no exemption is granted." + # THE WITNESS: THE SENTINEL'S OWN ROW MUST BE IN THE HISTORY (ersatztv#849, round 2). + # `ex_unverified=yes` means the COMBINED endpoint just returned the sentinel for this + # sha, and `/statuses/{sha}` keeps one row per POST — so a complete history that does + # NOT contain it, empty ones included, is a response that cannot be true. The post-write + # check already refuses an empty history for exactly this reason; reconciliation stands + # in the same position and had no such guard, so a single anomalous `[]` cleared the + # sentinel and let the run exempt a head carrying a buried rejection. `page_statuses` + # deliberately accepts an empty page 1 as complete (correct for a first-run mark), which + # is what made the shape reachable. + # + # THIS IS NOT THE WITHDRAWN CURRENCY WITNESS. That asked "is there ANY row above the + # mark", which an unrelated newer row satisfied, and it converted one anomaly into a + # PERMANENT sentinel. This asks for a SPECIFIC row already known to exist, and failing it + # carries the sentinel forward for THIS run only — the next run retries. + witness=$(printf '%s' "$ph_rows" | jq -r --arg c "$CONTEXT" --arg ud "$UNVERIFIED_DESC" \ + '[.[] | select(type == "object") + | select(.context? == $c) + | select(((.description | type) == "string") and (.description == $ud))] | length') || witness="" + case "$witness" in ''|*[!0-9]*) witness=0 ;; esac + if [ "$ph_ok" != yes ] || [ "$witness" -eq 0 ]; then + echo "::warning::Could not read a status history for ${SHA:0:7} that is both complete and contains the sentinel this head carries (complete=${ph_ok}, sentinel rows found=${witness}), so the unverified write from an earlier run still cannot be reconciled. Carrying the sentinel forward; ${CONTEXT} stays pending and no exemption is granted." else # `.description` IS TYPE-TESTED BEFORE `startswith`, exactly as the post-write filter # does it and for the same reason: a row whose description is a number makes @@ -1512,13 +1575,30 @@ jobs: # publish a green and take it back: a repair leaves a window in which branch protection can # see the exemption, and an already-scheduled auto-merge can fire inside it. # - # ONLY `success` IS DOWNGRADED. A `pending` write cannot be verified either, and that is - # handled where it belongs — by the post-write check now covering every write (route 2). - # Rewriting a generic `pending` into the sentinel HERE would make every ordinary PR sticky - # whenever this endpoint blinks, which withholds nothing (an unreviewed PR is blocked - # already) and costs the next docs-only run its exemption for no gain. - if [ "$state" = "success" ] && [ "$max_id_before" -lt 0 ]; then - echo "::error::No status high-water mark could be established for ${SHA:0:7}, so an exemption posted now could not be checked for a human verdict landing in the write window. Withholding it and writing the unverified-write sentinel instead. A later run reconciles this automatically once the history can be read; to clear it now, post a verdict: scripts/post-review-verdict.sh ${PR} " + # EVERY RE-DERIVABLE WRITE IS DOWNGRADED, not only `success` (corrected in round 2). An + # earlier version restricted this to the exemption, reasoning that a sticky generic + # `pending` "withholds nothing, since an unreviewed PR is blocked already". That analysed + # the wrong PR. The damaging case is a PR that IS exemptible and only got the generic + # `pending` from a transient enumeration failure: the generic description carries no + # marker, the post-write check below does not run without a mark, so a human verdict + # landing in the write window is buried and the NEXT run re-derives that `pending` into + # the exemption with the human row below its own mark. That is route 2's damage reached + # through route 1's condition. + # + # `$REPAIR_DESC` IS EXEMPT because it is the stronger fact and is not re-derivable — the + # classification refuses to exempt over it. Downgrading it here would lose a verdict this + # head is known to have lost. + # + # POSITIONED AFTER THE MARK, NOT WITH THE CLASSIFICATION. `$max_id_before` does not exist + # up there and referencing it early is an unbound variable under `set -u` — a job that + # dies before posting anything, on every PR. + # + # NOT POST-THEN-REPAIR, which is what happens when the mark DOES exist and the check then + # fires. Here the defect is known BEFORE the write, so there is no reason to publish a + # green and take it back: a repair leaves a window in which branch protection can see the + # exemption, and an already-scheduled auto-merge can fire inside it. + if [ "$max_id_before" -lt 0 ] && [ "$desc" != "$REPAIR_DESC" ]; then + echo "::error::No status high-water mark could be established for ${SHA:0:7}, so nothing can check this run's '${state}' write for a human verdict landing in the write window. Writing the unverified-write sentinel instead of a status a later run would re-derive. A later run will either clear it, or — if it finds a verdict underneath — ask you to re-post that verdict. To settle it now: scripts/post-review-verdict.sh ${PR} " state=pending desc="$UNVERIFIED_DESC" fi @@ -1537,7 +1617,8 @@ jobs: # whenever the high-water mark could not be established. read_existing_verdict if [ "$ex_attributable" = yes ] \ - && { [ "$ex_state" != "$pre_state" ] || [ "$ex_creator" != "$pre_creator" ] || [ "$ex_desc" != "$pre_desc" ]; }; then + && { [ "$ex_state" != "$pre_state" ] || [ "$ex_creator" != "$pre_creator" ] \ + || [ "$ex_desc" != "$pre_desc" ] || [ "$ex_id" != "$pre_id" ]; }; then echo "::notice::An attributable verdict ('${ex_state}' by '${ex_creator}') landed on ${SHA:0:7} while this job was classifying — leaving it alone and posting nothing." exit 0 fi @@ -1585,7 +1666,8 @@ jobs: # # A run whose own write IS a sentinel is exempt from the guard: replacing a sentinel with a # sentinel loses nothing, and the repair sentinel outranks this one. - if [ "$ex_unverified" = yes ] && [ "$ex_desc" != "$pre_desc" ] \ + if [ "$ex_unverified" = yes ] \ + && { [ "$ex_desc" != "$pre_desc" ] || [ "$ex_id" != "$pre_id" ]; } \ && [ "$desc" != "$REPAIR_DESC" ] && [ "$desc" != "$UNVERIFIED_DESC" ]; then echo "::notice::An unverified-write sentinel was written on ${SHA:0:7} while this job was classifying, so another run posted something it could not check. This run would overwrite that record with an unmarked status a later run could re-derive into an exemption — posting NOTHING and leaving the sentinel standing." exit 0 @@ -1635,7 +1717,19 @@ jobs: exit 0 fi if { [ "$retargets_before_ok" != yes ] || [ "$rt_ok" != yes ]; } && [ "$state" = "success" ]; then - echo "::error::Could not establish a trusted retarget/push count for PR #${PR} (before=${retargets_before_ok}, after=${rt_ok}), so an exemption 'success' cannot be shown to have been computed against the PR's current base, nor at a single head. Posting nothing; ${CONTEXT} stays absent, which blocks the merge. NOTE a later run only helps if the cause was transient — a PR whose timeline exceeds the page cap will fail this way on every run, and needs a human verdict." + # ABSTAINING HERE WAS A FAIL-OPEN WHEN THE HEAD ALREADY CARRIED A ROW (ersatztv#849, + # round 2). This branch is reached only after the classification DECLINED to inherit + # whatever `review-verdict/h10` the head carries — that is why it is re-deriving — so + # posting nothing leaves the declined row current. The old message said the context + # "stays absent", which is true only for a head that had none; on a head carrying a + # machine or off-list `success` it is the opposite of what happens, and no retarget or + # push need have occurred, so no successor run is guaranteed either. + # + # The sentinel is the right write rather than the exemption: it states what was actually + # established (this run could not verify its own classification), blocks the merge, and + # is cleared by the next run that can read the timeline — so a transient outage costs one + # event rather than a human verdict. + replace_unknown_state "Could not establish a trusted retarget/push count for PR #${PR} (before=${retargets_before_ok}, after=${rt_ok}), so an exemption 'success' cannot be shown to have been computed against the PR's current base, nor at a single head. NOTE a later run only helps if the cause was transient — a PR whose timeline exceeds the page cap will fail this way on every run, and needs a human verdict." exit 0 fi @@ -1923,10 +2017,25 @@ jobs: raced=1 ;; esac fi + # NEVER WRITE A WEAKER DESCRIPTION THAN THIS RUN ALREADY DECIDED (ersatztv#849, round + # 2). `repair_desc` is set to the reconcilable sentinel by every "could not check" arm, + # and since this block started running for EVERY write it also runs after a + # carry-forward write of `$REPAIR_DESC`. One transient post-write read then rewrote that + # head with the strictly weaker, machine-clearable sentinel — the exact reverse of the + # ordering rule the classification chain states, and the input to a two-step green if + # anything later cleared it wrongly. + if [ "$desc" = "$REPAIR_DESC" ]; then repair_desc="$REPAIR_DESC"; fi # NORMALISE FIRST, THEN ACT ONCE. Setting `raced=1` inside a `case` arm and expecting the # repair to happen in a SIBLING arm does not work — the case has already dispatched. That # is why the unusable-count branch above sets the flag and the decision lives out here, # where every path that concluded "raced" reaches the same single writer. + # A REPAIR THAT WOULD WRITE WHAT IS ALREADY THERE IS NOT A REPAIR. Reachable once the + # floor above pins `repair_desc` to this run's own description: without this the job + # POSTs a duplicate row and logs "Repaired …" for a change that did not happen. + if [ "$raced" -gt 0 ] && [ "$repair_desc" = "$desc" ]; then + echo "::notice::Post-write verification for ${SHA:0:7} could not clear the write window (${raced_why}), but ${CONTEXT} already carries the description the repair would write, so nothing is re-posted." + raced=0 + fi if [ "$raced" -gt 0 ]; then # The last-moment re-read found no human verdict, so any row present now was # written during the window and has just been masked by this job's write. @@ -1973,7 +2082,14 @@ jobs: # RE-COUNTING AFTER THE POST closes the induction. A retarget landing after this check # necessarily queues a successor that STARTS after the stale `success` already exists — and # a machine-written `success` is re-derived, not inherited, so that successor changes the - # answer. A retarget landing before it is caught here and the green never stands. + # answer. A retarget landing before it is caught here. + # + # WHAT THAT BUYS IS TRANSIENT INSTEAD OF PERMANENT, not "the green never stands" — which is + # what this comment claimed until round-2 review. The `success` is live between its POST + # and the repair below, including the timeline round trips in between, so branch protection + # or an already-scheduled auto-merge can observe it. Closing THAT window needs a + # compare-and-set or serialization the API does not offer; what is removed is the case + # where no event remained to correct it. # # ONLY WHEN AN EXEMPTION `success` IS WHAT STANDS. A `pending` cannot be a forged green, and # `$state` is re-read after the repair above precisely so a write already downgraded is not diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 8879d72c9..df5233d8a 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -1711,7 +1711,10 @@ this is why** — the job log names the counts. `ci.verdict-unverified-write-sentinel`). The pre-write fence covers *writing while overtaken*; it never covered *being overtaken after writing*, which was the worse of the two — a retarget landing after the final pre-write count left a stale `success` with the `edited` event already consumed by a successor -that short-circuited, so nothing remained to reclassify. The retarget axis only: a push after the POST +that short-circuited, so nothing remained to reclassify. It makes that green **transient rather than +permanent**, not absent: the `success` is live between its POST and the repair, so branch protection +or a scheduled auto-merge can still observe it. Closing that window needs a compare-and-set the API +does not offer. The retarget axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. @@ -1764,15 +1767,30 @@ interchangeable (`ci.verdict-unverified-write-sentinel`): it — upgrading to the repair sentinel — or finds none and clears it, so a transient API failure does not cost a head its exemption permanently. -**No high-water mark means no exemption**, withheld before the POST rather than posted and repaired: -the defect is known in advance, and publishing a green to take it back opens a window branch -protection — and an already-scheduled auto-merge — can see. +**No high-water mark means the write becomes the sentinel** — every re-derivable state, not only the +exemption — withheld before the POST rather than posted and repaired: the defect is known in advance, +and publishing a green to take it back opens a window branch protection, and an already-scheduled +auto-merge, can see. A generic `pending` is included because it is exactly what a later run +re-derives; `$REPAIR_DESC` is not, being the stronger fact. -**An unreadable combined-status read replaces the unknown state** rather than merely declining to -write. Declining protects a real verdict and leaves a *forged* one standing, which is what an off-list -`success` is; the job went red on a status branch protection does not read. The read is retried once -first, and nothing is destroyed — `/statuses/{sha}` keeps one row per POST, so the next run's -reconciliation finds a masked verdict and tells the reviewer to re-post. +**Every path that cannot establish what the head carries REPLACES the unknown state** rather than +merely declining to write: the combined read (retried once first), all four page-2 completeness +refusals, and the fence branch that cannot trust its retarget count while holding a derived +`success`. Declining protects a real verdict and leaves a *forged* one standing, which is what an +off-list `success` is; the job went red on a status branch protection does not read, and the fence +branch was reached only after the classification had DECLINED to inherit the very row it then left +current. Nothing is destroyed — `/statuses/{sha}` keeps one row per POST, so the next run's +reconciliation finds a masked verdict and tells the reviewer to re-post. Writing the sentinel and +failing the job are separate decisions: the read refusals were already non-zero exits and stay red, +the fence branch exited 0 and still does. + +**Reconciliation needs a witness.** It may clear the sentinel only over a complete history that +CONTAINS the sentinel's own row. `ex_unverified` means the combined endpoint just returned that row +and `/statuses/{sha}` keeps one row per POST, so a complete-but-empty history contradicts a write +that demonstrably happened — and `page_statuses` accepts an empty page 1 as complete, which is what +made the shape reachable. The cost lands where the replacement actually cost something: on an +APPROVED PR, reconciliation finds the masked verdict and upgrades to the repair sentinel, so the +recovery is "you are asked to re-post", not "it clears itself". **Both `/statuses/{sha}` reads are PAGED** (ersatztv#763). The history is read twice — before the write for the high-water mark, after it for the race check — and `limit` clamps to `MAX_RESPONSE_ITEMS` diff --git a/docs/decisions/README.md b/docs/decisions/README.md index bfbecaf08..1c6b7490c 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -68,7 +68,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `ci.small-lane-git-only` | `runs-on: small` is defined by what a job does (git-only), not its usual runtime; the two `docker build` jobs (docker-build.yml, ci-image.yml) move to `ubuntu-latest` because their worst-case memory, not median runtime, was pinning the small lane's per-slot cap. | 2026-07-20 | [link](records/ci/small-lane-git-only.md) | | `ci.toolchain-image-publish-is-a-dispatch` | A `push` trigger reachable from any ref other than `main` — that is `branches:` AND `tags:`, judged by the ref class it admits and never by which keyword is present — executes ref-supplied YAML, because Gitea resolves a `push` workflow's definition from the pushed ref — so `ci-image.yml` is `branches: [main]`, and publishing a toolchain image from a feature branch is a deliberate `workflow_dispatch` on that branch rather than a side effect of pushing. Be precise about what a `branches:` filter buys: it is loaded from the pushed ref like the rest of the file, so a branch that deletes it re-enables the route — this removes the DRIVE-BY case, and is not a boundary against a writer who intends to run their own YAML. The self-referencing trigger path `.gitea/workflows/ci-image.yml` came out of BOTH its own `paths:` and `ci-image-pin`'s `expected` in the same change — a DECIDED tradeoff, not a necessity: keeping it is workable via the branch dispatch, but prices every edit to that file, comments included, at a ~2GB publish plus a five-pin bump, redone after every rebase. The cost is stated, not assumed away — a change to HOW the image is built that lives only in `ci-image.yml` no longer republishes, and the ONLY remedy is to make it alongside a `docker/ci/**` edit: publishing after the merge and then pinning cannot work, because `expected` is the last `docker/ci` commit and would reject that pin. This closes the push route INTO THIS FILE, not the class: `docker-build.yml` remains reachable from an arbitrary ref by a `v*` tag push and by `pull_request`, and four workflows carry an unrestricted `workflow_dispatch`. The DISPATCH THIRD of that is settled — #853 probed it and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`): no ref restriction exists at Gitea 1.27.1, and restricting it would close nothing anyway, because the head-resolved `pull_request:` route runs attacker-authored YAML that reaches every secret in the store. The `v*` tag push and `pull_request:` rows are NOT settled and remain open in #885. Do not re-derive any of this. | 2026-08-27 | [link](records/ci/toolchain-image-publish-is-a-dispatch.md) | | `ci.ui-e2e-harness` | The UI-interactive E2E flows run as headless Playwright specs (`web/e2e/*.spec.ts`, driven by `scripts/e2e-ui.sh`) in a **second step of the existing advisory `functional-e2e` job**, never their own job; the browser is `chromium-headless-shell` **baked into the CI toolchain image** (`docker/ci/Dockerfile`, `PLAYWRIGHT_VERSION` kept equal to `web/package.json`'s EXACT `@playwright/test` pin), never installed per run; specs are `serial` with `retries: 0` and assert only contracts the curl harness structurally cannot reach. | 2026-07-25 | [link](records/ci/ui-e2e-harness.md) | -| `ci.verdict-unverified-write-sentinel` | The `review-verdict/h10` job runs its post-write race check after EVERY status write, not only an exemption `success`: a GENERIC `pending` masks a rejection landing in its own write window exactly as a `success` does, and because it carries no marker a later run re-derives it into an exemption with the human row now below THAT run's high-water mark — the damage arrives one event later, not never. Where a write cannot be checked at all the job writes a SECOND sentinel, `UNVERIFIED_DESC`, distinct from the repair sentinel and never interchangeable with it: the repair sentinel ASSERTS that a verdict existed and was buried, which only the arm that actually COUNTED such a row may claim, while every "could not check" arm — an unreadable or over-cap history, an impossible empty history, an unusable count — states only what it established. Both are STICKY (the classification refuses to grant an exemption over either, and re-writes its own description verbatim, so each is a FIXED POINT a later run cannot re-derive into `success`); only the unverified one is RECONCILABLE. Reconciliation is what bounds the stall that got the #742 attempt withdrawn: a later run pages `/statuses/{sha}` IN FULL and either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it UPGRADES to the repair sentinel, clearable only by a human — or finds none, resolving the uncertainty and clearing it so the PR is classified normally. The reconciliation is sound because the two endpoints differ: a verdict this job masked is invisible on the COMBINED endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. It is deliberately BROADER than the inheritance test — no `H10_REVIEWERS` membership, no `(base: …)` match — because over-counting upgrades to a stall a human clears in one command while MISSING one re-exempts a head carrying a buried rejection. ONLY a COMPLETE walk may clear it; `ph_ok != yes` carries it forward. When NO high-water mark can be established the exemption is withheld BEFORE the POST rather than posted and repaired, since the defect is known in advance and publishing a green to take it back opens a window branch protection — and an already-scheduled auto-merge — can see. Only `success` is downgraded there: rewriting a generic `pending` into the sentinel would make every ordinary PR sticky whenever the endpoint blinks, withholding nothing (an unreviewed PR is blocked already) and costing the next docs-only run its exemption. SEPARATELY, the retarget count is re-taken AFTER the POST on the exemption path, which closes the PERMANENT forged green `ci.verdict-write-retarget-fence` recorded as its residual: a retarget landing after the final pre-write count leaves a stale `success` with the `edited` event already consumed by a successor that short-circuited, so nothing remains to reclassify. Re-counting afterwards means a retarget later than the re-check necessarily queues a successor that STARTS after the stale `success` exists, and a machine-written `success` is re-derived rather than inherited. The RETARGET axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. An untrusted post-POST count repairs too — reaching that point with `success` means both earlier counts were trusted, so a third unreadable read is a fresh failure and "I cannot tell whether the base moved" must not resolve to leaving a green. FINALLY, an UNREADABLE combined-status read no longer merely declines to write: it retries once and then REPLACES the unknown state with the unverified sentinel. Declining protects a real verdict and leaves a FORGED one — an off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving it, and an unreadable read is the one thing that stops it while the job goes red on a status branch protection does not read. Nothing is destroyed: the per-POST history keeps the masked verdict and the next run's reconciliation upgrades to the repair sentinel, telling the reviewer to re-post rather than silently un-approving them. That branch is scoped to an UNREADABLE BODY only — the page-2 completeness probe still merely refuses, because it fires when NO row for this context was on page 1, i.e. when there is no green of any provenance to leave standing. | 2026-08-29 | [link](records/ci/verdict-unverified-write-sentinel.md) | +| `ci.verdict-unverified-write-sentinel` | The `review-verdict/h10` job runs its post-write race check after EVERY status write, not only an exemption `success`, and where no mark exists to run it against, the write ITSELF becomes the sentinel — so every status this job writes is either verified against the history or marked as unverifiable: a GENERIC `pending` masks a rejection landing in its own write window exactly as a `success` does, and because it carries no marker a later run re-derives it into an exemption with the human row now below THAT run's high-water mark — the damage arrives one event later, not never. Where a write cannot be checked at all the job writes a SECOND sentinel, `UNVERIFIED_DESC`, distinct from the repair sentinel and never interchangeable with it: the repair sentinel ASSERTS that a verdict existed and was buried, which only the arm that actually COUNTED such a row may claim, while every "could not check" arm — an unreadable or over-cap history, an impossible empty history, an unusable count — states only what it established. Both are STICKY (the classification refuses to grant an exemption over either, and re-writes its own description verbatim, so each is a FIXED POINT a later run cannot re-derive into `success`); only the unverified one is RECONCILABLE. Reconciliation is what bounds the stall that got the #742 attempt withdrawn: a later run pages `/statuses/{sha}` IN FULL and, ONLY IF that history contains the sentinel's own row, either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it UPGRADES to the repair sentinel, clearable only by a human — or finds none, resolving the uncertainty and clearing it so the PR is classified normally. The reconciliation is sound because the two endpoints differ: a verdict this job masked is invisible on the COMBINED endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. It is deliberately BROADER than the inheritance test — no `H10_REVIEWERS` membership, no `(base: …)` match — because over-counting upgrades to a stall a human clears in one command while MISSING one re-exempts a head carrying a buried rejection. ONLY a COMPLETE walk CONTAINING THE SENTINEL ROW may clear it. The witness is not optional decoration: `ex_unverified` means the combined endpoint just returned that row, and `/statuses/{sha}` keeps one row per POST, so a complete history WITHOUT it — an empty one included — contradicts a write that demonstrably happened. `page_statuses` accepts an empty page 1 as complete (correct for a first-run mark), which is what made that shape reachable, and clearing on it exempted a head whose sentinel may have been sitting on a rejection. This is NOT the withdrawn currency witness: that asked whether ANY row sat above the mark, which an unrelated row satisfied, and it made one anomaly permanent — this asks for a SPECIFIC row already known to exist, and failing it costs one run rather than requiring a human. When NO high-water mark can be established the write is withheld BEFORE the POST rather than posted and repaired, since the defect is known in advance and publishing a green to take it back opens a window branch protection — and an already-scheduled auto-merge — can see. EVERY re-derivable state is downgraded there, not only `success`: an earlier draft restricted it to the exemption on the grounds that a sticky generic `pending` "withholds nothing, since an unreviewed PR is blocked already", which analysed the wrong PR — the damaging case is one that IS exemptible and got the generic `pending` only from a transient enumeration failure, whose unmarked description the next run re-derives into the exemption with the human row below its own mark. `$REPAIR_DESC` is the one exemption, being the stronger fact and not re-derivable. SEPARATELY, the retarget count is re-taken AFTER the POST on the exemption path, which closes the PERMANENT forged green `ci.verdict-write-retarget-fence` recorded as its residual: a retarget landing after the final pre-write count leaves a stale `success` with the `edited` event already consumed by a successor that short-circuited, so nothing remains to reclassify. Re-counting afterwards means a retarget later than the re-check necessarily queues a successor that STARTS after the stale `success` exists, and a machine-written `success` is re-derived rather than inherited. The RETARGET axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. An untrusted post-POST count repairs too — reaching that point with `success` means both earlier counts were trusted, so a third unreadable read is a fresh failure and "I cannot tell whether the base moved" must not resolve to leaving a green. FINALLY, every path that cannot establish what the head carries REPLACES the unknown state instead of merely declining to write — the combined read (retried once first), all four page-2 completeness refusals, and the fence branch that cannot trust its retarget count while holding a derived `success`. That last one is reached only AFTER the classification declined to inherit the row the head carries, so posting nothing left the declined row current; its message used to say the context "stays absent", which is true only of a head that had none. Declining protects a real verdict and leaves a FORGED one — an off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving it, and an unreadable read is the one thing that stops it while the job goes red on a status branch protection does not read. Nothing is destroyed: the per-POST history keeps the masked verdict and the next run's reconciliation upgrades to the repair sentinel, telling the reviewer to re-post rather than silently un-approving them. The page-2 refusals were briefly excluded on the reasoning that the probe fires when NO row for this context was on page 1, so there is no green of any provenance to leave standing — self-contradictory, since the ONLY reason page 2 is read is that the row MAY be beyond page 1. WRITING THE SENTINEL AND FAILING THE JOB ARE SEPARATE DECISIONS: the read refusals were already non-zero exits on `main` and stay red, while the fence branch exited 0 there and still does, because an unreadable timeline is an ordinary hiccup and reddening every one of them is noise this file elsewhere refuses to add. The repair also has a FLOOR — it may never write a description weaker than the one this run decided, or a transient post-write read rewrites a correct `$REPAIR_DESC` carry-forward with the machine-clearable sentinel — and it is skipped entirely when it would rewrite what is already there. | 2026-08-29 | [link](records/ci/verdict-unverified-write-sentinel.md) | | `ci.verdict-write-retarget-fence` | The `review-verdict/h10` job counts BOTH `change_target_branch` AND `pull_push` events on the PR's issue timeline at run start and again immediately before its POST, and writes NOTHING if EITHER count moved. The COUNT is the key on both axes because the underlying VALUE is ABA-vulnerable — `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a forged exemption, and a force-push `H1 -> H2 -> H1` leaves `.head.sha` equal at both ends while the middle pages of `scripts/pr-changed-files.sh`'s enumeration came from `H2` (#803/#664) — while an event count is monotonic and cannot alias. ONE walk certifies BOTH counts, so an unreadable page abandons both; the two tallies are separate so the diagnostic names the axis that actually moved. Every push is counted and `is_force_push` is deliberately NOT read: an ordinary push also invalidates a mid-flight enumeration, and an `H1 -> H2 -> H1` restoration can have its second push non-forced when `H1` is an ancestor. The head fence does NOT abstain on its own triggering push — established FROM THE v1.27.1 SOURCE, since that would be a permanent stall rather than a fence: the push comment is created BEFORE the synchronize notification is emitted, so a run always sees its own causative event, and retries add no new event. The 26-102s margin measured across 20 triggered pairs on PRs #802/#834/#761 corroborates it and is a lower bound (the job runs a checkout first; 69s end to end on run 2385) — it is NOT the basis of the claim, which an earlier draft said it was. This NARROWS the residual rather than resolving it, and what CLOSED the permanent case is a separate mechanism recorded at `ci.verdict-unverified-write-sentinel`: the retarget count is re-taken AFTER the POST, so a retarget between the final pre-write count and the POST — which used to leave a PERMANENT forged green, the successor having consumed the `edited` event and exited before the stale run posted last — is now caught by the writing run itself, and one landing after the re-check necessarily queues a successor that starts with the stale `success` already visible and re-derivable (corrected 2026-08-27, closed 2026-08-29, #849). Abstaining is a handoff, not a stall, and that is the property the design rests on — for a run that ABSTAINS; it says nothing about one that already passed its final count and then posts (see the residual, corrected #849): 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 — but ONLY over runs that ABSTAIN. A run that already passed its final count is outside it: it writes whenever it gets there, so the run that writes LAST is not necessarily the one that classified last. That was the permanent residual until #849 gave the writing run a post-POST re-count of its own (`ci.verdict-unverified-write-sentinel`), which puts such a run back inside the induction — it either withdraws its own stale write or leaves a green a guaranteed successor re-derives. `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` blocks the merge immediately while withholding it would strand ordinary PRs whenever the timeline is unreadable — the right trade, but NOT a free one ("for no safety gain" retracted 2026-08-27): a GENERIC `pending` masks a rejection landing in its own write window just as a `success` does. What made that DURABLE — post-write verification skipping it, so a later run re-derived it into an exemption `success` — is closed since 2026-08-29: the check now runs after EVERY write (`ci.verdict-unverified-write-sentinel`), so such a write is repaired to the sticky repair sentinel and cannot be re-derived. The masking itself is still a cost, and a write the mark cannot cover is still unverified; that is what the second sentinel is for. SEPARATELY, and for the human-verdict race the fence does nothing about: after posting ANY status — every write since #849, not only an exemption `success` — the job re-reads `/statuses/{sha}` IN FULL (PAGED since #763) 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. BOTH `/statuses/{sha}` reads PAGE to a validated EMPTY page since #763 — `[]` on this endpoint, a THIRD terminator shape distinct from the timeline's bare `null` and the combined endpoint's `{"statuses": null}`, so each terminator is MEASURED per endpoint — never terminating on a SHORT page, retrying each page once, under a 20-page cap that validates at most 950 rows (the 20th request must be the empty terminator); correctness does not depend on the measured cap of 50, because any page size pages correctly. WHAT THE PAGING BUYS IS NOT WHAT #763 CLAIMED: under the DESC default page 1 already held the true maximum id AND every row newer than the mark, so a single-page read missed a raced verdict only if more than 50 rows were created INSIDE the write window — not merely on a head over 50 rows. What removed #761's stall is retiring the probe, not the walk; the walk's value is that the gate's one fail-toward-SUCCESS path no longer depends on an UNDOCUMENTED ordering the server honours only coarsely (page 1 came back `114,112,113,111,110`). This RETIRED #751's conservative page-2 probe, which treated "there are rows I did not read" as "assume raced" and so repaired every head that outgrew one page: it fired on Renovate PR #761, reporting a human verdict as overwritten when none existed and then, the sentinel being sticky, refusing to re-exempt that head on every later run. The two directions are NOT symmetric. POST-WRITE, uncertainty fails CLOSED — an unreadable history, an over-cap history, or a count that is not a number all repair to `pending`; previously an unreadable history warned and left the exemption green while the page-2 probe repaired on the same uncertainty, one check disagreeing with itself. PRE-WRITE, a PARTIAL list still yields a mark, because the mark gates the post-write check entirely and refusing one SKIPS that check, which is itself the fail-open; this rests on the DESC DEFAULT — the newest row, carrying the maximum id, is on page 1, so a walk that fails later still saw it — while a VALIDATED empty history is NOT abandoned (it yields a mark of 0, correct for a first run, since every later row is newer) — what abandons the mark is a read that both FAILED and returned nothing — which since 2026-08-29 WITHHOLDS the exemption before the POST and marks the head with the reconcilable sentinel, rather than posting a green nothing can check (#849) — and a NON-EMPTY history carrying no numeric id is reported unusable rather than collapsed to 0. BOTH id comparisons are NUMERIC-ONLY: jq orders strings above every number, so one `"id": "99999"` inflates the mark until nothing looks newer, and `.id > $since` reads any string id as newer than any mark — making a PRE-EXISTING base-mismatched verdict look raced on every run, a permanent per-sha stall (the twin was live on `main`). An EMPTY post-write history is REJECTED: the walk terminates on an empty page, which is correct before the write and impossible after it (one row per POST), and a well-formed "no statuses exist" is not retried — so accepting it would conclude `raced=0` from a list that cannot be real, silently. That is NOT the withdrawn currency witness, which asked whether ANY row sat above the mark and was satisfied by an unrelated newer row; this asks only whether the list is EMPTY, a state no unrelated row can produce. The `::error::` now names its own cause, of which there are THREE — a verdict actually FOUND, a read that could not be COMPLETED, and a read that completed but returned an IMPOSSIBLE answer (the third is not a variety of the second) — while WHICH sentinel description is written is itself part of the answer since #849: only an arm that actually COUNTED a verdict row may write the repair sentinel, and every "could not check" arm writes the reconcilable one (`ci.verdict-unverified-write-sentinel`). Both are fixed points the classification recognises. `.creator` is TYPE-TESTED before it is indexed: `.creator != null and .creator.login` hard-errors on a non-object creator, jq exits 5, and under `set -e` that took the step down AFTER the exemption was posted and BEFORE the repair — a forged green reported as an infrastructure error. | 2026-08-03 | [link](records/ci/verdict-write-retarget-fence.md) | | `ci.verify-locally-ci-confirms` | Treat the local build/verify/review pass as the decision point and CI as confirmation — don't idle waiting on a run you have no reason to doubt. | 2026-07-21 | [link](records/ci/verify-locally-ci-confirms.md) | | `ci.web-test-per-test-timeouts` | Give heavy-render web tests an explicit per-test vitest timeout (e.g. 15s); never raise the global default to fix one slow test. | 2026-07-21 | [link](records/ci/web-test-per-test-timeouts.md) | diff --git a/docs/decisions/records/ci/verdict-unverified-write-sentinel.md b/docs/decisions/records/ci/verdict-unverified-write-sentinel.md index f7dbc1964..15d2387cd 100644 --- a/docs/decisions/records/ci/verdict-unverified-write-sentinel.md +++ b/docs/decisions/records/ci/verdict-unverified-write-sentinel.md @@ -5,9 +5,9 @@ status: active since: '2026-08-29' supersedes: none superseded-by: none -rule: 'The `review-verdict/h10` job runs its post-write race check after EVERY status write, not only an exemption `success`: a GENERIC `pending` masks a rejection landing in its own write window exactly as a `success` does, and because it carries no marker a later run re-derives it into an exemption with the human row now below THAT run''s high-water mark — the damage arrives one event later, not never. Where a write cannot be checked at all the job writes a SECOND sentinel, `UNVERIFIED_DESC`, distinct from the repair sentinel and never interchangeable with it: the repair sentinel ASSERTS that a verdict existed and was buried, which only the arm that actually COUNTED such a row may claim, while every "could not check" arm — an unreadable or over-cap history, an impossible empty history, an unusable count — states only what it established. Both are STICKY (the classification refuses to grant an exemption over either, and re-writes its own description verbatim, so each is a FIXED POINT a later run cannot re-derive into `success`); only the unverified one is RECONCILABLE. Reconciliation is what bounds the stall that got the #742 attempt withdrawn: a later run pages `/statuses/{sha}` IN FULL and either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it UPGRADES to the repair sentinel, clearable only by a human — or finds none, resolving the uncertainty and clearing it so the PR is classified normally. The reconciliation is sound because the two endpoints differ: a verdict this job masked is invisible on the COMBINED endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. It is deliberately BROADER than the inheritance test — no `H10_REVIEWERS` membership, no `(base: …)` match — because over-counting upgrades to a stall a human clears in one command while MISSING one re-exempts a head carrying a buried rejection. ONLY a COMPLETE walk may clear it; `ph_ok != yes` carries it forward. When NO high-water mark can be established the exemption is withheld BEFORE the POST rather than posted and repaired, since the defect is known in advance and publishing a green to take it back opens a window branch protection — and an already-scheduled auto-merge — can see. Only `success` is downgraded there: rewriting a generic `pending` into the sentinel would make every ordinary PR sticky whenever the endpoint blinks, withholding nothing (an unreviewed PR is blocked already) and costing the next docs-only run its exemption. SEPARATELY, the retarget count is re-taken AFTER the POST on the exemption path, which closes the PERMANENT forged green `ci.verdict-write-retarget-fence` recorded as its residual: a retarget landing after the final pre-write count leaves a stale `success` with the `edited` event already consumed by a successor that short-circuited, so nothing remains to reclassify. Re-counting afterwards means a retarget later than the re-check necessarily queues a successor that STARTS after the stale `success` exists, and a machine-written `success` is re-derived rather than inherited. The RETARGET axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. An untrusted post-POST count repairs too — reaching that point with `success` means both earlier counts were trusted, so a third unreadable read is a fresh failure and "I cannot tell whether the base moved" must not resolve to leaving a green. FINALLY, an UNREADABLE combined-status read no longer merely declines to write: it retries once and then REPLACES the unknown state with the unverified sentinel. Declining protects a real verdict and leaves a FORGED one — an off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving it, and an unreadable read is the one thing that stops it while the job goes red on a status branch protection does not read. Nothing is destroyed: the per-POST history keeps the masked verdict and the next run''s reconciliation upgrades to the repair sentinel, telling the reviewer to re-post rather than silently un-approving them. That branch is scoped to an UNREADABLE BODY only — the page-2 completeness probe still merely refuses, because it fires when NO row for this context was on page 1, i.e. when there is no green of any provenance to leave standing.' +rule: 'The `review-verdict/h10` job runs its post-write race check after EVERY status write, not only an exemption `success`, and where no mark exists to run it against, the write ITSELF becomes the sentinel — so every status this job writes is either verified against the history or marked as unverifiable: a GENERIC `pending` masks a rejection landing in its own write window exactly as a `success` does, and because it carries no marker a later run re-derives it into an exemption with the human row now below THAT run''s high-water mark — the damage arrives one event later, not never. Where a write cannot be checked at all the job writes a SECOND sentinel, `UNVERIFIED_DESC`, distinct from the repair sentinel and never interchangeable with it: the repair sentinel ASSERTS that a verdict existed and was buried, which only the arm that actually COUNTED such a row may claim, while every "could not check" arm — an unreadable or over-cap history, an impossible empty history, an unusable count — states only what it established. Both are STICKY (the classification refuses to grant an exemption over either, and re-writes its own description verbatim, so each is a FIXED POINT a later run cannot re-derive into `success`); only the unverified one is RECONCILABLE. Reconciliation is what bounds the stall that got the #742 attempt withdrawn: a later run pages `/statuses/{sha}` IN FULL and, ONLY IF that history contains the sentinel''s own row, either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it UPGRADES to the repair sentinel, clearable only by a human — or finds none, resolving the uncertainty and clearing it so the PR is classified normally. The reconciliation is sound because the two endpoints differ: a verdict this job masked is invisible on the COMBINED endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. It is deliberately BROADER than the inheritance test — no `H10_REVIEWERS` membership, no `(base: …)` match — because over-counting upgrades to a stall a human clears in one command while MISSING one re-exempts a head carrying a buried rejection. ONLY a COMPLETE walk CONTAINING THE SENTINEL ROW may clear it. The witness is not optional decoration: `ex_unverified` means the combined endpoint just returned that row, and `/statuses/{sha}` keeps one row per POST, so a complete history WITHOUT it — an empty one included — contradicts a write that demonstrably happened. `page_statuses` accepts an empty page 1 as complete (correct for a first-run mark), which is what made that shape reachable, and clearing on it exempted a head whose sentinel may have been sitting on a rejection. This is NOT the withdrawn currency witness: that asked whether ANY row sat above the mark, which an unrelated row satisfied, and it made one anomaly permanent — this asks for a SPECIFIC row already known to exist, and failing it costs one run rather than requiring a human. When NO high-water mark can be established the write is withheld BEFORE the POST rather than posted and repaired, since the defect is known in advance and publishing a green to take it back opens a window branch protection — and an already-scheduled auto-merge — can see. EVERY re-derivable state is downgraded there, not only `success`: an earlier draft restricted it to the exemption on the grounds that a sticky generic `pending` "withholds nothing, since an unreviewed PR is blocked already", which analysed the wrong PR — the damaging case is one that IS exemptible and got the generic `pending` only from a transient enumeration failure, whose unmarked description the next run re-derives into the exemption with the human row below its own mark. `$REPAIR_DESC` is the one exemption, being the stronger fact and not re-derivable. SEPARATELY, the retarget count is re-taken AFTER the POST on the exemption path, which closes the PERMANENT forged green `ci.verdict-write-retarget-fence` recorded as its residual: a retarget landing after the final pre-write count leaves a stale `success` with the `edited` event already consumed by a successor that short-circuited, so nothing remains to reclassify. Re-counting afterwards means a retarget later than the re-check necessarily queues a successor that STARTS after the stale `success` exists, and a machine-written `success` is re-derived rather than inherited. The RETARGET axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. An untrusted post-POST count repairs too — reaching that point with `success` means both earlier counts were trusted, so a third unreadable read is a fresh failure and "I cannot tell whether the base moved" must not resolve to leaving a green. FINALLY, every path that cannot establish what the head carries REPLACES the unknown state instead of merely declining to write — the combined read (retried once first), all four page-2 completeness refusals, and the fence branch that cannot trust its retarget count while holding a derived `success`. That last one is reached only AFTER the classification declined to inherit the row the head carries, so posting nothing left the declined row current; its message used to say the context "stays absent", which is true only of a head that had none. Declining protects a real verdict and leaves a FORGED one — an off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving it, and an unreadable read is the one thing that stops it while the job goes red on a status branch protection does not read. Nothing is destroyed: the per-POST history keeps the masked verdict and the next run''s reconciliation upgrades to the repair sentinel, telling the reviewer to re-post rather than silently un-approving them. The page-2 refusals were briefly excluded on the reasoning that the probe fires when NO row for this context was on page 1, so there is no green of any provenance to leave standing — self-contradictory, since the ONLY reason page 2 is read is that the row MAY be beyond page 1. WRITING THE SENTINEL AND FAILING THE JOB ARE SEPARATE DECISIONS: the read refusals were already non-zero exits on `main` and stay red, while the fence branch exited 0 there and still does, because an unreadable timeline is an ordinary hiccup and reddening every one of them is noise this file elsewhere refuses to add. The repair also has a FLOOR — it may never write a description weaker than the one this run decided, or a transient post-write read rewrites a correct `$REPAIR_DESC` carry-forward with the machine-clearable sentinel — and it is skipped entirely when it would rewrite what is already there.' signals: 'exemption success standing over a human failure, generic pending re-derived into an exemption, unverified write sentinel, reconcilable sentinel vs repair sentinel, no high-water mark could be established, post-write verification runs for every write, retarget after the POST leaves a permanent forged green, unreadable combined status read leaves an off-list green, why did my docs-only PR lose its exemption, why does the gate say the write could not be verified · paths: `.gitea/workflows/review-verdict.yml`, `scripts/tests/test_pr_changed_files.py` · issues: #849, #742, #763, #706, #803' -mechanics: '`UNVERIFIED_DESC` ("Exemption write could not be verified — re-post the verdict") beside `REPAIR_DESC`; `read_existing_verdict()` sets `ex_unverified` from a prefix match, retries the combined read once and on persistent failure calls `repair_status_to "$UNVERIFIED_DESC"` before `exit 1`; the RECONCILE block runs `page_statuses` and counts `Review-verdict:` rows with a non-null creator, clearing `ex_unverified`, upgrading to `ex_repair`, or carrying the sentinel forward on `ph_ok != yes`; the classification chain is `exempt` → `ex_repair` → `ex_unverified` → generic, so the repair sentinel outranks the unverified one; the no-mark downgrade sits AFTER the mark (referencing `$max_id_before` earlier is an unbound variable under `set -u`); the mid-run guard adds `[ "$ex_desc" != "$pre_desc" ]`, which the repair guard does not need because a pre-existing repair sentinel forces its own description while a pre-existing unverified one is deliberately replaced after reconciliation; the post-write gate is `if [ "$max_id_before" -ge 0 ]`; `--arg own "$desc"` excludes the job''s OWN row from both machine-sentinel selectors, which became necessary the moment the block started running for every write; `.description` is TYPE-TESTED before `startswith`, since `(.description // "")` does not replace a NUMBER and `startswith` then hard-errors, killing the whole count and losing a genuine verdict beside the malformed row; `repair_status_to()` is the shared retry-once writer for all three repair sites; tests `test_a_verdict_racing_a_PENDING_write_is_repaired_to_the_repair_sentinel`, `test_a_raced_PENDING_write_repairs_and_the_repair_SURVIVES_the_next_run`, `test_the_unverified_sentinel_is_a_FIXED_POINT_while_it_cannot_be_reconciled`, `test_the_unverified_sentinel_is_RECONCILED_AWAY_once_the_history_can_be_read`, `test_the_reconciliation_UPGRADES_to_the_repair_sentinel_when_a_verdict_is_BURIED`, `test_a_RETARGET_AFTER_the_POST_replaces_the_exemption`, `test_an_UNREADABLE_retarget_count_AFTER_the_POST_also_replaces_the_exemption`, `test_a_MALFORMED_description_row_does_not_kill_the_post_write_count`, `test_a_transport_failure_on_the_STATUS_READ_REPLACES_the_unknown_state`, each paired with a `test_MUTATION_…` proof that restores the exact predecessor text through `_run_classify(mutate=…)`.' +mechanics: '`UNVERIFIED_DESC` ("Exemption write could not be verified — re-post the verdict") beside `REPAIR_DESC`; `read_existing_verdict()` sets `ex_unverified` from a prefix match, retries the combined read once and on persistent failure calls `repair_status_to "$UNVERIFIED_DESC"` before `exit 1`; the RECONCILE block runs `page_statuses` and counts `Review-verdict:` rows with a non-null creator, clearing `ex_unverified`, upgrading to `ex_repair`, or carrying the sentinel forward on `ph_ok != yes`; the classification chain is `exempt` → `ex_repair` → `ex_unverified` → generic, so the repair sentinel outranks the unverified one; the no-mark downgrade sits AFTER the mark (referencing `$max_id_before` earlier is an unbound variable under `set -u`); the mid-run guard adds `[ "$ex_desc" != "$pre_desc" ]`, which the repair guard does not need because a pre-existing repair sentinel forces its own description while a pre-existing unverified one is deliberately replaced after reconciliation; the post-write gate is `if [ "$max_id_before" -ge 0 ]`, with the no-mark case handled before the POST instead; `replace_unknown_state()` is the shared writer for every path that cannot establish the head''s state and `replace_unknown_and_die()` wraps it for the two read refusals that were already non-zero exits; the reconciliation witness is a count of rows whose description equals `$UNVERIFIED_DESC`; `pre_id`/`ex_id` extend the mid-run changed-row comparison to row IDENTITY, since two sentinel POSTs are byte-identical by design (the combined endpoint carries `id` — measured 2026-08-29 at 1.27.1, head 736649b3, 8 rows, ids 14..30); `--arg own "$desc"` excludes the job''s OWN row from both machine-sentinel selectors, which became necessary the moment the block started running for every write; `.description` is TYPE-TESTED before `startswith`, since `(.description // "")` does not replace a NUMBER and `startswith` then hard-errors, killing the whole count and losing a genuine verdict beside the malformed row; `repair_status_to()` is the shared retry-once writer for all three repair sites; tests `test_a_verdict_racing_a_PENDING_write_is_repaired_to_the_repair_sentinel`, `test_a_raced_PENDING_write_repairs_and_the_repair_SURVIVES_the_next_run`, `test_the_unverified_sentinel_is_a_FIXED_POINT_while_it_cannot_be_reconciled`, `test_the_unverified_sentinel_is_RECONCILED_AWAY_once_the_history_can_be_read`, `test_the_reconciliation_UPGRADES_to_the_repair_sentinel_when_a_verdict_is_BURIED`, `test_a_RETARGET_AFTER_the_POST_replaces_the_exemption`, `test_an_UNREADABLE_retarget_count_AFTER_the_POST_also_replaces_the_exemption`, `test_a_MALFORMED_description_row_does_not_kill_the_post_write_count`, `test_a_transport_failure_on_the_STATUS_READ_REPLACES_the_unknown_state`, each paired with a `test_MUTATION_…` proof that disarms the shipped clause it names through `_run_classify(mutate=…)`, whose count assertion is the binding. Two of those mutants restore `origin/main` verbatim, one restores the shape #742 withdrew, and the rest disarm clauses that have no predecessor because the blocks they gate are new — the section header in that file enumerates which is which, because "restores the exact predecessor" is true of only some of them.' --- `ci.verdict-write-retarget-fence` fenced the write and verified it afterwards. Five routes survived @@ -76,6 +76,12 @@ remaining. Re-counting after the POST closes the induction rather than narrowing retarget later than the re-check necessarily queues a successor that starts *after* the stale `success` exists, and a machine-written `success` is re-derived, not inherited. +**What that buys is TRANSIENT instead of PERMANENT, not "the green never stands".** The `success` is +live between its POST and the repair, including the timeline round trips in between, so branch +protection or an already-scheduled auto-merge can observe it. Closing *that* window needs a +compare-and-set or serialization the API does not offer. What is removed is the case where no event +remained to correct it. + **The retarget axis only.** A push after the POST moves the head, so the status is no longer on the PR's head and cannot gate its merge; a retarget changes the effective diff while the sha stays, which is why the status remains authoritative for a diff it no longer describes. Fencing pushes here would @@ -97,3 +103,12 @@ sha that becomes the head again after a revert. 4. **The reconciliation reads the same paged endpoint the rest of the job does**, so it inherits the 20-page cap: a history past 950 rows cannot be reconciled and the sentinel stands until a human clears it. +5. **A head carrying more CONTEXTS than the combined endpoint's page cap stalls on every run**, since + the page-2 refusal fires each time and now writes the sentinel each time. Measured 2026-08-29, + this repo puts 8 contexts on a `main` head against a cap of 50. That case already stalled before + this change — with an ABSENT required check, which reads as "not reviewed yet" — so what changed + is that the stall says why. +6. **A transient combined-read failure on an APPROVED PR costs that PR its verdict.** The + reconciliation finds the masked `Review-verdict:` row and UPGRADES to the repair sentinel, which + only a human can clear — so the recovery for the case where the replacement actually cost + something is "you are asked to re-post", not "it clears itself". The operator messages say so. diff --git a/docs/decisions/records/ci/verdict-write-retarget-fence.md b/docs/decisions/records/ci/verdict-write-retarget-fence.md index e384b5994..8a8df6878 100644 --- a/docs/decisions/records/ci/verdict-write-retarget-fence.md +++ b/docs/decisions/records/ci/verdict-write-retarget-fence.md @@ -235,9 +235,18 @@ to every row it extracts since #643, and the walk whose counts gate the write di **Not closed by this.** Two residuals, and the second is the one to read carefully. -1. The residual named in "What is NOT closed" item 1 applies unchanged to the head axis: a push - landing between the final pre-write count and the POST is not seen, and by the #849 argument that - leaves a permanent rather than transient forged green. +1. The residual named in "What is NOT closed" item 1 applies to the head axis with the base axis's + remedy DELIBERATELY WITHHELD: a push landing between the final pre-write count and the POST is + not seen, and #849 re-counts only the RETARGET axis after the POST. The reason is not neglect — a + push moves the head, so the status is no longer on the PR's head and cannot gate its merge, while + a retarget changes the effective diff with the sha unchanged; re-checking pushes here would + instead strand a sentinel on a sha that becomes the head again after a revert. Do not read the + #849 argument as concurring that this axis needs the same treatment. + + Worth flagging for whoever settles it: the wording above may overstate its own case, since the + enumeration is COMPLETE before the final count is taken, so a push inside that window cannot have + split the file list across heads. That is #803's text and settling it needs its own analysis; + #849 did not attempt one. 2. **The walk's terminator is defeatable, so neither axis is closed outright** — pre-existing, found by cross-family review of #803 from the v1.27.1 source, and tracked as ersatztv#870 because fixing diff --git a/docs/remote-state-inventory.md b/docs/remote-state-inventory.md index 4fc2060b2..095b84a4a 100644 --- a/docs/remote-state-inventory.md +++ b/docs/remote-state-inventory.md @@ -145,7 +145,7 @@ classifications differ; otherwise the strictest applies and the Note names the e | Site | Class | Note | |---|---|---| -| `.gitea/workflows/review-verdict.yml` — status read → status POST | `UNSAFE-KNOWN` | The residual this whole class reduces to. Gitea's status API has no ETag, no If-Match and no expected-previous-state, so read and write cannot be made one operation. Narrowed rather than claimed closed: a monotonic **event-count** fence refuses to write if the PR timeline's `change_target_branch` OR `pull_push` count moved (`ci.verdict-write-retarget-fence`; counts are used because the branch *name* and the head *sha* are both ABA-vulnerable — the head axis added by #803), and a high-water-mark re-read repairs a status posted over a human verdict back to `pending` — after EVERY write since #849, not only an exemption `success`, because a generic `pending` masks a rejection just as well and was then re-derived green by the next run. Since #849 the write side is also fenced on the far end: the retarget count is re-taken AFTER the POST on the exemption path (closing the permanent forged green that record listed as residual 1), an exemption no high-water mark can cover is withheld rather than posted, and an unreadable combined-status read REPLACES the unknown state instead of leaving a possibly-forged green standing (`ci.verdict-unverified-write-sentinel`). The file states the residual window explicitly rather than asserting safety. | +| `.gitea/workflows/review-verdict.yml` — status read → status POST | `UNSAFE-KNOWN` | The residual this whole class reduces to. Gitea's status API has no ETag, no If-Match and no expected-previous-state, so read and write cannot be made one operation. Narrowed rather than claimed closed: a monotonic **event-count** fence refuses to write if the PR timeline's `change_target_branch` OR `pull_push` count moved (`ci.verdict-write-retarget-fence`; counts are used because the branch *name* and the head *sha* are both ABA-vulnerable — the head axis added by #803), and a high-water-mark re-read repairs a status posted over a human verdict back to `pending` — after EVERY write since #849, not only an exemption `success`, because a generic `pending` masks a rejection just as well and was then re-derived green by the next run. Since #849 the write side is also fenced on the far end: the retarget count is re-taken AFTER the POST on the exemption path (closing the permanent forged green that record listed as residual 1), a write no high-water mark can cover becomes the sticky sentinel rather than a status a later run re-derives, and every path that cannot establish what the head carries — the combined read, the four page-2 completeness refusals, and an untrusted fence holding a derived `success` — REPLACES that unknown state instead of leaving a possibly-forged green standing (`ci.verdict-unverified-write-sentinel`). The post-POST re-count makes the retarget green TRANSIENT, not absent: it is live between its POST and the repair. The file states the residual window explicitly rather than asserting safety. | | `.gitea/workflows/review-verdict.yml` — base-ref checkout | `PINNED` | `ref: ${{ github.event.pull_request.base.sha }}` — a full sha from the fixed event payload, so the PR head cannot supply the workflow definition that judges it. | | `.gitea/workflows/review-verdict.yml` — changed-file enumeration | `UNSAFE-KNOWN` | Delegates to `scripts/pr-changed-files.sh` with the head sha and expected base, and therefore **inherits that row's residual, not a pin** — a cross-reference to another row's GRADE goes stale the moment that row is regraded, so this one names what it inherits. Accepted on better terms than the enumerator alone — this is the one caller that also runs the monotonic event-count fence (`ci.verdict-write-retarget-fence`), and since #803 that fence covers BOTH axes: `change_target_branch` for the base alias and `pull_push` for the HEAD alias described in the enumerator's row (`H1 -> H2 -> H1` during pagination). What remains is the residual the fence shares with the base axis — a mutation landing between the final pre-write count and the POST — not an unwatched axis, and since #849 the RETARGET half of it is caught by a post-POST re-count while the push half is deliberately not (a push moves the head, so the status no longer gates that PR). | | `.gitea/workflows/docker-build.yml` — CI toolchain image | `UNSAFE-KNOWN` | This file's own definition of `PINNED` names an image **digest**, and `ersatztv-ci:` is a mutable **tag**. A registry tag can be repointed after `ci-image-pin` verifies it and before a job pulls it, and a 7-hex short sha is additionally collision-prone. Accepted rather than fixed here because the exposure needs write access to our own LAN registry — i.e. an attacker already inside the trust boundary — and two controls bound it: jobs never consume `:latest`, and `pr-checks.yml`'s `ci-image-pin` fails the build if the tag drifts from the last commit touching `docker/ci/`. Consuming `image@sha256:…` is the real fix and is the natural companion to **#772**, which already covers the availability half of this tag's weakness. | diff --git a/scripts/tests/test_pr_changed_files.py b/scripts/tests/test_pr_changed_files.py index 367e1eca4..adab949fb 100644 --- a/scripts/tests/test_pr_changed_files.py +++ b/scripts/tests/test_pr_changed_files.py @@ -1355,6 +1355,14 @@ if "/statuses/" in url: seen = int(ctr.read_text()) if ctr.exists() else 0 ctr.write_text(str(seen + 1)) pages = [ordinary[:50], ordinary[50:], [dict(raced_row)] if seen > 0 else []] + # SEEDED ROWS (ersatztv#849 round 2). The reconciliation may only clear the sentinel over a + # history that CONTAINS it, which is the one shape the mode-driven fixtures above cannot + # express: they describe a head as it was BEFORE this job ever wrote to it, and the sentinel + # is by definition a row an EARLIER run already POSTed. Seeding is how a fixture says "this + # head has been written to before", which is exactly the precondition `ex_unverified` states. + extra = os.environ.get("STUB_HISTORY_EXTRA", "") + if extra: + rows = list(rows) + json.loads(extra) if order_faithful and not want_asc: rows = list(reversed(rows)) if pages is None: @@ -1619,6 +1627,7 @@ def _run_classify( timeline_pages: int = 1, history_mode: str = "none", history_creator: str = "timothy", + history_extra: list | None = None, midrun_creator: str = "timothy", pre_row: str = "", midrun_row: str = "", @@ -1664,6 +1673,7 @@ def _run_classify( env["STUB_STATUS_EMPTY_SHAPE"] = status_empty_shape env["STUB_HISTORY_MODE"] = history_mode env["STUB_HISTORY_CREATOR"] = history_creator + env["STUB_HISTORY_EXTRA"] = json.dumps(history_extra) if history_extra else "" env["STUB_MIDRUN_CREATOR"] = midrun_creator env["STUB_PRE_ROW"] = pre_row env["STUB_MIDRUN_ROW"] = midrun_row @@ -1702,10 +1712,10 @@ def _run_classify( if mutate is not None: # DISARM A CLAUSE OF THE SHIPPED BODY, for the mutation proofs (ersatztv#849). # - # THE MUTANT IS THE REAL PREDECESSOR, not a hand-written stand-in: every use below restores - # the exact text this branch replaced, so a proof shows the fix — rather than showing that - # some plausible-looking wrong version would fail. A hand-written mutant proves a test can go - # red, which is not the same claim. + # WHICH MUTANT EACH USE IS — a restored predecessor, a withdrawn draft, or a disarmed new + # clause — is enumerated at the ersatztv#849 section header below. Two restore `origin/main` + # verbatim; the rest disarm clauses that have no predecessor to restore, which is the precise + # mutation for a NEW guard and is not the same claim as "this is what the code used to be". # # The count assertion is the binding. Without it a clause that has since been reworded or # moved substitutes ZERO times, the "mutant" is the unmutated body, and the proof asserts the @@ -1745,6 +1755,27 @@ def _run_classify( DOCS_ONLY = 'printf "docs/a.md\\ndocs/b.md\\n"\n' +def _assert_withheld(posted, r, why): + """The gate refused to grant the exemption — which since ersatztv#849 means the head is MARKED, + not left alone. + + Every one of these tests asserted `posted is None`. That was right while "withhold" meant + "write nothing", and it became a fail-open assertion when it stopped meaning that: declining to + write protects a REAL verdict on the head and leaves a FORGED one, and this job's own red status + is not a required check, so branch protection still sees whatever was already there. + + Asserted positively — state AND description — because `pending` alone does not distinguish the + sticky sentinel from the generic awaiting-verdict text a later run happily re-derives. + """ + assert posted is not None, ( + f"{why}: nothing was posted, so whatever {'{CONTEXT}'} this head already carries is standing " + f"unread and unre-derived.\n{r.stdout[-900:]}" + ) + assert posted["state"] == "pending" and posted["description"] == UNVERIFIED_DESC, ( + f"{why}: expected the unverified-write sentinel, got {posted}\n{r.stdout[-900:]}" + ) + + def test_a_FAILING_enumeration_withholds_the_exemption_even_when_stdout_looks_docs_only(tmp_path): """The mutation that previously survived: ignore the exit status, trust stdout.""" posted, r = _run_classify(tmp_path, "#!/usr/bin/env bash\n" + DOCS_ONLY + "exit 1\n") @@ -1942,7 +1973,7 @@ def test_DOCS_ONLY_anchors_the_markdown_extension(tmp_path): assert posted["state"] == "pending", "a .mdx file was accepted as docs-only" -def test_a_transport_failure_under_jq_1_6_STILL_posts_nothing(tmp_path): +def test_a_transport_failure_under_jq_1_6_STILL_refuses_to_classify(tmp_path): """The ersatztv#647 fail-open, tested where it actually lives: jq 1.6. The shell emptiness check exists because `jq -e` over EMPTY input exits 4 on jq >= 1.7 but **0 on @@ -3218,7 +3249,7 @@ def test_the_head_arm_DEFERS_to_the_trust_guard_when_the_second_count_fails(tmp_ timeline_mode="trusted-then-unreadable", push_mode="stable:3", ) - assert posted is None, f"an exemption was granted on an untrusted count. Log:\n{r.stdout[-900:]}" + _assert_withheld(posted, r, "test_the_head_arm_DEFERS_to_the_trust_guard_when_the_second_count_fails") assert "Could not establish a trusted retarget/push count" in r.stdout, ( f"the untrusted re-count was not reported as such. Log:\n{r.stdout[-900:]}" ) @@ -3356,10 +3387,7 @@ def test_an_EMPTY_FIRST_page_is_untrusted_WHICHEVER_empty_shape_it_is(tmp_path, timeline_mode="empty-first-page", timeline_terminator=terminator, ) - assert posted is None, ( - f"an exemption was granted from an empty FIRST page ({terminator}) — a zero count was " - f"trusted from a response the walk cannot explain. Log:\n{r.stdout[-900:]}" - ) + _assert_withheld(posted, r, "test_an_EMPTY_FIRST_page_is_untrusted_WHICHEVER_empty_shape_it_is") def test_a_timeline_row_with_NO_READABLE_TYPE_is_not_counted_as_nothing(tmp_path): @@ -3371,10 +3399,7 @@ def test_a_timeline_row_with_NO_READABLE_TYPE_is_not_counted_as_nothing(tmp_path IT extracts, which the walk gating the write did not have. """ posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), timeline_mode="untyped-rows") - assert posted is None, ( - "an exemption was granted over a timeline page whose rows carry no readable `.type` — the " - f"count was trusted from rows the walk cannot classify. Log:\n{r.stdout[-900:]}" - ) + _assert_withheld(posted, r, "test_a_timeline_row_with_NO_READABLE_TYPE_is_not_counted_as_nothing") def test_the_head_fence_reports_the_OBSERVED_counts_in_its_notice(tmp_path): @@ -3431,10 +3456,7 @@ def test_an_UNTRUSTED_retarget_count_withholds_the_EXEMPTION(tmp_path, mode): """A `success` that cannot be shown to describe the PR's current base must not be written. An absent required check blocks the merge, which is the safe direction.""" posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), timeline_mode=mode) - assert posted is None, ( - "an exemption success was posted even though the retarget count could not be established " - f"({mode}). Log:\n{r.stdout[-900:]}" - ) + _assert_withheld(posted, r, "test_an_UNTRUSTED_retarget_count_withholds_the_EXEMPTION") @pytest.mark.parametrize("mode", ["unreadable", "transport-error"]) @@ -3643,7 +3665,7 @@ def test_an_UNTRUSTED_count_withholds_the_exemption_BY_THAT_BRANCH(tmp_path): """Round 2 test-gap: the existing untrusted-count test asserted only "posted nothing", which a crash also produces. Assert the discriminator and a clean exit.""" posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), timeline_mode="unreadable") - assert posted is None + _assert_withheld(posted, r, "test_an_UNTRUSTED_count_withholds_the_exemption_BY_THAT_BRANCH") assert r.returncode == 0, f"the job died rather than declining cleanly: {r.stderr[-800:]}" # The wording covers BOTH axes since ersatztv#803 — one walk certifies one trust flag, so an # unreadable page abandons the push count and the retarget count together. @@ -4327,10 +4349,7 @@ def test_a_terminator_on_PAGE_ONE_does_not_certify_a_zero_retarget_count(tmp_pat """ posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), timeline_mode="empty-first-page") assert r.returncode == 0, r.stderr - assert posted is None, ( - "an exemption `success` was posted from a timeline whose FIRST page was already the " - f"terminator, so no page of events was ever actually read: {posted}\n{r.stdout[-800:]}" - ) + _assert_withheld(posted, r, "test_a_terminator_on_PAGE_ONE_does_not_certify_a_zero_retarget_count") assert "trusted=no" in r.stdout, ( f"the exemption was withheld, but not because the count was untrusted:\n{r.stdout[-800:]}" ) @@ -4354,10 +4373,7 @@ def test_a_SECOND_PAGE_of_statuses_refuses_to_conclude_that_no_verdict_exists(tm the list is longer than one page and the verdict may be beyond it. """ posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode="twopage") - assert posted is None, ( - "an exemption was posted while the status list ran to a second page, so an existing verdict " - f"beyond page 1 would have been silently overwritten: {posted}\n{r.stdout[-800:]}" - ) + _assert_withheld(posted, r, "test_a_SECOND_PAGE_of_statuses_refuses_to_conclude_that_no_verdict_exists") assert "page 2" in (r.stdout + r.stderr).lower(), ( f"nothing was posted, but not because of the page-2 completeness probe:\n{r.stdout[-800:]}" ) @@ -4410,10 +4426,7 @@ def test_an_UNREADABLE_page_2_refuses_to_conclude_that_no_verdict_exists(tmp_pat that cannot be read justifies nothing. """ posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode=mode) - assert posted is None, ( - f"an exemption was posted despite {why}, so 'no verdict exists' was concluded without " - f"evidence: {posted}\n{r.stdout[-800:]}" - ) + _assert_withheld(posted, r, "test_an_UNREADABLE_page_2_refuses_to_conclude_that_no_verdict_exists") assert "page 2" in (r.stdout + r.stderr).lower(), ( f"nothing was posted, but not because of the page-2 probe:\n{r.stdout[-800:]}" ) @@ -4927,9 +4940,24 @@ def test_an_UNREADABLE_history_page_2_also_repairs_rather_than_leaving_green(tmp # --- ersatztv#849: post-write verification, the five routes ------------------------------------ # # Each route left an exemption `success` — or a generic `pending` a later run re-derives into one — -# standing over a human `failure`. The tests below pair a behavioural assertion with a MUTATION that -# restores the exact predecessor text, so each proof shows the shipped clause is what produces the -# outcome rather than showing that some plausible wrong version would fail. +# standing over a human `failure`. Each behavioural assertion below is paired with a MUTATION that +# disarms the shipped clause it names, so the proof shows THAT clause is what produces the outcome. +# +# BE PRECISE ABOUT WHAT THE MUTANTS ARE, because "restores the exact predecessor" is true of only +# some of them and claiming it of all would be the overclaim this repo treats as its own defect: +# +# * `test_MUTATION_a_SUCCESS_only_post_write_gate_...` and +# `test_MUTATION_restoring_the_default_operator_on_the_description_...` DO restore `origin/main` +# text verbatim. +# * `test_MUTATION_a_GENERIC_pending_...` restores the shape #742 attempted and WITHDREW, not +# `main` — which had no downgrade at all. +# * the `if false` mutants disarm clauses that have NO predecessor, because the blocks they gate +# are new. Disarming the condition isolates the DECISION from the round-trip beside it, which +# deleting the block would not; they are counterfactual mutants and are sound as such. +# +# The count assertion in `_run_classify(mutate=...)` is what keeps every one of them bound: a clause +# that has since been reworded substitutes zero times and fails loudly rather than quietly measuring +# the unmutated body. def test_a_verdict_racing_a_PENDING_write_is_repaired_to_the_repair_sentinel(tmp_path): @@ -5110,6 +5138,10 @@ def test_MUTATION_a_GENERIC_pending_for_an_unverifiable_write_is_re_derived_into status_creator=None, status_desc=f1["description"], # <-- the chain history_mode="stale-human-already-present", + # Seeded on THIS arm only, and the asymmetry is the fixture being faithful rather than + # convenient: the shipped run 1 POSTed the sentinel, so its row is in the per-POST history; + # the mutant's run 1 POSTed a generic `pending`, so no sentinel row exists on that head. + history_extra=[_sentinel_row()], ) assert f2 is not None and f2["state"] == "pending" and f2["description"] == REPAIR_DESC, ( "the shipped chain re-derived its own sentinel into an exemption over a buried verdict: " @@ -5117,6 +5149,24 @@ def test_MUTATION_a_GENERIC_pending_for_an_unverifiable_write_is_re_derived_into ) +def _sentinel_row(row_id=4000): + """The row an earlier run POSTed when it wrote the unverified sentinel. + + A fixture that puts the sentinel on the COMBINED endpoint without putting it in the per-POST + history describes a head that cannot exist: `/statuses/{sha}` returns one row per POST, so the + sentinel the combined endpoint is showing must be in there. Seeding it is not decoration — the + reconciliation now requires exactly this row as its witness, and the first version of these tests + passed against the impossible shape. + """ + return { + "id": row_id, + "context": "review-verdict/h10", + "status": "pending", + "creator": None, + "description": UNVERIFIED_DESC, + } + + def test_the_unverified_sentinel_is_RECONCILED_AWAY_once_the_history_can_be_read(tmp_path): """The bound on the stall, and the reason this is a second sentinel rather than the repair one. @@ -5134,6 +5184,14 @@ def test_the_unverified_sentinel_is_RECONCILED_AWAY_once_the_history_can_be_read status_mode="existing:pending", status_creator=None, status_desc=UNVERIFIED_DESC, + # A REAL history: the sentinel's own row, plus an unrelated one so "non-empty" is not what is + # being tested. Without the sentinel row this fixture is the impossible shape and the test + # passed against a defect — the reconciliation used to clear over an EMPTY history, which + # cannot be true for a head whose combined endpoint is showing a row. + history_extra=[ + {"id": 3900, "context": "ci/other", "status": "success", "creator": None, "description": "unrelated"}, + _sentinel_row(), + ], ) assert posted is not None, f"nothing was posted: {r.stderr[-800:]}" assert posted["state"] == "success", ( @@ -5162,6 +5220,7 @@ def test_the_reconciliation_UPGRADES_to_the_repair_sentinel_when_a_verdict_is_BU status_creator=None, status_desc=UNVERIFIED_DESC, history_mode="stale-human-already-present", + history_extra=[_sentinel_row()], ) assert posted is not None, f"nothing was posted: {r.stderr[-800:]}" assert posted["state"] == "pending" and posted["description"] == REPAIR_DESC, ( @@ -5179,9 +5238,14 @@ def test_MUTATION_reconciling_on_an_UNREADABLE_history_re_exempts_a_head(tmp_pat pending. Treating it as "nothing buried" is the fail-open, and it is a tempting simplification because the happy path looks identical. - The mutant inverts the trust test. The fixture makes the reconciliation walk fail while a verdict - IS present in the history, so the shipped code carries the sentinel forward and the mutant - exempts the head over a buried verdict. + The mutant disarms the trust test. BE EXACT ABOUT WHAT THE FIXTURE SHOWS, because an earlier + version of this docstring claimed it "makes the reconciliation walk fail while a verdict IS + present in the history" — `premark-page1-error` serves ordinary rows and no verdict, so that was + a description of a different fixture. What is shown here is narrower and sufficient: on a walk + that could not be read, the shipped code carries the sentinel forward and the mutant clears it + and grants the exemption. That the cleared sentinel can be sitting on a real verdict is shown by + `test_the_reconciliation_UPGRADES_to_the_repair_sentinel_when_a_verdict_is_BURIED`, which is the + test that supplies one. """ posted, r = _run_classify( tmp_path / "fixed", @@ -5202,7 +5266,10 @@ def test_MUTATION_reconciling_on_an_UNREADABLE_history_re_exempts_a_head(tmp_pat status_creator=None, status_desc=UNVERIFIED_DESC, history_mode="premark-page1-error", - mutate=('\n if [ "$ph_ok" != yes ]; then\n', "\n if false; then\n"), + mutate=( + '\n if [ "$ph_ok" != yes ] || [ "$witness" -eq 0 ]; then\n', + "\n if false; then\n", + ), ) assert mutant is not None and mutant["state"] == "success", ( "the mutant did not re-exempt, so the trust test is not what holds the sentinel and the " @@ -5210,6 +5277,63 @@ def test_MUTATION_reconciling_on_an_UNREADABLE_history_re_exempts_a_head(tmp_pat ) +def test_an_IMPOSSIBLE_EMPTY_history_does_NOT_reconcile_the_sentinel_away(tmp_path): + """A complete-but-empty history is the one shape this read cannot legitimately return. + + `ex_unverified=yes` means the COMBINED endpoint just returned the sentinel for this sha, and + `/statuses/{sha}` keeps one row per POST — so the sentinel it is showing MUST be in the history. + A complete walk that comes back empty therefore contradicts a write that demonstrably happened, + exactly as the post-write check already argues about its own read. + + It is reachable rather than theoretical: `page_statuses` deliberately accepts an empty page 1 as + complete, because a head nothing has posted to genuinely has no statuses and the high-water mark + needs that answer. Clearing on it let the run classify normally and exempt a head whose sentinel + may have been sitting on a rejection. + + The first version of the RECONCILED_AWAY test above used exactly this fixture, so the clear path + was only ever asserted against the impossible shape. + """ + posted, r = _run_classify( + tmp_path, + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=UNVERIFIED_DESC, + ) # no history_extra: the walk completes over ZERO rows + assert posted is not None, f"nothing was posted: {r.stderr[-800:]}" + assert posted["state"] == "pending" and posted["description"] == UNVERIFIED_DESC, ( + "an impossible empty history reconciled the sentinel away, so the run exempted a head whose " + f"write was never verified: {posted}\n{r.stdout[-900:]}" + ) + assert "sentinel rows found=0" in (r.stdout + r.stderr), ( + f"the sentinel was carried forward, but not because the witness was missing:\n{r.stdout[-900:]}" + ) + + +def test_MUTATION_dropping_the_reconciliation_WITNESS_clears_on_an_impossible_history(tmp_path): + """Disarming the witness conjunct alone, so the empty-history refusal is isolated. + + `ph_ok` is `yes` on this fixture — the walk really does complete — so the trust half of the + condition cannot be what refuses. Only the witness count can, and dropping it must produce the + exemption. + """ + posted, r = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=UNVERIFIED_DESC, + mutate=( + '[ "$ph_ok" != yes ] || [ "$witness" -eq 0 ]; then', + '[ "$ph_ok" != yes ]; then', + ), + ) + assert posted is not None and posted["state"] == "success", ( + "the mutant did not clear the sentinel, so the witness conjunct is not what refuses on an " + f"empty history and the test above proves nothing about it: {posted}\n{r.stdout[-900:]}" + ) + + def test_a_MALFORMED_description_row_does_not_kill_the_post_write_count(tmp_path): """Route 4, the twin of the malformed-`creator` row already covered above. @@ -5317,6 +5441,16 @@ def test_positive_control_a_QUIET_timeline_after_the_POST_leaves_the_exemption_a assert len(seq) == 1 and seq[0]["state"] == "success", ( f"a quiet timeline cost the PR its exemption: {seq}\n{r.stdout[-900:]}" ) + # THE SINGLE POST DOES NOT PROVE THE RECOUNT RAN — a job that skipped it entirely produces the + # identical result, so the assertion above is satisfied by the very regression it is meant to + # exclude. The stub counts REAL PAGES SERVED, one per walk at `STUB_TIMELINE_PAGES=1`, so three + # is the exempt path: before classifying, at the pre-POST fence, and after the POST. + walks = int((tmp_path / "timeline_reads.txt").read_text()) + assert walks == 3, ( + f"expected three timeline walks on the exemption path (before, pre-POST fence, post-POST " + f"re-check); the stub served {walks}, so the post-POST re-check did not run and the " + f"assertion above proves nothing about it" + ) def test_MUTATION_deleting_the_POST_POST_retarget_check_leaves_the_stale_green(tmp_path): @@ -5353,8 +5487,8 @@ def test_MUTATION_declining_to_replace_an_unreadable_combined_read_leaves_the_fo _emitting("docs/a.md"), status_mode="transport-error", mutate=( - ' if repair_status_to "$UNVERIFIED_DESC"; then', - " if false; then", + ' if repair_status_to "$UNVERIFIED_DESC"; then', + " if false; then", ), ) assert r.returncode != 0 -- 2.47.3 From 168fe21088e345fe2a5f4b9565e1416218ca09ef Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 30 Aug 2026 01:35:33 +0200 Subject: [PATCH 03/11] =?UTF-8?q?fix(849):=20round=203=20=E2=80=94=20repla?= =?UTF-8?q?ce=20every=20unknown=20state,=20and=20prove=20the=20clauses=20t?= =?UTF-8?q?hat=20claim=20to?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more cold reviews — cross-family (Codex/GPT-5.6) and a cold Claude reviewer that ran the mutants itself — converged on two separate things: a remaining class of paths that still left an unknown state standing, and, more importantly, that several clauses this branch claimed as fixes SURVIVED mutation of the exact text they name. ## Behaviour 1. The reconciliation witness matches the CURRENT row's `id`, not merely a row with the sentinel's description. Description alone is satisfied by an OLDER identical sentinel — which is what a fixed point produces — so a read carrying only the earlier row cleared the sentinel while the verdict buried under the current one ended up below the fresh mark. Falls back to the description where the server omits `id`. 2. The two OBSERVED-mutation arms mark a head that carries a row this run declined, instead of only abstaining. They are still right not to post their CLASSIFICATION — computed against a base or head the PR may no longer have — but a declined row must not stay authoritative for the whole window until a successor finishes, and for a PR's FIRST push no successor is queued at all. Scoped to `pre_state` being non-empty, so the common path stays quiet. 3. `replace_unknown_state` RETURNS a status. Its first version ended the failure arm with a successful `echo`, so it reported 0 after both POSTs failed and the fence caller's `exit 0` reported an abstention that had not happened. 4. An `id` difference counts only when BOTH reads supplied one. A response that omits `id` beside one that includes it otherwise reads as a replacement, and this guard's reaction is to abstain — over a row the classification had already declined. 5. Every element and every consumed field of the combined response is type-checked before extraction, and a schema failure routes to the replacement. `.statuses` being an array was checked; its ELEMENTS were not, so one scalar made `select(.context == $c)` hard-error and `set -e` took the step down before any path could mark the head. 6. The path-predicate failure replaces rather than merely exiting, for the same reason. 7. `$UNVERIFIED_DESC` says "Status write", not "Exemption write". It is now written on paths that grant no exemption at all, and it is the operator-facing text of a required check. 8. The no-op-repair skip keeps the human `::error::`. Skipping the WRITE is right — the head already carries the strongest marker — but that message is the only place a reviewer is told their verdict was buried. `raced_why` is a sentence now, not the token `human`. ## Proof The cold reviewer measured three of the six round-2 claims surviving mutation of their own clause, one against the verbatim predecessor from the previous commit. Nine proofs added: the no-mark downgrade's SCOPE (not just the description it writes), the page-2 refusals, the untrusted-fence write, the row-`id` comparison, the repair floor, the no-op skip, both `$own` exclusions, the write-result return, and the both-ids-present rule. Two of those needed the test double to grow: the combined-status stub emitted no `id` at all, so the `ex_id` clause had never once run with a non-empty value; and POSTs always succeeded, so both write helpers' failure arms were unreachable. The `$own` exclusions and the no-op skip are OUTCOME-redundant — mutating either alone leaves the post sequence unchanged, which is how duplicate guards hide each other. Their proofs assert the LOG, because what the exclusions alone decide is whether the job reports a race against its own row. One clause is left deliberately unproven and named as such in the record and the guard inventory rather than counted: the path-predicate failure branch has no fixture that can reach it. ## Also Round 2 left two comment paragraphs duplicated verbatim and a block header narrower than its block; both fixed. Stale prose corrected in the workflow ("dies WITHOUT posting", "post-write verification never runs for it", "this block only runs after a `success`"), `docs/ci-cd.md` ("the fence never re-counts", "the history is read twice" — it is three now), `ci.exemption-provenance` and `docs/guard-inventory.md`. refs #849 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF --- .gitea/workflows/review-verdict.yml | 186 +++-- docs/ci-cd.md | 9 +- docs/decisions/README.md | 2 +- .../records/ci/exemption-provenance.md | 2 +- .../ci/verdict-unverified-write-sentinel.md | 4 +- docs/guard-inventory.md | 2 +- scripts/tests/test_pr_changed_files.py | 641 +++++++++++++++++- 7 files changed, 761 insertions(+), 85 deletions(-) diff --git a/.gitea/workflows/review-verdict.yml b/.gitea/workflows/review-verdict.yml index c45447a85..2c0fc2c20 100644 --- a/.gitea/workflows/review-verdict.yml +++ b/.gitea/workflows/review-verdict.yml @@ -276,7 +276,16 @@ jobs: # stall bounded instead of permanent (see RECONCILE below), and it is sound because # `/statuses/{sha}` returns one row per POST: a verdict this job masked on the COMBINED # endpoint is still in the per-POST history for a later run to find. - UNVERIFIED_DESC="Exemption write could not be verified — re-post the verdict" + # "STATUS", NOT "EXEMPTION". This is written on paths that grant no exemption at all — an + # ordinary non-exempt `pending` with no mark, a page-2 read refusal on a head that carries + # nothing, an unreadable combined read — and the description is the operator-facing text of + # a branch-protection-required check. Telling someone whose plain unreviewed PR is waiting + # that an "exemption write" could not be verified names a thing that never happened. + # + # RENAMING IS A ONE-RUN TRANSITION, not a breaking change: a run that reads a head carrying + # the old text does not recognise it as a sentinel, re-derives it, and writes the new one. + # Nothing in production carries either string yet, so the transition is free here. + UNVERIFIED_DESC="Status write could not be verified — re-post the verdict" # WHOSE verdict may be INHERITED (ersatztv#742). Space-separated logins, compared exactly. # # The test this replaces was "the status has a non-null `.creator.login`", which only proves @@ -432,13 +441,18 @@ jobs: # every time. Measured 2026-08-29, this repo puts 8 contexts on a `main` head. That case # already stalled before this change — with an ABSENT required check, which reads as "not # reviewed yet" — so what changes is that the stall now says why. + # THE RETURN VALUE IS THE WHOLE POINT, and the first version did not have one: the `else` + # branch ended with a successful `echo`, so the function returned 0 after BOTH POSTs failed + # and its fence caller took the `exit 0` beside it as though the head had been marked. An + # explicit `return` per arm, and every caller acts on it. replace_unknown_state() { # $1 = the ::error:: naming what could not be established echo "::error::$1" if repair_status_to "$UNVERIFIED_DESC"; then echo "::error::Replaced ${CONTEXT} on ${SHA:0:7} with the unverified-write sentinel rather than leaving a state this job could not read standing. A later run will either clear it, or — if it finds a verdict underneath — ask you to re-post that verdict. To settle it now: scripts/post-review-verdict.sh ${PR} " - else - echo "::error::COULD NOT WRITE THE UNVERIFIED SENTINEL to ${SHA:0:7}. Whatever ${CONTEXT} this head carries is standing unread. Post a verdict immediately: scripts/post-review-verdict.sh ${PR} " + return 0 fi + echo "::error::COULD NOT WRITE THE UNVERIFIED SENTINEL to ${SHA:0:7}. Whatever ${CONTEXT} this head carries is standing unread. Post a verdict immediately: scripts/post-review-verdict.sh ${PR} " + return 1 } # WRITING THE SENTINEL AND FAILING THE JOB ARE SEPARATE DECISIONS, and only the read paths # take both. `read_existing_verdict`'s refusals already exited non-zero on `main`, so @@ -447,7 +461,10 @@ jobs: # every one of them into a red run is the noise this file elsewhere refuses to add. It # calls `replace_unknown_state` and returns cleanly. replace_unknown_and_die() { - replace_unknown_state "$1" + # The write may itself fail; either way this path exits non-zero, so the return value is + # deliberately not branched on here — it is `replace_unknown_state` that reports which + # happened, and the two messages differ. + replace_unknown_state "$1" || true exit 1 } @@ -510,8 +527,9 @@ jobs: # two provenance flags ex_attributable / ex_human (see the flag split below, ersatztv#742). # Factored into a function because it is now called TWICE — once here, and once immediately # before the POST (see below). An unreadable/unparseable response must NOT be read as "no - # verdict exists": the job dies WITHOUT posting, so a transient API error can never overwrite - # a verdict. + # verdict exists". Until ersatztv#849 the job simply died WITHOUT posting; it now REPLACES the + # unknown state with the sticky sentinel first, because declining to write protects a real + # verdict and leaves a FORGED one — and this job's own red status is not a required check. # # The empty case is checked EXPLICITLY, not left to jq's exit status: `jq -e` over empty input # exits 4 on jq >= 1.7 but 0 on jq 1.6, and THE RUNNER SHIPS 1.6 (ersatztv#647) — so on a @@ -613,7 +631,17 @@ jobs: replace_unknown_and_die "Could not read existing commit statuses for ${SHA:0:7} (.statuses was '${st_kind:-unparseable}', total_count '${st_total}') after a retry, so any ${CONTEXT} already on this head — including one posted by an account this gate does not accept verdicts from — can neither be read nor re-derived." fi # `// []` so the null case cannot hard-error here under `set -e` once it is accepted above. - row=$(printf '%s' "$json" | jq -r --arg c "$CONTEXT" '[(.statuses // [])[] | select(.context == $c)] | first // {}') + # TYPE-SAFE, AND A SCHEMA FAILURE REPLACES RATHER THAN DYING (ersatztv#849 round 3). + # `.statuses` being an array was checked; its ELEMENTS were not. A single scalar in that + # array makes `select(.context == ...)` hard-error, jq exits 5, and under `set -e` this + # unguarded assignment took the step down — BEFORE any of the replacement paths below, + # so a `success` already on the head stayed authoritative while the job merely went red + # on a status branch protection does not read. Same rule the post-write filter already + # applies to its own rows: drop what cannot be read, then judge what is left. + row=$(printf '%s' "$json" | jq -r --arg c "$CONTEXT" '[(.statuses // [])[] | select(type == "object") | select(.context? == $c)] | first // {}') || row="" + if [ -z "${row//[[:space:]]/}" ]; then + replace_unknown_and_die "The commit statuses for ${SHA:0:7} parsed as an array but could not be read row by row, so any ${CONTEXT} on this head can neither be read nor re-derived." + fi # THE COMPLETENESS PROBE, run only when page 1 shows no verdict — see the note above. An # unreadable or unexpected page 2 is treated as "cannot tell" and refuses, the same # direction as every other unreadable case here: concluding "no verdict exists" is what @@ -652,9 +680,13 @@ jobs: # updated_at/url, ids 14..30 ascending. `// ""` so a server that ever stopped sending it # degrades to the pre-existing text comparison rather than to a false "changed". ex_id=$(printf '%s' "$row" | jq -r 'if (.id | type) == "number" then (.id | tostring) else "" end') - ex_state=$(printf '%s' "$row" | jq -r '.status // ""') - ex_creator=$(printf '%s' "$row" | jq -r '.creator.login // ""') - ex_desc=$(printf '%s' "$row" | jq -r '.description // ""') + ex_state=$(printf '%s' "$row" | jq -r 'if (.status | type) == "string" then .status else "" end') + # `.creator.login` HARD-ERRORS on any non-object creator — the same defect #763 fixed in + # the post-write filter, still live on this read. `(.creator | type)` short-circuits it, + # so a malformed row reads as "no creator" (unattributable, hence re-derived) instead of + # killing the step after a `success` is already standing. + ex_creator=$(printf '%s' "$row" | jq -r 'if (.creator | type) == "object" then (.creator.login // "") else "" end') + ex_desc=$(printf '%s' "$row" | jq -r 'if (.description | type) == "string" then .description else "" end') # A `case` prefix test rather than grep: the description is a single short string, and this # removes one more pipeline from a security predicate entirely. The PATTERN is a literal, so # there is no glob-injection concern from $ex_desc. @@ -1273,10 +1305,29 @@ jobs: # mark", which an unrelated newer row satisfied, and it converted one anomaly into a # PERMANENT sentinel. This asks for a SPECIFIC row already known to exist, and failing it # carries the sentinel forward for THIS run only — the next run retries. - witness=$(printf '%s' "$ph_rows" | jq -r --arg c "$CONTEXT" --arg ud "$UNVERIFIED_DESC" \ - '[.[] | select(type == "object") - | select(.context? == $c) - | select(((.description | type) == "string") and (.description == $ud))] | length') || witness="" + # THE WITNESS IS THE CURRENT ROW'S ID, not merely a row with the right text + # (ersatztv#849 round 3). Matching on description alone is satisfied by an OLDER + # identical sentinel, which is precisely what a fixed point produces: with S1 and a + # buried human verdict below the CURRENT S2, a read carrying only S1 satisfies the + # witness, `buried` sees nothing, the sentinel clears, and the verdict ends up below the + # fresh mark and invisible. The record already described this as identifying a SPECIFIC + # row; the code did not. + # + # `$ex_id` COMES FROM THE COMBINED READ, so it names the row that is current right now. + # When the server omits it — not observed on this instance, where every row carries one — + # there is nothing to match on and the check degrades to the description, which is the + # pre-round-3 behaviour rather than a new hole. + if [ -n "$ex_id" ]; then + witness=$(printf '%s' "$ph_rows" | jq -r --arg c "$CONTEXT" --argjson wid "$ex_id" \ + '[.[] | select(type == "object") + | select(.context? == $c) + | select(((.id | numbers) // -1) == $wid)] | length') || witness="" + else + witness=$(printf '%s' "$ph_rows" | jq -r --arg c "$CONTEXT" --arg ud "$UNVERIFIED_DESC" \ + '[.[] | select(type == "object") + | select(.context? == $c) + | select(((.description | type) == "string") and (.description == $ud))] | length') || witness="" + fi case "$witness" in ''|*[!0-9]*) witness=0 ;; esac if [ "$ph_ok" != yes ] || [ "$witness" -eq 0 ]; then echo "::warning::Could not read a status history for ${SHA:0:7} that is both complete and contains the sentinel this head carries (complete=${ph_ok}, sentinel rows found=${witness}), so the unverified write from an earlier run still cannot be reconciled. Carrying the sentinel forward; ${CONTEXT} stays pending and no exemption is granted." @@ -1373,8 +1424,11 @@ jobs: for v in "$n_protected" "$n_not_manifest" "$n_not_docs"; do case "$v" in ''|*[!0-9]*) - echo "::error::A path predicate returned '${v}' instead of a count — the classifier is not operating, so no ${CONTEXT} status will be written for ${SHA:0:7}." - exit 1 ;; + # REPLACES, for the reason every other refusal here does (ersatztv#849 round 3): this + # run has already DECLINED to inherit whatever the head carries, so exiting without + # writing leaves that row authoritative. The old message said no status "will be + # written", which was true and beside the point — the question is what is standing. + replace_unknown_and_die "A path predicate returned '${v}' instead of a count for ${SHA:0:7}, so the classifier is not operating and this run cannot say what belongs on this head." ;; esac done @@ -1564,16 +1618,9 @@ jobs: max_id_before=-1 fi - # WITHHOLD THE EXEMPTION WHEN NOTHING CAN VERIFY IT (ersatztv#849 route 1). - # - # POSITIONED HERE, AFTER THE MARK, NOT WITH THE CLASSIFICATION. `$max_id_before` does not - # exist up there and referencing it early is an unbound variable under `set -u` — a job - # that dies before posting anything, on every PR. - # - # NOT POST-THEN-REPAIR, which is what the `success` path does when the mark DOES exist and - # the check then fires. Here the defect is known BEFORE the write, so there is no reason to - # publish a green and take it back: a repair leaves a window in which branch protection can - # see the exemption, and an already-scheduled auto-merge can fire inside it. + # MARK ANY WRITE NOTHING CAN VERIFY (ersatztv#849 route 1). The heading said "withhold the + # EXEMPTION" while the block downgrades every re-derivable state, which is narrower than + # what it does. # # EVERY RE-DERIVABLE WRITE IS DOWNGRADED, not only `success` (corrected in round 2). An # earlier version restricted this to the exemption, reasoning that a sticky generic @@ -1616,9 +1663,20 @@ jobs: # over the row — and the post-write repair does not cover that, because it is skipped # whenever the high-water mark could not be established. read_existing_verdict - if [ "$ex_attributable" = yes ] \ - && { [ "$ex_state" != "$pre_state" ] || [ "$ex_creator" != "$pre_creator" ] \ - || [ "$ex_desc" != "$pre_desc" ] || [ "$ex_id" != "$pre_id" ]; }; then + # `$row_replaced` — "the row changed", computed ONCE so both mid-run guards ask the same + # question. The id is evidence of a REPLACEMENT only when BOTH reads supplied one: if one + # response omits `id` and the other includes it, a bare inequality reports a replacement + # that did not happen, and this guard's reaction to that is to abstain — leaving a row the + # classification had already declined to inherit. One-sided absence therefore falls back to + # the triple, which is exactly the pre-existing behaviour. + row_replaced=no + if [ "$ex_state" != "$pre_state" ] || [ "$ex_creator" != "$pre_creator" ] || [ "$ex_desc" != "$pre_desc" ]; then + row_replaced=yes + fi + if [ -n "$ex_id" ] && [ -n "$pre_id" ] && [ "$ex_id" != "$pre_id" ]; then + row_replaced=yes + fi + if [ "$ex_attributable" = yes ] && [ "$row_replaced" = yes ]; then echo "::notice::An attributable verdict ('${ex_state}' by '${ex_creator}') landed on ${SHA:0:7} while this job was classifying — leaving it alone and posting nothing." exit 0 fi @@ -1666,8 +1724,7 @@ jobs: # # A run whose own write IS a sentinel is exempt from the guard: replacing a sentinel with a # sentinel loses nothing, and the repair sentinel outranks this one. - if [ "$ex_unverified" = yes ] \ - && { [ "$ex_desc" != "$pre_desc" ] || [ "$ex_id" != "$pre_id" ]; } \ + if [ "$ex_unverified" = yes ] && [ "$row_replaced" = yes ] \ && [ "$desc" != "$REPAIR_DESC" ] && [ "$desc" != "$UNVERIFIED_DESC" ]; then echo "::notice::An unverified-write sentinel was written on ${SHA:0:7} while this job was classifying, so another run posted something it could not check. This run would overwrite that record with an unmarked status a later run could re-derive into an exemption — posting NOTHING and leaving the sentinel standing." exit 0 @@ -1692,14 +1749,35 @@ jobs: # prevent, while `pending` blocks the merge immediately. # # "FOR NO SAFETY GAIN" WAS TOO STRONG and is retracted (ersatztv#742 review, #849). A - # GENERIC `pending` is not inert: it masks a rejection landing in its own write window, and - # post-write verification never runs for it, so a LATER run re-derives it into an exemption - # `success` with that rejection below the new mark. Letting it through is still right — the - # alternative strands every PR whenever the timeline is unreadable — but the trade is - # "immediate block, later re-derivation risk", not "free". + # GENERIC `pending` is not inert: it masks a rejection landing in its own write window. What + # made that DURABLE — post-write verification skipping it, so a later run re-derived it into + # an exemption `success` with the rejection below the new mark — is closed: the check now + # runs after every write, and a write no high-water mark can cover becomes the sentinel + # instead of a re-derivable description. Letting `pending` through here is still right — the + # alternative strands every PR whenever the timeline is unreadable — but the masking itself + # remains a cost, so the trade is "immediate block, one repaired write", not "free". + # ABSTAINING IS A HANDOFF ONLY WHEN THERE IS NOTHING TO HAND OFF (ersatztv#849 round 3). + # The two arms below are right not to write their CLASSIFICATION — it was computed against + # a base or a head the PR may no longer have, and the mutation that invalidated it has + # already queued a successor. But when this run DECLINED to inherit a row the head carries, + # posting nothing leaves that row authoritative for the whole window until the successor + # finishes, and the successor is not guaranteed in the one case the head-arm's own message + # names: a PR's FIRST push fires no `synchronize`. + # + # So the arms mark the head instead of merely leaving it. The sentinel is not a + # classification — it asserts nothing about the diff, only that this head carries something + # unverified — so writing it does not reintroduce what the fence exists to prevent. + # Scoped to `pre_state` being non-empty: on a head that carried nothing, abstaining leaves + # nothing, and a write there would be noise on the commonest path in this job. + mark_declined_row_if_any() { # $1 = the ::notice:: this arm already emitted + if [ -z "$pre_state" ]; then return 0; fi + replace_unknown_state "$1 This head already carried a ${CONTEXT} that this run declined to inherit, so leaving it alone would keep it authoritative until a successor finishes — and for a PR's FIRST push no successor is queued at all." || return 1 + return 0 + } count_pr_mutations if [ "$retargets_before_ok" = yes ] && [ "$rt_ok" = yes ] && [ "$rt_count" -ne "$retargets_before" ]; then - echo "::notice::PR #${PR} was retargeted while this job was classifying (${retargets_before} -> ${rt_count} retarget events). This run's classification was computed against a base the PR may no longer target, so it posts NOTHING. The retarget fired an 'edited' event, so a successor run is already queued and will write the authoritative status for ${SHA:0:7}." + echo "::notice::PR #${PR} was retargeted while this job was classifying (${retargets_before} -> ${rt_count} retarget events). This run's classification was computed against a base the PR may no longer target, so it does NOT post that classification. The retarget fired an 'edited' event, so a successor run is already queued and will write the authoritative status for ${SHA:0:7}." + mark_declined_row_if_any "PR #${PR} was retargeted while this job was classifying." || exit 1 exit 0 fi # THE HEAD AXIS (ersatztv#803/#664). Separate arm, separate message, same rule — see the @@ -1713,7 +1791,8 @@ jobs: # and it is why the enumerator's contract now says "detects one-way movement" rather than # "bound to one head". if [ "$retargets_before_ok" = yes ] && [ "$rt_ok" = yes ] && [ "$hp_count" -ne "$pushes_before" ]; then - echo "::notice::PR #${PR}'s head branch was pushed while this job was classifying (${pushes_before} -> ${hp_count} push events). The file enumeration behind this run's classification may have been paged across more than one head, and a force-push that RESTORED ${SHA:0:7} would leave every sha comparison equal, so this run posts NOTHING. A push to an OPEN PR fires 'synchronize', so a successor run is normally already queued and will write the authoritative status for whatever head is current — with one exception worth knowing before you conclude this PR is stuck: a PR's FIRST push is recorded about a second after creation and fires no 'synchronize', so an 'opened' run that abstained on it has no successor coming and the PR needs a re-trigger: a push, or a title/body/base edit. A label or a comment fires none of this workflow's triggers and will NOT re-trigger it." + echo "::notice::PR #${PR}'s head branch was pushed while this job was classifying (${pushes_before} -> ${hp_count} push events). The file enumeration behind this run's classification may have been paged across more than one head, and a force-push that RESTORED ${SHA:0:7} would leave every sha comparison equal, so this run does NOT post that classification. A push to an OPEN PR fires 'synchronize', so a successor run is normally already queued and will write the authoritative status for whatever head is current — with one exception worth knowing before you conclude this PR is stuck: a PR's FIRST push is recorded about a second after creation and fires no 'synchronize', so an 'opened' run that abstained on it has no successor coming and the PR needs a re-trigger: a push, or a title/body/base edit. A label or a comment fires none of this workflow's triggers and will NOT re-trigger it." + mark_declined_row_if_any "PR #${PR}'s head branch was pushed while this job was classifying." || exit 1 exit 0 fi if { [ "$retargets_before_ok" != yes ] || [ "$rt_ok" != yes ]; } && [ "$state" = "success" ]; then @@ -1729,8 +1808,12 @@ jobs: # established (this run could not verify its own classification), blocks the merge, and # is cleared by the next run that can read the timeline — so a transient outage costs one # event rather than a human verdict. - replace_unknown_state "Could not establish a trusted retarget/push count for PR #${PR} (before=${retargets_before_ok}, after=${rt_ok}), so an exemption 'success' cannot be shown to have been computed against the PR's current base, nor at a single head. NOTE a later run only helps if the cause was transient — a PR whose timeline exceeds the page cap will fail this way on every run, and needs a human verdict." - exit 0 + if replace_unknown_state "Could not establish a trusted retarget/push count for PR #${PR} (before=${retargets_before_ok}, after=${rt_ok}), so an exemption 'success' cannot be shown to have been computed against the PR's current base, nor at a single head. NOTE a later run only helps if the cause was transient — a PR whose timeline exceeds the page cap will fail this way on every run, and needs a human verdict."; then + exit 0 + fi + # THE SENTINEL WRITE FAILED, so nothing marked this head and whatever it carries is still + # authoritative. Exiting 0 here would report an abstention that did not happen. + exit 1 fi payload=$(jq -n --arg s "$state" --arg c "$CONTEXT" --arg d "$desc" --arg u "$PR_URL" \ @@ -1843,7 +1926,10 @@ jobs: # the next run instead of requiring a human. Before this the uncertainty arms wrote # `REPAIR_DESC` and then had to soften their own message to "no verdict was necessarily # overwritten", i.e. the description and the log contradicted each other. - raced_why=human + # `raced_why` IS OPERATOR-FACING, so its initial value is a sentence rather than the bare + # token `human`. The skip below prints it, and a log line reading "(human)" says nothing + # to whoever has to act on it. + raced_why="a human verdict landed in the write window" repair_desc="$REPAIR_DESC" if [ "$ph_ok" = yes ] && [ "$ph_len_after" -eq 0 ]; then echo "::warning::The status history for ${SHA:0:7} came back empty after this job posted to it, which cannot be true, so a raced verdict could not be ruled out. Repairing ${CONTEXT} to pending." @@ -1908,8 +1994,10 @@ jobs: # Counting the sentinel closes it: A repairs, and both runs converge on the fixed point. # # It cannot false-fire. A sentinel that already existed would have been seen at the FIRST - # read, forcing the pending path, and this block only runs after a `success` — so a - # sentinel ABOVE the mark can only have been written by another run mid-flight. + # read, forcing the pending path — so a sentinel ABOVE the mark can only have been + # written by another run mid-flight. (This block runs after EVERY write since #849; the + # argument never depended on the state, only on what a sentinel at the FIRST read + # forces, and `$own` excludes this run's own row.) # `.creator` IS TYPE-TESTED BEFORE IT IS INDEXED, and the substitution is guarded. # `.creator != null and .creator.login` hard-errors ("Cannot index number with string") # on a row whose `creator` is any non-object, jq exits 5, and under `set -e` the @@ -2033,7 +2121,15 @@ jobs: # floor above pins `repair_desc` to this run's own description: without this the job # POSTs a duplicate row and logs "Repaired …" for a change that did not happen. if [ "$raced" -gt 0 ] && [ "$repair_desc" = "$desc" ]; then - echo "::notice::Post-write verification for ${SHA:0:7} could not clear the write window (${raced_why}), but ${CONTEXT} already carries the description the repair would write, so nothing is re-posted." + # THE HUMAN CASE STILL SCREAMS. Skipping the POST is right — the head already carries + # the strongest marker this job writes — but the `::error::` below is the only place a + # reviewer is told their verdict was buried and given the command to re-post it, and an + # early `raced=0` used to swallow it. What is skipped is the WRITE, not the report. + if [ "$raced_why" = "a human verdict landed in the write window" ]; then + echo "::error::A human ${CONTEXT} verdict landed on ${SHA:0:7} while this job was writing its '${state}' status, and was overwritten. ${CONTEXT} already carries the strongest marker this job writes, so nothing is re-posted — but the verdict itself still needs re-posting: scripts/post-review-verdict.sh ${PR} " + else + echo "::notice::Post-write verification for ${SHA:0:7} could not clear the write window (${raced_why}), and the repair would write the description this run already POSTed, so nothing is re-posted. If another run has written since, its row stands — a convergence on its answer, not a silent loss: both sentinels block the merge." + fi raced=0 fi if [ "$raced" -gt 0 ]; then @@ -2047,7 +2143,7 @@ jobs: # STATE-NEUTRAL WORDING since ersatztv#849: this block now also runs after a `pending` # write, so a message naming "this exemption write" would be wrong on the very path # that was added, and wrong in the direction of understating what happened. - if [ "$raced_why" = human ]; then + if [ "$raced_why" = "a human verdict landed in the write window" ]; then echo "::error::A human ${CONTEXT} verdict landed on ${SHA:0:7} while this job was writing its '${state}' status, and was overwritten. Downgrading to 'pending' so an explicit human decision cannot be silently green, and marking the head so a later run cannot re-derive it. Re-post it with: scripts/post-review-verdict.sh ${PR} " else echo "::error::Could not verify that no human ${CONTEXT} verdict raced this job's '${state}' write on ${SHA:0:7} — ${raced_why}. Downgrading to 'pending' rather than leaving an unverified write standing; no verdict was necessarily overwritten. Clear it with: scripts/post-review-verdict.sh ${PR} " diff --git a/docs/ci-cd.md b/docs/ci-cd.md index df5233d8a..0be8aaf80 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -1690,7 +1690,7 @@ payload, which a retarget cannot rewrite, and `edited` is in `types:` so a retar `edited` gives **detection, not atomicity**: runs are not serialized, so a stale run could still post `success` after the reclassifying run posted `pending`. -**That residual is now fenced — NARROWED, not resolved (ersatztv#706; correction ersatztv#849).** The fence never re-counts *after* the POST, so a retarget landing between its final pre-write count and the write still yields a **permanent** forged green: the successor run consumes the `edited` event and exits on the existing status, and the stale run then posts last with nothing left to correct it. Runs are still not serialized — instead a run that was +**That residual is now fenced — NARROWED, not resolved (ersatztv#706; correction ersatztv#849).** The pre-write fence does not re-count *after* the POST, so a retarget landing between its final pre-write count and the write used to yield a **permanent** forged green: the successor run consumes the `edited` event and exits on the existing status, and the stale run then posts last with nothing left to correct it. Since ersatztv#849 a SEPARATE post-POST re-count (below) makes that green transient rather than permanent. Runs are still not serialized — instead a run that was overtaken *declines to write*. The job counts `change_target_branch` events on the PR's issue timeline at start and again immediately before its POST, and posts **nothing** if the count moved. The count is the key precisely because the branch *name* is ABA-vulnerable: `main → scratch → main` reads `main` at @@ -1792,9 +1792,10 @@ made the shape reachable. The cost lands where the replacement actually cost som APPROVED PR, reconciliation finds the masked verdict and upgrades to the repair sentinel, so the recovery is "you are asked to re-post", not "it clears itself". -**Both `/statuses/{sha}` reads are PAGED** (ersatztv#763). The history is read twice — before the write -for the high-water mark, after it for the race check — and `limit` clamps to `MAX_RESPONSE_ITEMS` -(measured 50), so a single read of a busy head returned a partial list. +**Every `/statuses/{sha}` read is PAGED** (ersatztv#763). The history is read twice on an ordinary +run — before the write for the high-water mark, after it for the race check — and a THIRD time on a +head carrying the unverified sentinel, for the reconciliation (ersatztv#849). `limit` clamps to +`MAX_RESPONSE_ITEMS` (measured 50), so a single read of a busy head returned a partial list. **Be precise about what that cost** — ersatztv#763's framing of it is too strong. Under the server default (`created_unix DESC`) page 1 holds the *newest* rows and ids are monotonic with `created_at`, diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 1c6b7490c..104146e99 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -68,7 +68,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `ci.small-lane-git-only` | `runs-on: small` is defined by what a job does (git-only), not its usual runtime; the two `docker build` jobs (docker-build.yml, ci-image.yml) move to `ubuntu-latest` because their worst-case memory, not median runtime, was pinning the small lane's per-slot cap. | 2026-07-20 | [link](records/ci/small-lane-git-only.md) | | `ci.toolchain-image-publish-is-a-dispatch` | A `push` trigger reachable from any ref other than `main` — that is `branches:` AND `tags:`, judged by the ref class it admits and never by which keyword is present — executes ref-supplied YAML, because Gitea resolves a `push` workflow's definition from the pushed ref — so `ci-image.yml` is `branches: [main]`, and publishing a toolchain image from a feature branch is a deliberate `workflow_dispatch` on that branch rather than a side effect of pushing. Be precise about what a `branches:` filter buys: it is loaded from the pushed ref like the rest of the file, so a branch that deletes it re-enables the route — this removes the DRIVE-BY case, and is not a boundary against a writer who intends to run their own YAML. The self-referencing trigger path `.gitea/workflows/ci-image.yml` came out of BOTH its own `paths:` and `ci-image-pin`'s `expected` in the same change — a DECIDED tradeoff, not a necessity: keeping it is workable via the branch dispatch, but prices every edit to that file, comments included, at a ~2GB publish plus a five-pin bump, redone after every rebase. The cost is stated, not assumed away — a change to HOW the image is built that lives only in `ci-image.yml` no longer republishes, and the ONLY remedy is to make it alongside a `docker/ci/**` edit: publishing after the merge and then pinning cannot work, because `expected` is the last `docker/ci` commit and would reject that pin. This closes the push route INTO THIS FILE, not the class: `docker-build.yml` remains reachable from an arbitrary ref by a `v*` tag push and by `pull_request`, and four workflows carry an unrestricted `workflow_dispatch`. The DISPATCH THIRD of that is settled — #853 probed it and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`): no ref restriction exists at Gitea 1.27.1, and restricting it would close nothing anyway, because the head-resolved `pull_request:` route runs attacker-authored YAML that reaches every secret in the store. The `v*` tag push and `pull_request:` rows are NOT settled and remain open in #885. Do not re-derive any of this. | 2026-08-27 | [link](records/ci/toolchain-image-publish-is-a-dispatch.md) | | `ci.ui-e2e-harness` | The UI-interactive E2E flows run as headless Playwright specs (`web/e2e/*.spec.ts`, driven by `scripts/e2e-ui.sh`) in a **second step of the existing advisory `functional-e2e` job**, never their own job; the browser is `chromium-headless-shell` **baked into the CI toolchain image** (`docker/ci/Dockerfile`, `PLAYWRIGHT_VERSION` kept equal to `web/package.json`'s EXACT `@playwright/test` pin), never installed per run; specs are `serial` with `retries: 0` and assert only contracts the curl harness structurally cannot reach. | 2026-07-25 | [link](records/ci/ui-e2e-harness.md) | -| `ci.verdict-unverified-write-sentinel` | The `review-verdict/h10` job runs its post-write race check after EVERY status write, not only an exemption `success`, and where no mark exists to run it against, the write ITSELF becomes the sentinel — so every status this job writes is either verified against the history or marked as unverifiable: a GENERIC `pending` masks a rejection landing in its own write window exactly as a `success` does, and because it carries no marker a later run re-derives it into an exemption with the human row now below THAT run's high-water mark — the damage arrives one event later, not never. Where a write cannot be checked at all the job writes a SECOND sentinel, `UNVERIFIED_DESC`, distinct from the repair sentinel and never interchangeable with it: the repair sentinel ASSERTS that a verdict existed and was buried, which only the arm that actually COUNTED such a row may claim, while every "could not check" arm — an unreadable or over-cap history, an impossible empty history, an unusable count — states only what it established. Both are STICKY (the classification refuses to grant an exemption over either, and re-writes its own description verbatim, so each is a FIXED POINT a later run cannot re-derive into `success`); only the unverified one is RECONCILABLE. Reconciliation is what bounds the stall that got the #742 attempt withdrawn: a later run pages `/statuses/{sha}` IN FULL and, ONLY IF that history contains the sentinel's own row, either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it UPGRADES to the repair sentinel, clearable only by a human — or finds none, resolving the uncertainty and clearing it so the PR is classified normally. The reconciliation is sound because the two endpoints differ: a verdict this job masked is invisible on the COMBINED endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. It is deliberately BROADER than the inheritance test — no `H10_REVIEWERS` membership, no `(base: …)` match — because over-counting upgrades to a stall a human clears in one command while MISSING one re-exempts a head carrying a buried rejection. ONLY a COMPLETE walk CONTAINING THE SENTINEL ROW may clear it. The witness is not optional decoration: `ex_unverified` means the combined endpoint just returned that row, and `/statuses/{sha}` keeps one row per POST, so a complete history WITHOUT it — an empty one included — contradicts a write that demonstrably happened. `page_statuses` accepts an empty page 1 as complete (correct for a first-run mark), which is what made that shape reachable, and clearing on it exempted a head whose sentinel may have been sitting on a rejection. This is NOT the withdrawn currency witness: that asked whether ANY row sat above the mark, which an unrelated row satisfied, and it made one anomaly permanent — this asks for a SPECIFIC row already known to exist, and failing it costs one run rather than requiring a human. When NO high-water mark can be established the write is withheld BEFORE the POST rather than posted and repaired, since the defect is known in advance and publishing a green to take it back opens a window branch protection — and an already-scheduled auto-merge — can see. EVERY re-derivable state is downgraded there, not only `success`: an earlier draft restricted it to the exemption on the grounds that a sticky generic `pending` "withholds nothing, since an unreviewed PR is blocked already", which analysed the wrong PR — the damaging case is one that IS exemptible and got the generic `pending` only from a transient enumeration failure, whose unmarked description the next run re-derives into the exemption with the human row below its own mark. `$REPAIR_DESC` is the one exemption, being the stronger fact and not re-derivable. SEPARATELY, the retarget count is re-taken AFTER the POST on the exemption path, which closes the PERMANENT forged green `ci.verdict-write-retarget-fence` recorded as its residual: a retarget landing after the final pre-write count leaves a stale `success` with the `edited` event already consumed by a successor that short-circuited, so nothing remains to reclassify. Re-counting afterwards means a retarget later than the re-check necessarily queues a successor that STARTS after the stale `success` exists, and a machine-written `success` is re-derived rather than inherited. The RETARGET axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. An untrusted post-POST count repairs too — reaching that point with `success` means both earlier counts were trusted, so a third unreadable read is a fresh failure and "I cannot tell whether the base moved" must not resolve to leaving a green. FINALLY, every path that cannot establish what the head carries REPLACES the unknown state instead of merely declining to write — the combined read (retried once first), all four page-2 completeness refusals, and the fence branch that cannot trust its retarget count while holding a derived `success`. That last one is reached only AFTER the classification declined to inherit the row the head carries, so posting nothing left the declined row current; its message used to say the context "stays absent", which is true only of a head that had none. Declining protects a real verdict and leaves a FORGED one — an off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving it, and an unreadable read is the one thing that stops it while the job goes red on a status branch protection does not read. Nothing is destroyed: the per-POST history keeps the masked verdict and the next run's reconciliation upgrades to the repair sentinel, telling the reviewer to re-post rather than silently un-approving them. The page-2 refusals were briefly excluded on the reasoning that the probe fires when NO row for this context was on page 1, so there is no green of any provenance to leave standing — self-contradictory, since the ONLY reason page 2 is read is that the row MAY be beyond page 1. WRITING THE SENTINEL AND FAILING THE JOB ARE SEPARATE DECISIONS: the read refusals were already non-zero exits on `main` and stay red, while the fence branch exited 0 there and still does, because an unreadable timeline is an ordinary hiccup and reddening every one of them is noise this file elsewhere refuses to add. The repair also has a FLOOR — it may never write a description weaker than the one this run decided, or a transient post-write read rewrites a correct `$REPAIR_DESC` carry-forward with the machine-clearable sentinel — and it is skipped entirely when it would rewrite what is already there. | 2026-08-29 | [link](records/ci/verdict-unverified-write-sentinel.md) | +| `ci.verdict-unverified-write-sentinel` | The `review-verdict/h10` job runs its post-write race check after EVERY status write, not only an exemption `success`, and where no mark exists to run it against, the write ITSELF becomes the sentinel — so every status this job writes is either verified against the history or marked as unverifiable: a GENERIC `pending` masks a rejection landing in its own write window exactly as a `success` does, and because it carries no marker a later run re-derives it into an exemption with the human row now below THAT run's high-water mark — the damage arrives one event later, not never. Where a write cannot be checked at all the job writes a SECOND sentinel, `UNVERIFIED_DESC`, distinct from the repair sentinel and never interchangeable with it: the repair sentinel ASSERTS that a verdict existed and was buried, which only the arm that actually COUNTED such a row may claim, while every "could not check" arm — an unreadable or over-cap history, an impossible empty history, an unusable count — states only what it established. Both are STICKY (the classification refuses to grant an exemption over either, and re-writes its own description verbatim, so each is a FIXED POINT a later run cannot re-derive into `success`); only the unverified one is RECONCILABLE. Reconciliation is what bounds the stall that got the #742 attempt withdrawn: a later run pages `/statuses/{sha}` IN FULL and, ONLY IF that history contains the sentinel's own row, either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it UPGRADES to the repair sentinel, clearable only by a human — or finds none, resolving the uncertainty and clearing it so the PR is classified normally. The reconciliation is sound because the two endpoints differ: a verdict this job masked is invisible on the COMBINED endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. It is deliberately BROADER than the inheritance test — no `H10_REVIEWERS` membership, no `(base: …)` match — because over-counting upgrades to a stall a human clears in one command while MISSING one re-exempts a head carrying a buried rejection. ONLY a COMPLETE walk CONTAINING THE CURRENT SENTINEL ROW — matched by the `id` the combined read reported, not merely by a row with the same description — may clear it. Description alone is satisfied by an OLDER identical sentinel, which is exactly what a fixed point produces: with an earlier sentinel and a buried verdict below the current one, a read carrying only the earlier row satisfies the test, the sentinel clears, and the verdict ends up below the fresh mark. Where the server omits `id` there is nothing to match on and the check degrades to the description, which is the pre-existing behaviour rather than a new hole. The witness is not optional decoration: `ex_unverified` means the combined endpoint just returned that row, and `/statuses/{sha}` keeps one row per POST, so a complete history WITHOUT it — an empty one included — contradicts a write that demonstrably happened. `page_statuses` accepts an empty page 1 as complete (correct for a first-run mark), which is what made that shape reachable, and clearing on it exempted a head whose sentinel may have been sitting on a rejection. This is NOT the withdrawn currency witness: that asked whether ANY row sat above the mark, which an unrelated row satisfied, and it made one anomaly permanent — this asks for a SPECIFIC row already known to exist. Failing it costs one run in the transient case; it is NOT bounded in general, because a history past the 20-page cap can never be walked completely, so such a head needs a human verdict and no later run will clear it. When NO high-water mark can be established the write is withheld BEFORE the POST rather than posted and repaired, since the defect is known in advance and publishing a green to take it back opens a window branch protection — and an already-scheduled auto-merge — can see. EVERY re-derivable state is downgraded there, not only `success`: an earlier draft restricted it to the exemption on the grounds that a sticky generic `pending` "withholds nothing, since an unreviewed PR is blocked already", which analysed the wrong PR — the damaging case is one that IS exemptible and got the generic `pending` only from a transient enumeration failure, whose unmarked description the next run re-derives into the exemption with the human row below its own mark. `$REPAIR_DESC` is the one exemption, being the stronger fact and not re-derivable. SEPARATELY, the retarget count is re-taken AFTER the POST on the exemption path, which closes the PERMANENT forged green `ci.verdict-write-retarget-fence` recorded as its residual: a retarget landing after the final pre-write count leaves a stale `success` with the `edited` event already consumed by a successor that short-circuited, so nothing remains to reclassify. Re-counting afterwards means a retarget later than the re-check necessarily queues a successor that STARTS after the stale `success` exists, and a machine-written `success` is re-derived rather than inherited. The RETARGET axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. An untrusted post-POST count repairs too — reaching that point with `success` means both earlier counts were trusted, so a third unreadable read is a fresh failure and "I cannot tell whether the base moved" must not resolve to leaving a green. FINALLY, every path that cannot establish what the head carries REPLACES the unknown state instead of merely declining to write — the combined read (retried once first), all four page-2 completeness refusals, and the fence branch that cannot trust its retarget count while holding a derived `success`. That last one is reached only AFTER the classification declined to inherit the row the head carries, so posting nothing left the declined row current; its message used to say the context "stays absent", which is true only of a head that had none. The two OBSERVED-mutation arms take the same decision for the same reason, scoped to a head that actually carries a declined row: they still refuse to post their CLASSIFICATION, which was computed against a base or head the PR may no longer have, but a row this run declined must not stay authoritative for the whole window until a successor finishes — and for a PR's FIRST push no successor is queued at all. Every element and every consumed field of the combined response is TYPE-CHECKED before extraction, and a schema failure routes to the same replacement rather than dying: `.statuses` being an array was checked and its ELEMENTS were not, so one scalar made `select(.context == $c)` hard-error and `set -e` took the step down before any path could mark the head. The path-predicate failure replaces too. And the write helper RETURNS a status: its first version ended the failure arm with a successful `echo`, so it reported 0 after both POSTs failed and the fence caller exited 0 as though the head had been marked. Declining protects a real verdict and leaves a FORGED one — an off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving it, and an unreadable read is the one thing that stops it while the job goes red on a status branch protection does not read. Nothing is destroyed: the per-POST history keeps the masked verdict and the next run's reconciliation upgrades to the repair sentinel, telling the reviewer to re-post rather than silently un-approving them. The page-2 refusals were briefly excluded on the reasoning that the probe fires when NO row for this context was on page 1, so there is no green of any provenance to leave standing — self-contradictory, since the ONLY reason page 2 is read is that the row MAY be beyond page 1. WRITING THE SENTINEL AND FAILING THE JOB ARE SEPARATE DECISIONS: the read refusals were already non-zero exits on `main` and stay red, while the fence branch exited 0 there and still does, because an unreadable timeline is an ordinary hiccup and reddening every one of them is noise this file elsewhere refuses to add. The repair also has a FLOOR — it may never write a description weaker than the one this run decided, or a transient post-write read rewrites a correct `$REPAIR_DESC` carry-forward with the machine-clearable sentinel — and it is skipped entirely when it would rewrite what is already there. | 2026-08-29 | [link](records/ci/verdict-unverified-write-sentinel.md) | | `ci.verdict-write-retarget-fence` | The `review-verdict/h10` job counts BOTH `change_target_branch` AND `pull_push` events on the PR's issue timeline at run start and again immediately before its POST, and writes NOTHING if EITHER count moved. The COUNT is the key on both axes because the underlying VALUE is ABA-vulnerable — `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a forged exemption, and a force-push `H1 -> H2 -> H1` leaves `.head.sha` equal at both ends while the middle pages of `scripts/pr-changed-files.sh`'s enumeration came from `H2` (#803/#664) — while an event count is monotonic and cannot alias. ONE walk certifies BOTH counts, so an unreadable page abandons both; the two tallies are separate so the diagnostic names the axis that actually moved. Every push is counted and `is_force_push` is deliberately NOT read: an ordinary push also invalidates a mid-flight enumeration, and an `H1 -> H2 -> H1` restoration can have its second push non-forced when `H1` is an ancestor. The head fence does NOT abstain on its own triggering push — established FROM THE v1.27.1 SOURCE, since that would be a permanent stall rather than a fence: the push comment is created BEFORE the synchronize notification is emitted, so a run always sees its own causative event, and retries add no new event. The 26-102s margin measured across 20 triggered pairs on PRs #802/#834/#761 corroborates it and is a lower bound (the job runs a checkout first; 69s end to end on run 2385) — it is NOT the basis of the claim, which an earlier draft said it was. This NARROWS the residual rather than resolving it, and what CLOSED the permanent case is a separate mechanism recorded at `ci.verdict-unverified-write-sentinel`: the retarget count is re-taken AFTER the POST, so a retarget between the final pre-write count and the POST — which used to leave a PERMANENT forged green, the successor having consumed the `edited` event and exited before the stale run posted last — is now caught by the writing run itself, and one landing after the re-check necessarily queues a successor that starts with the stale `success` already visible and re-derivable (corrected 2026-08-27, closed 2026-08-29, #849). Abstaining is a handoff, not a stall, and that is the property the design rests on — for a run that ABSTAINS; it says nothing about one that already passed its final count and then posts (see the residual, corrected #849): 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 — but ONLY over runs that ABSTAIN. A run that already passed its final count is outside it: it writes whenever it gets there, so the run that writes LAST is not necessarily the one that classified last. That was the permanent residual until #849 gave the writing run a post-POST re-count of its own (`ci.verdict-unverified-write-sentinel`), which puts such a run back inside the induction — it either withdraws its own stale write or leaves a green a guaranteed successor re-derives. `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` blocks the merge immediately while withholding it would strand ordinary PRs whenever the timeline is unreadable — the right trade, but NOT a free one ("for no safety gain" retracted 2026-08-27): a GENERIC `pending` masks a rejection landing in its own write window just as a `success` does. What made that DURABLE — post-write verification skipping it, so a later run re-derived it into an exemption `success` — is closed since 2026-08-29: the check now runs after EVERY write (`ci.verdict-unverified-write-sentinel`), so such a write is repaired to the sticky repair sentinel and cannot be re-derived. The masking itself is still a cost, and a write the mark cannot cover is still unverified; that is what the second sentinel is for. SEPARATELY, and for the human-verdict race the fence does nothing about: after posting ANY status — every write since #849, not only an exemption `success` — the job re-reads `/statuses/{sha}` IN FULL (PAGED since #763) 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. BOTH `/statuses/{sha}` reads PAGE to a validated EMPTY page since #763 — `[]` on this endpoint, a THIRD terminator shape distinct from the timeline's bare `null` and the combined endpoint's `{"statuses": null}`, so each terminator is MEASURED per endpoint — never terminating on a SHORT page, retrying each page once, under a 20-page cap that validates at most 950 rows (the 20th request must be the empty terminator); correctness does not depend on the measured cap of 50, because any page size pages correctly. WHAT THE PAGING BUYS IS NOT WHAT #763 CLAIMED: under the DESC default page 1 already held the true maximum id AND every row newer than the mark, so a single-page read missed a raced verdict only if more than 50 rows were created INSIDE the write window — not merely on a head over 50 rows. What removed #761's stall is retiring the probe, not the walk; the walk's value is that the gate's one fail-toward-SUCCESS path no longer depends on an UNDOCUMENTED ordering the server honours only coarsely (page 1 came back `114,112,113,111,110`). This RETIRED #751's conservative page-2 probe, which treated "there are rows I did not read" as "assume raced" and so repaired every head that outgrew one page: it fired on Renovate PR #761, reporting a human verdict as overwritten when none existed and then, the sentinel being sticky, refusing to re-exempt that head on every later run. The two directions are NOT symmetric. POST-WRITE, uncertainty fails CLOSED — an unreadable history, an over-cap history, or a count that is not a number all repair to `pending`; previously an unreadable history warned and left the exemption green while the page-2 probe repaired on the same uncertainty, one check disagreeing with itself. PRE-WRITE, a PARTIAL list still yields a mark, because the mark gates the post-write check entirely and refusing one SKIPS that check, which is itself the fail-open; this rests on the DESC DEFAULT — the newest row, carrying the maximum id, is on page 1, so a walk that fails later still saw it — while a VALIDATED empty history is NOT abandoned (it yields a mark of 0, correct for a first run, since every later row is newer) — what abandons the mark is a read that both FAILED and returned nothing — which since 2026-08-29 WITHHOLDS the exemption before the POST and marks the head with the reconcilable sentinel, rather than posting a green nothing can check (#849) — and a NON-EMPTY history carrying no numeric id is reported unusable rather than collapsed to 0. BOTH id comparisons are NUMERIC-ONLY: jq orders strings above every number, so one `"id": "99999"` inflates the mark until nothing looks newer, and `.id > $since` reads any string id as newer than any mark — making a PRE-EXISTING base-mismatched verdict look raced on every run, a permanent per-sha stall (the twin was live on `main`). An EMPTY post-write history is REJECTED: the walk terminates on an empty page, which is correct before the write and impossible after it (one row per POST), and a well-formed "no statuses exist" is not retried — so accepting it would conclude `raced=0` from a list that cannot be real, silently. That is NOT the withdrawn currency witness, which asked whether ANY row sat above the mark and was satisfied by an unrelated newer row; this asks only whether the list is EMPTY, a state no unrelated row can produce. The `::error::` now names its own cause, of which there are THREE — a verdict actually FOUND, a read that could not be COMPLETED, and a read that completed but returned an IMPOSSIBLE answer (the third is not a variety of the second) — while WHICH sentinel description is written is itself part of the answer since #849: only an arm that actually COUNTED a verdict row may write the repair sentinel, and every "could not check" arm writes the reconcilable one (`ci.verdict-unverified-write-sentinel`). Both are fixed points the classification recognises. `.creator` is TYPE-TESTED before it is indexed: `.creator != null and .creator.login` hard-errors on a non-object creator, jq exits 5, and under `set -e` that took the step down AFTER the exemption was posted and BEFORE the repair — a forged green reported as an infrastructure error. | 2026-08-03 | [link](records/ci/verdict-write-retarget-fence.md) | | `ci.verify-locally-ci-confirms` | Treat the local build/verify/review pass as the decision point and CI as confirmation — don't idle waiting on a run you have no reason to doubt. | 2026-07-21 | [link](records/ci/verify-locally-ci-confirms.md) | | `ci.web-test-per-test-timeouts` | Give heavy-render web tests an explicit per-test vitest timeout (e.g. 15s); never raise the global default to fix one slow test. | 2026-07-21 | [link](records/ci/web-test-per-test-timeouts.md) | diff --git a/docs/decisions/records/ci/exemption-provenance.md b/docs/decisions/records/ci/exemption-provenance.md index 286ae286b..87c0e219c 100644 --- a/docs/decisions/records/ci/exemption-provenance.md +++ b/docs/decisions/records/ci/exemption-provenance.md @@ -38,7 +38,7 @@ than described as fixed. `edited` and re-derivation remain one fix — `edited` on the existing `success`; re-derivation alone never gets a second run — but together they are mitigation, not a guarantee. -**Narrowed 2026-08-03 (#706) — an earlier heading here said RESOLVED, corrected 2026-08-27 (#849): the fence never re-counts after its final pre-write read, so the post-final-count/pre-POST window remains, and is PERMANENT rather than transient. Worth recording that the narrowing finally came from somewhere else +**Narrowed 2026-08-03 (#706) — an earlier heading here said RESOLVED, corrected 2026-08-27 (#849): the pre-write fence does not re-count after its final read, so the post-final-count/pre-POST window remains. It was PERMANENT rather than transient until #849 added a post-POST re-count (`ci.verdict-unverified-write-sentinel`), which makes it transient — the window between the POST and the repair — rather than closing it. Worth recording that the narrowing finally came from somewhere else entirely.** The missing piece was never ordering: `ci.verdict-write-retarget-fence` leaves the runs as unserialized as they ever were and instead makes a run that was overtaken decline to write, keyed on the timeline's monotonic retarget COUNT — the one signal the `main → scratch → main` ABA cannot make diff --git a/docs/decisions/records/ci/verdict-unverified-write-sentinel.md b/docs/decisions/records/ci/verdict-unverified-write-sentinel.md index 15d2387cd..292de0316 100644 --- a/docs/decisions/records/ci/verdict-unverified-write-sentinel.md +++ b/docs/decisions/records/ci/verdict-unverified-write-sentinel.md @@ -5,9 +5,9 @@ status: active since: '2026-08-29' supersedes: none superseded-by: none -rule: 'The `review-verdict/h10` job runs its post-write race check after EVERY status write, not only an exemption `success`, and where no mark exists to run it against, the write ITSELF becomes the sentinel — so every status this job writes is either verified against the history or marked as unverifiable: a GENERIC `pending` masks a rejection landing in its own write window exactly as a `success` does, and because it carries no marker a later run re-derives it into an exemption with the human row now below THAT run''s high-water mark — the damage arrives one event later, not never. Where a write cannot be checked at all the job writes a SECOND sentinel, `UNVERIFIED_DESC`, distinct from the repair sentinel and never interchangeable with it: the repair sentinel ASSERTS that a verdict existed and was buried, which only the arm that actually COUNTED such a row may claim, while every "could not check" arm — an unreadable or over-cap history, an impossible empty history, an unusable count — states only what it established. Both are STICKY (the classification refuses to grant an exemption over either, and re-writes its own description verbatim, so each is a FIXED POINT a later run cannot re-derive into `success`); only the unverified one is RECONCILABLE. Reconciliation is what bounds the stall that got the #742 attempt withdrawn: a later run pages `/statuses/{sha}` IN FULL and, ONLY IF that history contains the sentinel''s own row, either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it UPGRADES to the repair sentinel, clearable only by a human — or finds none, resolving the uncertainty and clearing it so the PR is classified normally. The reconciliation is sound because the two endpoints differ: a verdict this job masked is invisible on the COMBINED endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. It is deliberately BROADER than the inheritance test — no `H10_REVIEWERS` membership, no `(base: …)` match — because over-counting upgrades to a stall a human clears in one command while MISSING one re-exempts a head carrying a buried rejection. ONLY a COMPLETE walk CONTAINING THE SENTINEL ROW may clear it. The witness is not optional decoration: `ex_unverified` means the combined endpoint just returned that row, and `/statuses/{sha}` keeps one row per POST, so a complete history WITHOUT it — an empty one included — contradicts a write that demonstrably happened. `page_statuses` accepts an empty page 1 as complete (correct for a first-run mark), which is what made that shape reachable, and clearing on it exempted a head whose sentinel may have been sitting on a rejection. This is NOT the withdrawn currency witness: that asked whether ANY row sat above the mark, which an unrelated row satisfied, and it made one anomaly permanent — this asks for a SPECIFIC row already known to exist, and failing it costs one run rather than requiring a human. When NO high-water mark can be established the write is withheld BEFORE the POST rather than posted and repaired, since the defect is known in advance and publishing a green to take it back opens a window branch protection — and an already-scheduled auto-merge — can see. EVERY re-derivable state is downgraded there, not only `success`: an earlier draft restricted it to the exemption on the grounds that a sticky generic `pending` "withholds nothing, since an unreviewed PR is blocked already", which analysed the wrong PR — the damaging case is one that IS exemptible and got the generic `pending` only from a transient enumeration failure, whose unmarked description the next run re-derives into the exemption with the human row below its own mark. `$REPAIR_DESC` is the one exemption, being the stronger fact and not re-derivable. SEPARATELY, the retarget count is re-taken AFTER the POST on the exemption path, which closes the PERMANENT forged green `ci.verdict-write-retarget-fence` recorded as its residual: a retarget landing after the final pre-write count leaves a stale `success` with the `edited` event already consumed by a successor that short-circuited, so nothing remains to reclassify. Re-counting afterwards means a retarget later than the re-check necessarily queues a successor that STARTS after the stale `success` exists, and a machine-written `success` is re-derived rather than inherited. The RETARGET axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. An untrusted post-POST count repairs too — reaching that point with `success` means both earlier counts were trusted, so a third unreadable read is a fresh failure and "I cannot tell whether the base moved" must not resolve to leaving a green. FINALLY, every path that cannot establish what the head carries REPLACES the unknown state instead of merely declining to write — the combined read (retried once first), all four page-2 completeness refusals, and the fence branch that cannot trust its retarget count while holding a derived `success`. That last one is reached only AFTER the classification declined to inherit the row the head carries, so posting nothing left the declined row current; its message used to say the context "stays absent", which is true only of a head that had none. Declining protects a real verdict and leaves a FORGED one — an off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving it, and an unreadable read is the one thing that stops it while the job goes red on a status branch protection does not read. Nothing is destroyed: the per-POST history keeps the masked verdict and the next run''s reconciliation upgrades to the repair sentinel, telling the reviewer to re-post rather than silently un-approving them. The page-2 refusals were briefly excluded on the reasoning that the probe fires when NO row for this context was on page 1, so there is no green of any provenance to leave standing — self-contradictory, since the ONLY reason page 2 is read is that the row MAY be beyond page 1. WRITING THE SENTINEL AND FAILING THE JOB ARE SEPARATE DECISIONS: the read refusals were already non-zero exits on `main` and stay red, while the fence branch exited 0 there and still does, because an unreadable timeline is an ordinary hiccup and reddening every one of them is noise this file elsewhere refuses to add. The repair also has a FLOOR — it may never write a description weaker than the one this run decided, or a transient post-write read rewrites a correct `$REPAIR_DESC` carry-forward with the machine-clearable sentinel — and it is skipped entirely when it would rewrite what is already there.' +rule: 'The `review-verdict/h10` job runs its post-write race check after EVERY status write, not only an exemption `success`, and where no mark exists to run it against, the write ITSELF becomes the sentinel — so every status this job writes is either verified against the history or marked as unverifiable: a GENERIC `pending` masks a rejection landing in its own write window exactly as a `success` does, and because it carries no marker a later run re-derives it into an exemption with the human row now below THAT run''s high-water mark — the damage arrives one event later, not never. Where a write cannot be checked at all the job writes a SECOND sentinel, `UNVERIFIED_DESC`, distinct from the repair sentinel and never interchangeable with it: the repair sentinel ASSERTS that a verdict existed and was buried, which only the arm that actually COUNTED such a row may claim, while every "could not check" arm — an unreadable or over-cap history, an impossible empty history, an unusable count — states only what it established. Both are STICKY (the classification refuses to grant an exemption over either, and re-writes its own description verbatim, so each is a FIXED POINT a later run cannot re-derive into `success`); only the unverified one is RECONCILABLE. Reconciliation is what bounds the stall that got the #742 attempt withdrawn: a later run pages `/statuses/{sha}` IN FULL and, ONLY IF that history contains the sentinel''s own row, either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it UPGRADES to the repair sentinel, clearable only by a human — or finds none, resolving the uncertainty and clearing it so the PR is classified normally. The reconciliation is sound because the two endpoints differ: a verdict this job masked is invisible on the COMBINED endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. It is deliberately BROADER than the inheritance test — no `H10_REVIEWERS` membership, no `(base: …)` match — because over-counting upgrades to a stall a human clears in one command while MISSING one re-exempts a head carrying a buried rejection. ONLY a COMPLETE walk CONTAINING THE CURRENT SENTINEL ROW — matched by the `id` the combined read reported, not merely by a row with the same description — may clear it. Description alone is satisfied by an OLDER identical sentinel, which is exactly what a fixed point produces: with an earlier sentinel and a buried verdict below the current one, a read carrying only the earlier row satisfies the test, the sentinel clears, and the verdict ends up below the fresh mark. Where the server omits `id` there is nothing to match on and the check degrades to the description, which is the pre-existing behaviour rather than a new hole. The witness is not optional decoration: `ex_unverified` means the combined endpoint just returned that row, and `/statuses/{sha}` keeps one row per POST, so a complete history WITHOUT it — an empty one included — contradicts a write that demonstrably happened. `page_statuses` accepts an empty page 1 as complete (correct for a first-run mark), which is what made that shape reachable, and clearing on it exempted a head whose sentinel may have been sitting on a rejection. This is NOT the withdrawn currency witness: that asked whether ANY row sat above the mark, which an unrelated row satisfied, and it made one anomaly permanent — this asks for a SPECIFIC row already known to exist. Failing it costs one run in the transient case; it is NOT bounded in general, because a history past the 20-page cap can never be walked completely, so such a head needs a human verdict and no later run will clear it. When NO high-water mark can be established the write is withheld BEFORE the POST rather than posted and repaired, since the defect is known in advance and publishing a green to take it back opens a window branch protection — and an already-scheduled auto-merge — can see. EVERY re-derivable state is downgraded there, not only `success`: an earlier draft restricted it to the exemption on the grounds that a sticky generic `pending` "withholds nothing, since an unreviewed PR is blocked already", which analysed the wrong PR — the damaging case is one that IS exemptible and got the generic `pending` only from a transient enumeration failure, whose unmarked description the next run re-derives into the exemption with the human row below its own mark. `$REPAIR_DESC` is the one exemption, being the stronger fact and not re-derivable. SEPARATELY, the retarget count is re-taken AFTER the POST on the exemption path, which closes the PERMANENT forged green `ci.verdict-write-retarget-fence` recorded as its residual: a retarget landing after the final pre-write count leaves a stale `success` with the `edited` event already consumed by a successor that short-circuited, so nothing remains to reclassify. Re-counting afterwards means a retarget later than the re-check necessarily queues a successor that STARTS after the stale `success` exists, and a machine-written `success` is re-derived rather than inherited. The RETARGET axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. An untrusted post-POST count repairs too — reaching that point with `success` means both earlier counts were trusted, so a third unreadable read is a fresh failure and "I cannot tell whether the base moved" must not resolve to leaving a green. FINALLY, every path that cannot establish what the head carries REPLACES the unknown state instead of merely declining to write — the combined read (retried once first), all four page-2 completeness refusals, and the fence branch that cannot trust its retarget count while holding a derived `success`. That last one is reached only AFTER the classification declined to inherit the row the head carries, so posting nothing left the declined row current; its message used to say the context "stays absent", which is true only of a head that had none. The two OBSERVED-mutation arms take the same decision for the same reason, scoped to a head that actually carries a declined row: they still refuse to post their CLASSIFICATION, which was computed against a base or head the PR may no longer have, but a row this run declined must not stay authoritative for the whole window until a successor finishes — and for a PR''s FIRST push no successor is queued at all. Every element and every consumed field of the combined response is TYPE-CHECKED before extraction, and a schema failure routes to the same replacement rather than dying: `.statuses` being an array was checked and its ELEMENTS were not, so one scalar made `select(.context == $c)` hard-error and `set -e` took the step down before any path could mark the head. The path-predicate failure replaces too. And the write helper RETURNS a status: its first version ended the failure arm with a successful `echo`, so it reported 0 after both POSTs failed and the fence caller exited 0 as though the head had been marked. Declining protects a real verdict and leaves a FORGED one — an off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving it, and an unreadable read is the one thing that stops it while the job goes red on a status branch protection does not read. Nothing is destroyed: the per-POST history keeps the masked verdict and the next run''s reconciliation upgrades to the repair sentinel, telling the reviewer to re-post rather than silently un-approving them. The page-2 refusals were briefly excluded on the reasoning that the probe fires when NO row for this context was on page 1, so there is no green of any provenance to leave standing — self-contradictory, since the ONLY reason page 2 is read is that the row MAY be beyond page 1. WRITING THE SENTINEL AND FAILING THE JOB ARE SEPARATE DECISIONS: the read refusals were already non-zero exits on `main` and stay red, while the fence branch exited 0 there and still does, because an unreadable timeline is an ordinary hiccup and reddening every one of them is noise this file elsewhere refuses to add. The repair also has a FLOOR — it may never write a description weaker than the one this run decided, or a transient post-write read rewrites a correct `$REPAIR_DESC` carry-forward with the machine-clearable sentinel — and it is skipped entirely when it would rewrite what is already there.' signals: 'exemption success standing over a human failure, generic pending re-derived into an exemption, unverified write sentinel, reconcilable sentinel vs repair sentinel, no high-water mark could be established, post-write verification runs for every write, retarget after the POST leaves a permanent forged green, unreadable combined status read leaves an off-list green, why did my docs-only PR lose its exemption, why does the gate say the write could not be verified · paths: `.gitea/workflows/review-verdict.yml`, `scripts/tests/test_pr_changed_files.py` · issues: #849, #742, #763, #706, #803' -mechanics: '`UNVERIFIED_DESC` ("Exemption write could not be verified — re-post the verdict") beside `REPAIR_DESC`; `read_existing_verdict()` sets `ex_unverified` from a prefix match, retries the combined read once and on persistent failure calls `repair_status_to "$UNVERIFIED_DESC"` before `exit 1`; the RECONCILE block runs `page_statuses` and counts `Review-verdict:` rows with a non-null creator, clearing `ex_unverified`, upgrading to `ex_repair`, or carrying the sentinel forward on `ph_ok != yes`; the classification chain is `exempt` → `ex_repair` → `ex_unverified` → generic, so the repair sentinel outranks the unverified one; the no-mark downgrade sits AFTER the mark (referencing `$max_id_before` earlier is an unbound variable under `set -u`); the mid-run guard adds `[ "$ex_desc" != "$pre_desc" ]`, which the repair guard does not need because a pre-existing repair sentinel forces its own description while a pre-existing unverified one is deliberately replaced after reconciliation; the post-write gate is `if [ "$max_id_before" -ge 0 ]`, with the no-mark case handled before the POST instead; `replace_unknown_state()` is the shared writer for every path that cannot establish the head''s state and `replace_unknown_and_die()` wraps it for the two read refusals that were already non-zero exits; the reconciliation witness is a count of rows whose description equals `$UNVERIFIED_DESC`; `pre_id`/`ex_id` extend the mid-run changed-row comparison to row IDENTITY, since two sentinel POSTs are byte-identical by design (the combined endpoint carries `id` — measured 2026-08-29 at 1.27.1, head 736649b3, 8 rows, ids 14..30); `--arg own "$desc"` excludes the job''s OWN row from both machine-sentinel selectors, which became necessary the moment the block started running for every write; `.description` is TYPE-TESTED before `startswith`, since `(.description // "")` does not replace a NUMBER and `startswith` then hard-errors, killing the whole count and losing a genuine verdict beside the malformed row; `repair_status_to()` is the shared retry-once writer for all three repair sites; tests `test_a_verdict_racing_a_PENDING_write_is_repaired_to_the_repair_sentinel`, `test_a_raced_PENDING_write_repairs_and_the_repair_SURVIVES_the_next_run`, `test_the_unverified_sentinel_is_a_FIXED_POINT_while_it_cannot_be_reconciled`, `test_the_unverified_sentinel_is_RECONCILED_AWAY_once_the_history_can_be_read`, `test_the_reconciliation_UPGRADES_to_the_repair_sentinel_when_a_verdict_is_BURIED`, `test_a_RETARGET_AFTER_the_POST_replaces_the_exemption`, `test_an_UNREADABLE_retarget_count_AFTER_the_POST_also_replaces_the_exemption`, `test_a_MALFORMED_description_row_does_not_kill_the_post_write_count`, `test_a_transport_failure_on_the_STATUS_READ_REPLACES_the_unknown_state`, each paired with a `test_MUTATION_…` proof that disarms the shipped clause it names through `_run_classify(mutate=…)`, whose count assertion is the binding. Two of those mutants restore `origin/main` verbatim, one restores the shape #742 withdrew, and the rest disarm clauses that have no predecessor because the blocks they gate are new — the section header in that file enumerates which is which, because "restores the exact predecessor" is true of only some of them.' +mechanics: '`UNVERIFIED_DESC` ("Exemption write could not be verified — re-post the verdict") beside `REPAIR_DESC`; `read_existing_verdict()` sets `ex_unverified` from a prefix match, retries the combined read once and on persistent failure calls `repair_status_to "$UNVERIFIED_DESC"` before `exit 1`; the RECONCILE block runs `page_statuses` and counts `Review-verdict:` rows with a non-null creator, clearing `ex_unverified`, upgrading to `ex_repair`, or carrying the sentinel forward on `ph_ok != yes`; the classification chain is `exempt` → `ex_repair` → `ex_unverified` → generic, so the repair sentinel outranks the unverified one; the no-mark downgrade sits AFTER the mark (referencing `$max_id_before` earlier is an unbound variable under `set -u`); the mid-run guard adds `[ "$ex_desc" != "$pre_desc" ]`, which the repair guard does not need because a pre-existing repair sentinel forces its own description while a pre-existing unverified one is deliberately replaced after reconciliation; the post-write gate is `if [ "$max_id_before" -ge 0 ]`, with the no-mark case handled before the POST instead; `replace_unknown_state()` is the shared writer for every path that cannot establish the head''s state and `replace_unknown_and_die()` wraps it for the two read refusals that were already non-zero exits; the reconciliation witness is a count of rows whose description equals `$UNVERIFIED_DESC`; `pre_id`/`ex_id` extend the mid-run changed-row comparison to row IDENTITY through a shared `row_replaced` flag, since two sentinel POSTs are byte-identical by design — and an id difference counts only when BOTH reads supplied one, because a response that omits `id` beside one that includes it would otherwise report a replacement that did not happen and make the run abstain over a row it had already declined (the combined endpoint carries `id` — measured 2026-08-29 at 1.27.1, head 736649b3, 8 rows, ids 14..30); `--arg own "$desc"` excludes the job''s OWN row from both machine-sentinel selectors, which became necessary the moment the block started running for every write; `.description` is TYPE-TESTED before `startswith`, since `(.description // "")` does not replace a NUMBER and `startswith` then hard-errors, killing the whole count and losing a genuine verdict beside the malformed row; `repair_status_to()` is the shared retry-once writer for all three repair sites; tests `test_a_verdict_racing_a_PENDING_write_is_repaired_to_the_repair_sentinel`, `test_a_raced_PENDING_write_repairs_and_the_repair_SURVIVES_the_next_run`, `test_the_unverified_sentinel_is_a_FIXED_POINT_while_it_cannot_be_reconciled`, `test_the_unverified_sentinel_is_RECONCILED_AWAY_once_the_history_can_be_read`, `test_the_reconciliation_UPGRADES_to_the_repair_sentinel_when_a_verdict_is_BURIED`, `test_a_RETARGET_AFTER_the_POST_replaces_the_exemption`, `test_an_UNREADABLE_retarget_count_AFTER_the_POST_also_replaces_the_exemption`, `test_a_MALFORMED_description_row_does_not_kill_the_post_write_count`, `test_a_transport_failure_on_the_STATUS_READ_REPLACES_the_unknown_state`, each paired with a `test_MUTATION_…` proof that disarms the shipped clause it names through `_run_classify(mutate=…)`, whose count assertion is the binding. Three of those mutants restore text this branch or `origin/main` actually shipped, and the rest disarm clauses that have no predecessor because the blocks they gate are new — the section header in that file enumerates which is which, because "restores the exact predecessor" is true of only some of them. ONE CLAUSE IS DELIBERATELY UNPROVEN and says so rather than being counted: the path-predicate failure branch, which fires only when `grep -c` exits above 1, has no fixture that can reach it — the same standing exception the post-write unusable-count arm already carries, and it is defence in depth behind a filter that makes the count numeric for every input a stub can pose.' --- `ci.verdict-write-retarget-fence` fenced the write and verified it afterwards. Five routes survived diff --git a/docs/guard-inventory.md b/docs/guard-inventory.md index 10940f412..8172f790c 100644 --- a/docs/guard-inventory.md +++ b/docs/guard-inventory.md @@ -331,7 +331,7 @@ job it covers, because a proof reference that covers a fraction must not read as | `pr-checks.yml::decisions-guard` | a PR whose decision records fail lifecycle validation, whose catalog is stale, or whose kickoff file drifted | GUARD | `scripts/decisions_validate.py`, `scripts/build_decisions_catalog.py`, `scripts/check-kickoff-guard.sh` | those scripts' rows above | | `pr-checks.yml::prove-fix` | a PR whose `Proves:` trailer names a test that passes without the fix | GUARD | inline + `scripts/prove-fix.sh` | that script's row above | | `pr-checks.yml::script-tests` | a PR failing ruff or the `scripts/tests` suite — this job is the RUNNER for every `scripts/tests/` row above | GUARD | inline (the ruff population guards) + pytest | the suite it runs | -| `review-verdict.yml::set-verdict-status` | a MERGE, by withholding the branch-protection-required `review-verdict/h10` status | GUARD | inline | `scripts/tests/test_pr_changed_files.py`, which EXECUTES the shipped `run:` body: it guards the changed-file derivation, the exemption classification, the timeline fence and — since #849 — the post-write verification state machine, each `test_MUTATION_…` there restoring the exact predecessor clause through `_run_classify(mutate=…)`. It does NOT cover the runner's step wiring, which no local test can reach | +| `review-verdict.yml::set-verdict-status` | a MERGE, by withholding the branch-protection-required `review-verdict/h10` status | GUARD | inline | `scripts/tests/test_pr_changed_files.py`, which EXECUTES the shipped `run:` body: it guards the changed-file derivation, the exemption classification, the timeline fence and — since #849 — the post-write verification state machine, each `test_MUTATION_…` there disarming through `_run_classify(mutate=…)` the shipped clause it names, bound by a count assertion — three restore text that actually shipped and the rest are counterfactual, which that file's own section header enumerates, and the one clause with no reachable fixture is named there rather than counted. It does NOT cover the runner's step wiring, which no local test can reach | ### The four jobs with no dropped-step guard, decided per job (ersatztv#786) diff --git a/scripts/tests/test_pr_changed_files.py b/scripts/tests/test_pr_changed_files.py index adab949fb..00c7cdc93 100644 --- a/scripts/tests/test_pr_changed_files.py +++ b/scripts/tests/test_pr_changed_files.py @@ -956,6 +956,12 @@ url = [a for a in args if a.startswith("http")][-1] out = pathlib.Path(os.environ["STUB_DIR"]) if "-X" in args and args[args.index("-X") + 1] == "POST": + if os.environ.get("STUB_POST_FAILS") == "1": + # EVERY POST FAILS, retries included. `gh` is `curl -sf`, so an HTTP error is exit 22 with + # empty stdout. Without this the write helpers' failure arms are unreachable — and one of + # them reported SUCCESS after both attempts failed, which is what let a caller exit 0 + # believing the head had been marked. + sys.exit(22) payload = args[args.index("-d") + 1] (out / "posted.json").write_text(payload) (out / "posted_url.txt").write_text(url) @@ -1406,6 +1412,28 @@ if "/statuses/" in url: n_logical = int(logical.read_text()) if logical.exists() else 1 pages = json.loads(snapshot.read_text()) if snapshot.exists() else [[]] + if hist_page > 1 and mode == "reconcile-page2-error" and n_logical == 1: + # PAGE 2 FAILS ON THE FIRST LOGICAL READ — the reconciliation walk — while page 1, which + # carries the seeded sentinel, is served. That separates the two halves of the trust + # condition: `ph_ok` is `no` and `witness` is 1, so a mutant that drops only the completeness + # operand still clears, and a mutant that drops only the witness operand does not. Against a + # fixture where BOTH are false, `if false` disarms two guards at once and isolates neither. + sys.exit(22) + if hist_page == 1 and mode == "postwrite-page1-error": + # FAILS THE POST-WRITE WALK ONLY (ersatztv#849 round 2). `premark-page1-error` fails the + # FIRST logical read, which is the mark; the repair floor needs a run that got its mark, made + # its write, and THEN could not read the history back. Both attempts of the second logical + # read fail, so the retry cannot rescue it. + # THE THRESHOLD IS DERIVED FROM WHERE THE REQUESTS FALL, not picked: `page_statuses` asks + # for page 1 exactly once per successful walk, so the mark's walk is request 0 and the + # post-write walk is requests 1 and 2 (the second being its retry). `>= 1` therefore lets the + # mark be established and fails the post-write read outright, which is the arrangement the + # repair floor needs and the one `premark-page1-error` cannot produce. + ctr = out / "p1_attempts_pw.txt" + seen = int(ctr.read_text()) if ctr.exists() else 0 + ctr.write_text(str(seen + 1)) + if seen >= 1: + sys.exit(22) if hist_page == 1 and mode == "premark-page1-error": # PAGE 1 ITSELF FAILS, for the whole first logical read (both attempts), so the walk returns # nothing at all and the mark must be abandoned. Counted in its own file because the logical @@ -1543,6 +1571,52 @@ if "/status" in url: ] print(json.dumps({"state": "pending", "total_count": len(mid_rows), "statuses": mid_rows})) sys.exit(0) + if mode == "malformed-row-beside-verdict": + # A SCALAR IN `.statuses` BESIDE A REAL ROW. `.statuses` is an array and `total_count` agrees, + # so the response passes the shape gate; it is the ELEMENTS that cannot be read. An untyped + # `select(.context == $c)` hard-errors on the scalar, jq exits 5, and under `set -e` the + # assignment takes the whole step down — before any path that could mark the head, while the + # `success` below stays authoritative. + rows = [7, {"context": "review-verdict/h10", "status": "success", + "creator": {"login": "mallory"}, + "description": "Review-verdict: MERGEABLE @ a9e3e23 (base: main)"}] + print(json.dumps({"state": "success", "total_count": len(rows), "statuses": rows})) + sys.exit(0) + if mode == "id-appears-on-second-read": + # THE SAME ROW, reported once WITHOUT `id` and once WITH it. Nothing else about it moves. An + # id comparison that does not require both sides to be present reads this as a replacement + # and makes the run abstain — over a row it had already declined to inherit. + ctr = out / "status_reads.txt" + n = int(ctr.read_text()) if ctr.exists() else 0 + ctr.write_text(str(n + 1)) + row = {"context": "review-verdict/h10", "status": "success", + "creator": {"login": "mallory"}, + "description": "Review-verdict: MERGEABLE @ a9e3e23 (base: main)"} + if n > 0: + row = dict(row, id=77) + rows = [{"context": "ci/decoy", "status": "pending", "creator": None, + "description": "unrelated"}, row] + print(json.dumps({"state": "pending", "total_count": len(rows), "statuses": rows})) + sys.exit(0) + if mode == "sentinel-replaced-mid-run": + # THE SAME SENTINEL TEXT AT TWO DIFFERENT IDS (ersatztv#849 round 2). Both reads return an + # unverified sentinel whose description is byte-identical — which is what a fixed point IS — + # so only the row id distinguishes "the row I snapshotted" from "a row another run wrote + # while I classified". Ids are carried here and nowhere else in this stub because this is the + # only fixture whose outcome turns on them. + ctr = out / "status_reads.txt" + n = int(ctr.read_text()) if ctr.exists() else 0 + ctr.write_text(str(n + 1)) + rows = [ + {"context": "ci/decoy", "status": "pending", "creator": None, "description": "unrelated"}, + {"id": 100 if n == 0 else 200, "context": "review-verdict/h10", "status": "pending", + "creator": None, + # FROM THE SHIPPED BODY, never a copy: the sentinel is a fixed point, so a stub carrying + # its own spelling would keep passing after the workflow reworded its own. + "description": os.environ["STUB_UNVERIFIED_DESC"]}, + ] + print(json.dumps({"state": "pending", "total_count": len(rows), "statuses": rows})) + sys.exit(0) if mode.startswith("sentinel-appears-on-read:"): # A repair sentinel written by ANOTHER, overlapping run between this job's first read and its # last-moment re-read (ersatztv#706 round 3). Creator is null: the sentinel is machine-written. @@ -1635,6 +1709,7 @@ def _run_classify( timeline_terminator: str = "null", status_empty_shape: str = "null", mutate: tuple[str, str] | None = None, + post_fails: bool = False, ): """Execute the workflow's classify `run:` block with a stubbed enumeration script. @@ -1674,6 +1749,8 @@ def _run_classify( env["STUB_HISTORY_MODE"] = history_mode env["STUB_HISTORY_CREATOR"] = history_creator env["STUB_HISTORY_EXTRA"] = json.dumps(history_extra) if history_extra else "" + env["STUB_UNVERIFIED_DESC"] = UNVERIFIED_DESC + env["STUB_POST_FAILS"] = "1" if post_fails else "" env["STUB_MIDRUN_CREATOR"] = midrun_creator env["STUB_PRE_ROW"] = pre_row env["STUB_MIDRUN_ROW"] = midrun_row @@ -1755,25 +1832,35 @@ def _run_classify( DOCS_ONLY = 'printf "docs/a.md\\ndocs/b.md\\n"\n' -def _assert_withheld(posted, r, why): +def _assert_withheld(tmp_path, r, why, expect_rc): """The gate refused to grant the exemption — which since ersatztv#849 means the head is MARKED, not left alone. - Every one of these tests asserted `posted is None`. That was right while "withhold" meant - "write nothing", and it became a fail-open assertion when it stopped meaning that: declining to - write protects a REAL verdict on the head and leaves a FORGED one, and this job's own red status - is not a required check, so branch protection still sees whatever was already there. + Every one of these tests asserted `posted is None`. That was right while "withhold" meant "write + nothing", and it became a fail-open assertion when it stopped meaning that: declining to write + protects a REAL verdict on the head and leaves a FORGED one, and this job's own red status is not + a required check, so branch protection still sees whatever was already there. - Asserted positively — state AND description — because `pending` alone does not distinguish the - sticky sentinel from the generic awaiting-verdict text a later run happily re-derives. + THE WHOLE POST SEQUENCE, not the last write. Inspecting only the final status would accept a job + that posted `success` and then repaired it — and the green interval IS part of the threat model + here, since branch protection and an already-scheduled auto-merge can both observe it. Exactly + one POST, and it is the sentinel. + + THE EXIT CODE IS PART OF THE CONTRACT and differs by path, so each caller states its own rather + than inheriting a default: the two read refusals were already non-zero exits before this change + and stay red, while the fence branch abstains cleanly. A single default would let one path's + regression hide behind the other's expectation. """ - assert posted is not None, ( - f"{why}: nothing was posted, so whatever {'{CONTEXT}'} this head already carries is standing " - f"unread and unre-derived.\n{r.stdout[-900:]}" + seq = _posted_sequence(tmp_path) + assert len(seq) == 1, ( + f"{why}: expected exactly one status write — the sentinel — got {seq}. Nothing posted at all " + f"means whatever this head already carries is standing unread; more than one means a green " + f"was published and taken back.\n{r.stdout[-900:]}" ) - assert posted["state"] == "pending" and posted["description"] == UNVERIFIED_DESC, ( - f"{why}: expected the unverified-write sentinel, got {posted}\n{r.stdout[-900:]}" + assert seq[0]["state"] == "pending" and seq[0]["description"] == UNVERIFIED_DESC, ( + f"{why}: expected the unverified-write sentinel, got {seq[0]}\n{r.stdout[-900:]}" ) + assert r.returncode == expect_rc, f"{why}: expected exit {expect_rc}, got {r.returncode}\n{r.stdout[-900:]}" def test_a_FAILING_enumeration_withholds_the_exemption_even_when_stdout_looks_docs_only(tmp_path): @@ -3249,7 +3336,9 @@ def test_the_head_arm_DEFERS_to_the_trust_guard_when_the_second_count_fails(tmp_ timeline_mode="trusted-then-unreadable", push_mode="stable:3", ) - _assert_withheld(posted, r, "test_the_head_arm_DEFERS_to_the_trust_guard_when_the_second_count_fails") + _assert_withheld( + tmp_path, r, "test_the_head_arm_DEFERS_to_the_trust_guard_when_the_second_count_fails", expect_rc=0 + ) assert "Could not establish a trusted retarget/push count" in r.stdout, ( f"the untrusted re-count was not reported as such. Log:\n{r.stdout[-900:]}" ) @@ -3387,7 +3476,7 @@ def test_an_EMPTY_FIRST_page_is_untrusted_WHICHEVER_empty_shape_it_is(tmp_path, timeline_mode="empty-first-page", timeline_terminator=terminator, ) - _assert_withheld(posted, r, "test_an_EMPTY_FIRST_page_is_untrusted_WHICHEVER_empty_shape_it_is") + _assert_withheld(tmp_path, r, "test_an_EMPTY_FIRST_page_is_untrusted_WHICHEVER_empty_shape_it_is", expect_rc=0) def test_a_timeline_row_with_NO_READABLE_TYPE_is_not_counted_as_nothing(tmp_path): @@ -3399,7 +3488,7 @@ def test_a_timeline_row_with_NO_READABLE_TYPE_is_not_counted_as_nothing(tmp_path IT extracts, which the walk gating the write did not have. """ posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), timeline_mode="untyped-rows") - _assert_withheld(posted, r, "test_a_timeline_row_with_NO_READABLE_TYPE_is_not_counted_as_nothing") + _assert_withheld(tmp_path, r, "test_a_timeline_row_with_NO_READABLE_TYPE_is_not_counted_as_nothing", expect_rc=0) def test_the_head_fence_reports_the_OBSERVED_counts_in_its_notice(tmp_path): @@ -3456,7 +3545,7 @@ def test_an_UNTRUSTED_retarget_count_withholds_the_EXEMPTION(tmp_path, mode): """A `success` that cannot be shown to describe the PR's current base must not be written. An absent required check blocks the merge, which is the safe direction.""" posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), timeline_mode=mode) - _assert_withheld(posted, r, "test_an_UNTRUSTED_retarget_count_withholds_the_EXEMPTION") + _assert_withheld(tmp_path, r, "test_an_UNTRUSTED_retarget_count_withholds_the_EXEMPTION", expect_rc=0) @pytest.mark.parametrize("mode", ["unreadable", "transport-error"]) @@ -3665,7 +3754,7 @@ def test_an_UNTRUSTED_count_withholds_the_exemption_BY_THAT_BRANCH(tmp_path): """Round 2 test-gap: the existing untrusted-count test asserted only "posted nothing", which a crash also produces. Assert the discriminator and a clean exit.""" posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), timeline_mode="unreadable") - _assert_withheld(posted, r, "test_an_UNTRUSTED_count_withholds_the_exemption_BY_THAT_BRANCH") + _assert_withheld(tmp_path, r, "test_an_UNTRUSTED_count_withholds_the_exemption_BY_THAT_BRANCH", expect_rc=0) assert r.returncode == 0, f"the job died rather than declining cleanly: {r.stderr[-800:]}" # The wording covers BOTH axes since ersatztv#803 — one walk certifies one trust flag, so an # unreadable page abandons the push count and the retarget count together. @@ -4349,7 +4438,7 @@ def test_a_terminator_on_PAGE_ONE_does_not_certify_a_zero_retarget_count(tmp_pat """ posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), timeline_mode="empty-first-page") assert r.returncode == 0, r.stderr - _assert_withheld(posted, r, "test_a_terminator_on_PAGE_ONE_does_not_certify_a_zero_retarget_count") + _assert_withheld(tmp_path, r, "test_a_terminator_on_PAGE_ONE_does_not_certify_a_zero_retarget_count", expect_rc=0) assert "trusted=no" in r.stdout, ( f"the exemption was withheld, but not because the count was untrusted:\n{r.stdout[-800:]}" ) @@ -4373,7 +4462,9 @@ def test_a_SECOND_PAGE_of_statuses_refuses_to_conclude_that_no_verdict_exists(tm the list is longer than one page and the verdict may be beyond it. """ posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode="twopage") - _assert_withheld(posted, r, "test_a_SECOND_PAGE_of_statuses_refuses_to_conclude_that_no_verdict_exists") + _assert_withheld( + tmp_path, r, "test_a_SECOND_PAGE_of_statuses_refuses_to_conclude_that_no_verdict_exists", expect_rc=1 + ) assert "page 2" in (r.stdout + r.stderr).lower(), ( f"nothing was posted, but not because of the page-2 completeness probe:\n{r.stdout[-800:]}" ) @@ -4426,7 +4517,7 @@ def test_an_UNREADABLE_page_2_refuses_to_conclude_that_no_verdict_exists(tmp_pat that cannot be read justifies nothing. """ posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode=mode) - _assert_withheld(posted, r, "test_an_UNREADABLE_page_2_refuses_to_conclude_that_no_verdict_exists") + _assert_withheld(tmp_path, r, "test_an_UNREADABLE_page_2_refuses_to_conclude_that_no_verdict_exists", expect_rc=1) assert "page 2" in (r.stdout + r.stderr).lower(), ( f"nothing was posted, but not because of the page-2 probe:\n{r.stdout[-800:]}" ) @@ -4951,6 +5042,17 @@ def test_an_UNREADABLE_history_page_2_also_repairs_rather_than_leaving_green(tmp # text verbatim. # * `test_MUTATION_a_GENERIC_pending_...` restores the shape #742 attempted and WITHDREW, not # `main` — which had no downgrade at all. +# * `test_MUTATION_restoring_the_SUCCESS_only_no_mark_downgrade_...` restores the predicate this +# branch itself shipped one commit earlier, which is where a cold review found it surviving the +# whole suite: the nearest existing proof mutated the DESCRIPTION the downgrade writes, not its +# SCOPE, and its fixture ran a succeeding enumeration, so `state=success` there and the +# `success`-only predecessor fired identically. +# +# TWO GUARDS HERE ARE OUTCOME-REDUNDANT AND WOULD OTHERWISE HIDE EACH OTHER: the `$own` exclusions +# and the no-op-repair skip both suppress the same duplicate POST, so mutating either alone leaves +# the post sequence unchanged. What the exclusions alone decide is the REPORT — without them a run +# counts its own row and tells a reviewer their verdict was overwritten when nothing raced it — so +# their proofs assert the LOG. That is the honest discriminator, not a weaker one. # * the `if false` mutants disarm clauses that have NO predecessor, because the blocks they gate # are new. Disarming the condition isolates the DECISION from the round-trip beside it, which # deleting the block would not; they are counterfactual mutants and are sound as such. @@ -5238,25 +5340,30 @@ def test_MUTATION_reconciling_on_an_UNREADABLE_history_re_exempts_a_head(tmp_pat pending. Treating it as "nothing buried" is the fail-open, and it is a tempting simplification because the happy path looks identical. - The mutant disarms the trust test. BE EXACT ABOUT WHAT THE FIXTURE SHOWS, because an earlier - version of this docstring claimed it "makes the reconciliation walk fail while a verdict IS - present in the history" — `premark-page1-error` serves ordinary rows and no verdict, so that was - a description of a different fixture. What is shown here is narrower and sufficient: on a walk - that could not be read, the shipped code carries the sentinel forward and the mutant clears it - and grants the exemption. That the cleared sentinel can be sitting on a real verdict is shown by + THE MUTATION ISOLATES THE COMPLETENESS OPERAND, which needs a fixture where the OTHER operand + is satisfied. `reconcile-page2-error` serves page 1 — carrying the seeded sentinel, so the + witness is 1 — and fails page 2 of the reconciliation walk, so `ph_ok` is `no`. Dropping the + completeness operand therefore clears the sentinel over a list the job knows it did not finish + reading, which is where a buried verdict would be. An earlier version used a fixture with BOTH + operands false and mutated the whole condition to `if false`, which disarms two guards at once + and isolates neither. + + That the cleared sentinel can be sitting on a real verdict is shown by `test_the_reconciliation_UPGRADES_to_the_repair_sentinel_when_a_verdict_is_BURIED`, which is the test that supplies one. """ + seed = [_sentinel_row()] posted, r = _run_classify( tmp_path / "fixed", _emitting("docs/a.md"), status_mode="existing:pending", status_creator=None, status_desc=UNVERIFIED_DESC, - history_mode="premark-page1-error", + history_mode="reconcile-page2-error", + history_extra=seed, ) assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( - f"an unreadable reconciliation did not carry the sentinel forward: {posted}\n{r.stdout[-900:]}" + f"an incomplete reconciliation did not carry the sentinel forward: {posted}\n{r.stdout[-900:]}" ) mutant, rm = _run_classify( @@ -5265,10 +5372,11 @@ def test_MUTATION_reconciling_on_an_UNREADABLE_history_re_exempts_a_head(tmp_pat status_mode="existing:pending", status_creator=None, status_desc=UNVERIFIED_DESC, - history_mode="premark-page1-error", + history_mode="reconcile-page2-error", + history_extra=seed, mutate=( - '\n if [ "$ph_ok" != yes ] || [ "$witness" -eq 0 ]; then\n', - "\n if false; then\n", + '[ "$ph_ok" != yes ] || [ "$witness" -eq 0 ]; then', + '[ "$witness" -eq 0 ]; then', ), ) assert mutant is not None and mutant["state"] == "success", ( @@ -5498,6 +5606,477 @@ def test_MUTATION_declining_to_replace_an_unreadable_combined_read_leaves_the_fo ) +def test_MUTATION_a_page_2_refusal_that_only_EXITS_leaves_the_head_unmarked(tmp_path): + """The page-2 completeness probe refuses AND replaces (ersatztv#849 round 2). + + It was excluded from the replacement on the reasoning that the probe fires when NO row for this + context was on page 1, so there is no green of any provenance to leave standing. That is + self-contradictory: the only reason page 2 is read is that the row MAY be beyond page 1, which + the probe's own message says. + + `twopage` is a head whose status list runs past one page with no `h10` on either — the shape that + makes "no verdict exists" unestablishable. + """ + posted, r = _run_classify(tmp_path / "fixed", _emitting("docs/a.md"), status_mode="twopage") + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + f"a page-2 refusal left the head unmarked: {posted}\n{r.stdout[-900:]}" + ) + + mutant, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="twopage", + mutate=( + 'replace_unknown_and_die "${CONTEXT} was not on page 1 of the statuses for ${SHA:0:7},' + " but page 2 carries ${more_len} more row(s) — the list is longer than one page, so a" + ' verdict of ANY provenance may be sitting beyond it where this job cannot read it." ;;', + "exit 1 ;;", + ), + ) + assert mutant is None, ( + "the mutant still posted, so this fixture does not reach the replacement through the page-2 " + f"refusal and the assertion above proves nothing about it: {mutant}\n{rm.stdout[-900:]}" + ) + + +def test_MUTATION_an_untrusted_fence_that_only_ABSTAINS_leaves_the_declined_row_current(tmp_path): + """The untrusted-fence branch writes rather than abstains (ersatztv#849 round 2). + + It is reached only AFTER the classification declined to inherit whatever `h10` the head carries — + that is why it is re-deriving — so posting nothing leaves the declined row current, and no + retarget or push need have occurred, so no successor run is guaranteed. Its message used to say + the context "stays absent", which is true only of a head that had none. + """ + posted, r = _run_classify(tmp_path / "fixed", _emitting("docs/a.md"), timeline_mode="unreadable") + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + f"an untrusted fence left the head unmarked: {posted}\n{r.stdout[-900:]}" + ) + + mutant, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + timeline_mode="unreadable", + mutate=( + 'replace_unknown_state "Could not establish a trusted retarget/push count for PR #${PR}' + " (before=${retargets_before_ok}, after=${rt_ok}), so an exemption 'success' cannot be" + " shown to have been computed against the PR's current base, nor at a single head. NOTE a" + " later run only helps if the cause was transient — a PR whose timeline exceeds the page" + ' cap will fail this way on every run, and needs a human verdict."', + ":", + ), + ) + assert mutant is None, ( + f"the mutant still posted, so the replacement does not come from that branch: {mutant}\n{rm.stdout[-900:]}" + ) + + +def test_MUTATION_dropping_the_ROW_ID_from_the_mid_run_comparison_overwrites_another_runs_sentinel( + tmp_path, +): + """Two sentinels are byte-identical by design, so only the row id can tell them apart. + + This run reconciles a pre-existing sentinel away — which is the case the guard must NOT fire on, + and the reason it cannot simply abstain whenever a sentinel is present — and then classifies + docs-only. Between the two reads another run replaces that sentinel with its own. The + description is unchanged, so the state/creator/description triple sees nothing; the id moved. + + Without the id clause the run posts its exemption over a sentinel another run had just written, + which is a marker that something on this head is unchecked being replaced by a status a later run + re-derives. + """ + # THE SEEDED SENTINEL CARRIES THE ID THE COMBINED READ REPORTS AT THE FIRST READ (100), because + # the reconciliation witness now matches that id rather than the description. A seed with an + # unrelated id would make this run carry the sentinel forward instead of reconciling it, and the + # guard under test would never be reached. + seed = [ + {"id": 3900, "context": "ci/other", "status": "success", "creator": None, "description": "unrelated"}, + _sentinel_row(100), + ] + posted, r = _run_classify( + tmp_path / "fixed", + _emitting("docs/a.md"), + status_mode="sentinel-replaced-mid-run", + history_extra=seed, + ) + assert posted is None, ( + f"a sentinel written by another run mid-classification was overwritten: {posted}\n{r.stdout[-900:]}" + ) + assert "written on" in (r.stdout + r.stderr), f"abstained, but silently:\n{r.stdout[-900:]}" + + mutant, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="sentinel-replaced-mid-run", + history_extra=seed, + mutate=( + 'if [ -n "$ex_id" ] && [ -n "$pre_id" ] && [ "$ex_id" != "$pre_id" ]; then', + "if false; then", + ), + ) + assert mutant is not None and mutant["state"] == "success", ( + "the mutant did not overwrite the sentinel, so the id clause is not what stops it and the " + f"assertion above proves nothing about it: {mutant}\n{rm.stdout[-900:]}" + ) + + +def test_MUTATION_removing_the_repair_FLOOR_downgrades_the_repair_sentinel(tmp_path): + """The repair may never write a description weaker than the one this run decided. + + Widening the post-write gate to every write means the block now also runs after a carry-forward + write of `$REPAIR_DESC`. One transient post-write read then rewrote that head with the strictly + weaker, machine-clearable sentinel — reversing the ordering the classification chain states, and + depending on a later reconciliation to put it back. + """ + posted, r = _run_classify( + tmp_path / "fixed", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=REPAIR_DESC, + history_mode="postwrite-page1-error", + ) + seq = _posted_sequence(tmp_path / "fixed") + assert seq and seq[-1]["description"] == REPAIR_DESC, ( + f"the repair sentinel was downgraded on an unreadable post-write read: {seq}\n{r.stdout[-900:]}" + ) + + _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=REPAIR_DESC, + history_mode="postwrite-page1-error", + mutate=(' if [ "$desc" = "$REPAIR_DESC" ]; then repair_desc="$REPAIR_DESC"; fi', " :"), + ) + mseq = _posted_sequence(tmp_path / "mutant") + assert mseq and mseq[-1]["description"] == UNVERIFIED_DESC, ( + "the mutant did not downgrade, so the floor is not what preserves the repair sentinel and " + f"the assertion above proves nothing about it: {mseq}" + ) + + +def test_an_OBSERVED_retarget_MARKS_a_row_this_run_declined(tmp_path): + """Abstaining is a handoff only when there is nothing to hand off (ersatztv#849 round 3). + + The arm is right not to post its CLASSIFICATION — computed against a base the PR may no longer + target — but when the head already carries a row this run DECLINED to inherit, posting nothing + leaves that row authoritative for the whole window until the successor finishes. And in the case + the head-arm's own message names, a PR's FIRST push, no successor is queued at all. + + Here `mallory` is off `$H10_REVIEWERS`, so the existing `success` is declined rather than + inherited, and `moves:0,1` retargets the PR mid-classification. + """ + posted, r = _run_classify( + tmp_path, + _emitting("docs/a.md"), + status_mode="existing:success", + status_creator="mallory", + timeline_mode="moves:0,1", + ) + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + "a declined `success` was left authoritative while this run abstained on an observed " + f"retarget: {posted}\n{r.stdout[-900:]}" + ) + assert "was retargeted while this job was classifying" in (r.stdout + r.stderr), ( + f"marked, but not by the retarget arm:\n{r.stdout[-900:]}" + ) + + +def test_positive_control_an_OBSERVED_retarget_on_an_UNMARKED_head_still_posts_nothing(tmp_path): + """The scoping, asserted rather than assumed. + + On a head that carries nothing there is nothing to leave standing, so the arm must stay silent — + a write there would be noise on the commonest path in this job, and it is also what + `test_a_RETARGET_DURING_the_run_posts_NOTHING` above depends on. + """ + posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), timeline_mode="moves:0,1") + assert posted is None, f"an empty head was marked on an observed retarget: {posted}" + + +def test_MUTATION_not_marking_the_declined_row_leaves_it_authoritative(tmp_path): + """Disarming the scope test, which is the whole decision this helper makes.""" + _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="existing:success", + status_creator="mallory", + timeline_mode="moves:0,1", + mutate=('if [ -z "$pre_state" ]; then return 0; fi', "return 0"), + ) + seq = _posted_sequence(tmp_path / "mutant") + assert seq == [], ( + "the mutant still marked the head, so the scope test is not what produces the write and the " + f"test above proves nothing about it: {seq}" + ) + + +def test_a_MALFORMED_combined_ROW_does_not_kill_the_read_before_anything_can_mark_the_head(tmp_path): + """`.statuses` being an array was checked; its ELEMENTS were not. + + A scalar beside a real row makes an untyped `select(.context == $c)` hard-error, jq exits 5, and + under `set -euo pipefail` the unguarded assignment takes the step down — before any of the paths + that replace an unknown state, and with an off-list `success` still authoritative on the head. + The job goes red, but its own status is not a required check. + + The type-safe filter drops the unreadable element and judges what is left, so the real row is + still found, still declined (`mallory` is off the allow-list), and still re-derived. + """ + posted, r = _run_classify(tmp_path / "fixed", _emitting("docs/a.md"), status_mode="malformed-row-beside-verdict") + assert posted is not None, f"a malformed neighbour row stopped the job writing anything: {r.stdout[-900:]}" + assert posted["state"] == "success" and posted["description"].startswith("Exempt:"), ( + f"expected the off-list row to be re-derived into the docs-only exemption; got {posted}" + ) + + mutant, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="malformed-row-beside-verdict", + mutate=( + '\'[(.statuses // [])[] | select(type == "object") | select(.context? == $c)] | first // {}\') || row=""', + "'[(.statuses // [])[] | select(.context == $c)] | first // {}')", + ), + ) + assert mutant is None and rm.returncode != 0, ( + "the mutant did not die on the malformed row, so the type test is not what keeps this read " + f"alive: {mutant} rc={rm.returncode}" + ) + + +def test_a_generic_PENDING_with_no_mark_also_becomes_the_sentinel(tmp_path): + """The no-mark downgrade covers every re-derivable write, not only the exemption. + + The damaging PR is one that IS exemptible and got the generic `pending` only from a transient + enumeration failure. Its description carries no marker, the post-write check does not run without + a mark, so a verdict landing in the write window is buried and the NEXT run re-derives that + `pending` into the exemption with the human row below its own mark. + + The fixture is that PR: the enumerator exits 1 (generic `pending`) AND the status history cannot + be read (no mark). + """ + posted, r = _run_classify( + tmp_path, + "#!/usr/bin/env bash\n" + DOCS_ONLY + "exit 1\n", + history_mode="premark-page1-error", + ) + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + "a generic `pending` nothing could verify was posted with its re-derivable description " + f"intact: {posted}\n{r.stdout[-900:]}" + ) + + +def test_MUTATION_restoring_the_SUCCESS_only_no_mark_downgrade_leaves_a_re_derivable_pending(tmp_path): + """The exact predecessor from this branch's own previous commit, restored. + + This is the mutation the round-2 suite did not have: the nearest proof mutated the DESCRIPTION + the downgrade writes, not its SCOPE, and its fixture ran a succeeding enumeration — so + `state=success` there and the `success`-only predicate fired identically. Nothing reached the + downgrade with `state=pending`, and the predecessor survived the whole suite. + """ + posted, r = _run_classify( + tmp_path / "mutant", + "#!/usr/bin/env bash\n" + DOCS_ONLY + "exit 1\n", + history_mode="premark-page1-error", + mutate=( + '[ "$max_id_before" -lt 0 ] && [ "$desc" != "$REPAIR_DESC" ]; then', + '[ "$state" = "success" ] && [ "$max_id_before" -lt 0 ]; then', + ), + ) + assert posted is not None and posted["description"] != UNVERIFIED_DESC, ( + "the `success`-only predecessor still wrote the sentinel, so this fixture does not reach the " + f"downgrade with state=pending and the test above proves nothing about its scope: {posted}" + f"\n{r.stdout[-900:]}" + ) + + +def test_MUTATION_dropping_the_no_op_repair_SKIP_re_posts_what_is_already_there(tmp_path): + """The skip, isolated: a repair that would write what this run already wrote is not a repair. + + The fixture reaches it the ordinary way — a carry-forward `$REPAIR_DESC` write whose post-write + walk then fails, so the floor pins `repair_desc` to the description just POSTed. + """ + _run_classify( + tmp_path / "fixed", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=REPAIR_DESC, + history_mode="postwrite-page1-error", + ) + assert len(_posted_sequence(tmp_path / "fixed")) == 1, ( + f"the shipped code re-posted: {_posted_sequence(tmp_path / 'fixed')}" + ) + + _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=REPAIR_DESC, + history_mode="postwrite-page1-error", + mutate=('if [ "$raced" -gt 0 ] && [ "$repair_desc" = "$desc" ]; then', "if false; then"), + ) + mseq = _posted_sequence(tmp_path / "mutant") + assert len(mseq) == 2 and mseq[0] == mseq[1], ( + "the mutant did not duplicate the row, so the skip is not what suppresses it and the " + f"assertion above proves nothing about it: {mseq}" + ) + + +def test_MUTATION_dropping_the_OWN_row_exclusion_reports_a_race_that_did_not_happen(tmp_path): + """`--arg own "$desc"` keeps this job from counting the row it has just written. + + The two exclusions are OUTCOME-redundant with the no-op skip above — drop one and the other + still suppresses the duplicate POST — which is exactly the shape where two guards hide each + other. What the exclusion alone decides is the REPORT, and after the skip learned to keep the + human `::error::` that report is a false alarm: a run whose own carry-forward row is counted + tells a reviewer their verdict was overwritten when nothing raced it. + + So this asserts the log, not the post sequence, and that is the honest discriminator rather than + a weaker one. The head carries `$REPAIR_DESC`, the history is otherwise empty (mark 0), and this + run's own POST lands above the mark. + """ + _, r = _run_classify( + tmp_path / "fixed", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=REPAIR_DESC, + ) + # `::error::A human` and not the bare phrase "was overwritten": the classification's own REASON + # string for a carry-forward run also contains that phrase, so matching it would report the + # shipped code as failing on text it is supposed to print. + assert "::error::A human" not in (r.stdout + r.stderr), ( + f"the shipped code reported a race against its own row:\n{r.stdout[-900:]}" + ) + + _, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=REPAIR_DESC, + mutate=( + 'or ((.creator == null) and ((.description // "") == $rd)\n' + ' and ((.description // "") != $own))', + 'or ((.creator == null) and ((.description // "") == $rd))', + ), + ) + assert "::error::A human" in (rm.stdout + rm.stderr), ( + "the mutant did not report a false race, so the `$own` exclusion on the repair-sentinel arm " + f"is not what prevents it:\n{rm.stdout[-900:]}" + ) + + +def test_MUTATION_dropping_the_OWN_exclusion_on_the_UNVERIFIED_arm_reports_a_phantom_other_run(tmp_path): + """The twin, on the arm that counts the reconcilable sentinel. + + A run carrying the unverified sentinel forward POSTs it, then its own row sits above the mark. If + the arm does not exclude it, the job reports that ANOTHER run recorded an unverified write on this + head — a second run that does not exist. + """ + _, r = _run_classify( + tmp_path / "fixed", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=UNVERIFIED_DESC, + ) + assert "another run recorded an unverified write" not in (r.stdout + r.stderr), ( + f"the shipped code reported a phantom second run:\n{r.stdout[-900:]}" + ) + + _, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=UNVERIFIED_DESC, + mutate=( + 'select((.creator == null) and ((.description // "") == $ud)\n' + ' and ((.description // "") != $own))] | length\')', + 'select((.creator == null) and ((.description // "") == $ud))] | length\')', + ), + ) + assert "another run recorded an unverified write" in (rm.stdout + rm.stderr), ( + "the mutant did not report a phantom run, so the `$own` exclusion on the unverified arm is " + f"not what prevents it:\n{rm.stdout[-900:]}" + ) + + +def test_a_FAILED_sentinel_write_on_the_fence_path_FAILS_the_job(tmp_path): + """The write helper reports whether it wrote, and the fence caller acts on it. + + Its first version ended the failure arm with a successful `echo`, so it returned 0 after BOTH + POST attempts failed and the caller's `exit 0` beside it reported an abstention that had not + happened — while whatever the head carried stayed authoritative. + """ + posted, r = _run_classify(tmp_path / "fixed", _emitting("docs/a.md"), timeline_mode="unreadable", post_fails=True) + assert posted is None, "the stub was supposed to reject every POST" + assert r.returncode != 0, ( + "the job reported a clean abstention while the head was left unmarked and its POSTs had all " + f"failed:\n{r.stdout[-900:]}" + ) + assert "COULD NOT WRITE THE UNVERIFIED SENTINEL" in (r.stdout + r.stderr), ( + f"the job went red, but not because the sentinel write failed:\n{r.stdout[-900:]}" + ) + + +def test_MUTATION_ignoring_the_write_result_reports_a_clean_abstention(tmp_path): + """The predecessor: `replace_unknown_state` followed by an unconditional `exit 0`.""" + _, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + timeline_mode="unreadable", + post_fails=True, + # MUTATES THE CALLER'S REACTION, not the `if` itself: dropping the `if` keyword leaves a + # dangling `then`/`fi` and the step dies on a syntax error, which is a red for the wrong + # reason. Turning the failure exit into a clean one is exactly the predecessor's OUTCOME and + # isolates the decision. + mutate=( + " # THE SENTINEL WRITE FAILED, so nothing marked this head and whatever it carries is still\n" + " # authoritative. Exiting 0 here would report an abstention that did not happen.\n" + " exit 1", + " exit 0", + ), + ) + assert rm.returncode == 0, ( + "the mutant still failed the job, so the caller is not what turns a failed write into a red " + f"run and the test above proves nothing about it: rc={rm.returncode}\n{rm.stdout[-900:]}" + ) + + +def test_an_ID_that_appears_on_only_ONE_read_is_not_a_replacement(tmp_path): + """One response omitting `id` beside one that includes it is not evidence of a mid-run write. + + The row, its state, its creator and its description are all unchanged; only the SERVER's + reporting differs. Treating that as a replacement makes the run abstain — leaving current a row + the classification had already declined to inherit, which is the direction that costs something. + """ + posted, r = _run_classify(tmp_path / "fixed", _emitting("docs/a.md"), status_mode="id-appears-on-second-read") + assert posted is not None, f"an asymmetric id report was read as a mid-run replacement:\n{r.stdout[-900:]}" + assert posted["state"] == "success" and posted["description"].startswith("Exempt:"), ( + f"expected the off-list row to be re-derived into the docs-only exemption; got {posted}" + ) + + +def test_MUTATION_comparing_ids_WITHOUT_requiring_both_makes_the_run_abstain(tmp_path): + """Disarming the presence guards alone, leaving the inequality.""" + mutant, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="id-appears-on-second-read", + mutate=( + 'if [ -n "$ex_id" ] && [ -n "$pre_id" ] && [ "$ex_id" != "$pre_id" ]; then', + 'if [ "$ex_id" != "$pre_id" ]; then', + ), + ) + assert mutant is None, ( + "the mutant did not abstain, so the both-present requirement is not what keeps an asymmetric " + f"id report from reading as a replacement: {mutant}\n{rm.stdout[-900:]}" + ) + + # --- Workflow token scope (ersatztv#748) ------------------------------------------------------- # # Both assertions guard a property whose violation is SILENT and, for the first, unrecoverable. -- 2.47.3 From 28e82fcb5952102928e23e0aae21a6a47361100c Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 30 Aug 2026 02:36:20 +0200 Subject: [PATCH 04/11] =?UTF-8?q?fix(849):=20round=204=20=E2=80=94=20a=20r?= =?UTF-8?q?egression=20round=203=20introduced,=20and=20the=20clauses=20it?= =?UTF-8?q?=20left=20unproven?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A third cold review, which ran the mutants itself, found one measured direction regression against `origin/main`, one ordering inversion, and four clauses this branch claims as fixes that survived mutation of their own text. ## The regression Round 3 type-tested the four consumed fields of the existing `h10` row and resolved a failure to `""`. For `.creator` that means "no creator" — unattributable — which is a LICENCE TO RE-DERIVE. Measured, same fixture, both bodies: a head carrying `h10=failure` with `"creator": 7` posts `Exempt: docs-only change` here and posted NOTHING on `main`, which died on `.creator.login` before any write. Fail-closed became fail-open. The rationale that produced it came from #763, whose site is the POST-WRITE filter: there, dying leaves a green already published, so dropping the row is the safe direction. Here the alternative is dying BEFORE any write. The deferral rationale did not transfer — which is the shape this repo has a record for. A wrong TYPE is now distinguished from a legitimately ABSENT value: `null` is the machine creator, an unset description and every field of the `{}` no-verdict row; anything else is unknown state and takes the route an unreadable ELEMENT already took. ## The ordering inversion `mark_declined_row_if_any` was scoped to "the head carries any row", so it fired on a head carrying `$REPAIR_DESC` and replaced the human-only marker with the machine-clearable one — inverting the ordering the SAME commit added a floor to protect at the repair site. One mechanism, three writers, and only two had the rule. It also buried a verdict an ALLOW-LISTED reviewer wrote for another base. "Declined" is decided against this event's `$BASE_REF`, so such a row is still the right answer for the base it names and the successor run for that base short-circuits on it; burying it costs a manual re-post on an ordinary retarget-onto-the-reviewed-base flow. Membership is tested on the raw creator, not on `ex_human`, which the base check has already cleared — the question is who wrote the row, not whether it governs this diff. ## The unproven clauses Four claims survived mutation, including the headline one. The witness fixture had been designed AROUND its own discriminator — its comment said a seed with an unrelated id "would make this run carry the sentinel forward … and the guard under test would never be reached", which is a description of the test not reaching it. Eleven proofs added, covering the witness-by-id, the head arm's own call site (two callers of one helper, one fixture), the mark helper's result propagation, and the round-4 behaviour above. `raced_why`'s human value is a named constant now: it is the one such value that is also a PREDICATE, compared twice, and a drift in either copy silently downgrades the human `::error::` — the only message that tells a reviewer their verdict was buried. ## Docs The renamed sentinel literal in two places; three documents still asserting the fence "writes NOTHING"; the record's `mechanics:` still describing round 2's witness; the replacement-site list, which had grown by four; a residual pointing "below" at something above it; and `CLAUDE.md`'s "closed", which is stronger than the record it points at — that record lists six residuals including both endpoints failing at once. The proof inventory is stated as an invariant (every clause with a predecessor is mutated back to it) rather than a count that rots. refs #849 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF --- .gitea/workflows/review-verdict.yml | 63 ++++- docs/ci-cd.md | 9 +- docs/decisions/README.md | 2 +- .../ci/verdict-unverified-write-sentinel.md | 2 +- .../ci/verdict-write-retarget-fence.md | 4 +- docs/guard-inventory.md | 2 +- docs/remote-state-inventory.md | 2 +- scripts/tests/test_pr_changed_files.py | 254 +++++++++++++++++- 8 files changed, 318 insertions(+), 20 deletions(-) diff --git a/.gitea/workflows/review-verdict.yml b/.gitea/workflows/review-verdict.yml index 2c0fc2c20..36648c1ac 100644 --- a/.gitea/workflows/review-verdict.yml +++ b/.gitea/workflows/review-verdict.yml @@ -286,6 +286,12 @@ jobs: # the old text does not recognise it as a sentinel, re-derives it, and writes the new one. # Nothing in production carries either string yet, so the transition is free here. UNVERIFIED_DESC="Status write could not be verified — re-post the verdict" + # THE ONE `raced_why` VALUE THAT IS ALSO A PREDICATE. The others are prose the log prints; + # this one is compared twice, so a drift in either copy silently downgrades the human + # `::error::` — the one message that tells a reviewer their verdict was buried — to the + # generic "could not verify" text. Named for the same reason the two descriptions beside it + # are named. + RACED_BY_HUMAN="a human verdict landed in the write window" # WHOSE verdict may be INHERITED (ersatztv#742). Space-separated logins, compared exactly. # # The test this replaces was "the status has a non-null `.creator.login`", which only proves @@ -448,7 +454,7 @@ jobs: replace_unknown_state() { # $1 = the ::error:: naming what could not be established echo "::error::$1" if repair_status_to "$UNVERIFIED_DESC"; then - echo "::error::Replaced ${CONTEXT} on ${SHA:0:7} with the unverified-write sentinel rather than leaving a state this job could not read standing. A later run will either clear it, or — if it finds a verdict underneath — ask you to re-post that verdict. To settle it now: scripts/post-review-verdict.sh ${PR} " + echo "::error::Replaced ${CONTEXT} on ${SHA:0:7} with the unverified-write sentinel rather than leaving the status it carried standing. A later run will either clear it, or — if it finds a verdict underneath — ask you to re-post that verdict. To settle it now: scripts/post-review-verdict.sh ${PR} " return 0 fi echo "::error::COULD NOT WRITE THE UNVERIFIED SENTINEL to ${SHA:0:7}. Whatever ${CONTEXT} this head carries is standing unread. Post a verdict immediately: scripts/post-review-verdict.sh ${PR} " @@ -679,14 +685,36 @@ jobs: # 736649b3 returned 8 rows keyed id/context/status/creator/description/created_at/ # updated_at/url, ids 14..30 ascending. `// ""` so a server that ever stopped sending it # degrades to the pre-existing text comparison rather than to a false "changed". - ex_id=$(printf '%s' "$row" | jq -r 'if (.id | type) == "number" then (.id | tostring) else "" end') - ex_state=$(printf '%s' "$row" | jq -r 'if (.status | type) == "string" then .status else "" end') + # A WRONG TYPE IS NOT AN ABSENT VALUE (ersatztv#849 round 4). Round 3 type-tested these + # four fields and resolved a failure to `""` — which for `.creator` means "no creator", + # i.e. unattributable, i.e. RE-DERIVE. That turned a corrupt row into an exemption + # `success` where `origin/main` had died before writing anything: measured, a head + # carrying `h10=failure` with `"creator": 7` posts `Exempt: docs-only change` here and + # posted NOTHING on main. The rationale was transplanted from #763, whose site is the + # POST-WRITE filter — there, dying leaves a green already published, so dropping the row + # is the safe direction; HERE the alternative is dying BEFORE any write, which is + # fail-closed. The deferral rationale did not transfer. + # + # So the three outcomes are kept distinct: a value of the right type, a legitimately + # ABSENT one (`null` — an unset `.description`, the machine `creator`, and every field of + # the `{}` no-verdict row), and a type the schema does not allow, which is unknown state + # and takes the same route an unreadable ELEMENT already takes two lines up. + # + # The token cannot collide with a real value: no Gitea status field contains a NUL. + SCHEMA_FAULT=$'\001schema-fault' + ex_id=$(printf '%s' "$row" | jq -r --arg f "$SCHEMA_FAULT" 'if (.id | type) == "number" then (.id | tostring) elif (.id | type) == "null" then "" else $f end') + ex_state=$(printf '%s' "$row" | jq -r --arg f "$SCHEMA_FAULT" 'if (.status | type) == "string" then .status elif (.status | type) == "null" then "" else $f end') # `.creator.login` HARD-ERRORS on any non-object creator — the same defect #763 fixed in # the post-write filter, still live on this read. `(.creator | type)` short-circuits it, # so a malformed row reads as "no creator" (unattributable, hence re-derived) instead of # killing the step after a `success` is already standing. - ex_creator=$(printf '%s' "$row" | jq -r 'if (.creator | type) == "object" then (.creator.login // "") else "" end') - ex_desc=$(printf '%s' "$row" | jq -r 'if (.description | type) == "string" then .description else "" end') + ex_creator=$(printf '%s' "$row" | jq -r --arg f "$SCHEMA_FAULT" 'if (.creator | type) == "object" then (.creator.login // "") elif (.creator | type) == "null" then "" else $f end') + ex_desc=$(printf '%s' "$row" | jq -r --arg f "$SCHEMA_FAULT" 'if (.description | type) == "string" then .description elif (.description | type) == "null" then "" else $f end') + for _f in "$ex_id" "$ex_state" "$ex_creator" "$ex_desc"; do + if [ "$_f" = "$SCHEMA_FAULT" ]; then + replace_unknown_and_die "A field of the ${CONTEXT} row on ${SHA:0:7} carries a type the schema does not allow, so this row can neither be read nor judged — and reading it as an absent value would make it unattributable, which is a licence to re-derive it." + fi + done # A `case` prefix test rather than grep: the description is a single short string, and this # removes one more pipeline from a security predicate entirely. The PATTERN is a literal, so # there is no glob-injection concern from $ex_desc. @@ -1770,8 +1798,25 @@ jobs: # Scoped to `pre_state` being non-empty: on a head that carried nothing, abstaining leaves # nothing, and a write there would be noise on the commonest path in this job. mark_declined_row_if_any() { # $1 = the ::notice:: this arm already emitted + local rv if [ -z "$pre_state" ]; then return 0; fi - replace_unknown_state "$1 This head already carried a ${CONTEXT} that this run declined to inherit, so leaving it alone would keep it authoritative until a successor finishes — and for a PR's FIRST push no successor is queued at all." || return 1 + # NEVER WEAKEN THE REPAIR SENTINEL. `replace_unknown_state` writes the machine-clearable + # one, and a head carrying `$REPAIR_DESC` has a non-empty `pre_state`, so the bare scope + # test fired on it — inverting the ordering this same change added a floor to protect at + # the repair site. One mechanism, three writers, and only two had the rule. + if [ "$desc" = "$REPAIR_DESC" ] || [ "$pre_desc" = "$REPAIR_DESC" ]; then return 0; fi + # NEVER BURY A ROW AN ALLOW-LISTED REVIEWER WROTE. "Declined" is decided against THIS + # event's `$BASE_REF`, so a genuine verdict recorded for another base is declined here and + # is still the right answer for the base it names — the successor run for that base + # short-circuits on it. Burying it costs a manual re-post on an ordinary + # retarget-onto-the-reviewed-base flow. Membership is tested on the raw creator rather + # than on `ex_human`, which the base check has already cleared: the question here is who + # wrote the row, not whether it governs this diff. An off-list row is exactly what this + # marking exists for, and is left to it. + for rv in $H10_REVIEWERS; do + if [ "$rv" = "$pre_creator" ]; then return 0; fi + done + replace_unknown_state "$1 This head already carried a ${CONTEXT} that this run did not inherit, so leaving it alone would keep it authoritative until a successor finishes — and for a PR's FIRST push no successor is queued at all." || return 1 return 0 } count_pr_mutations @@ -1929,7 +1974,7 @@ jobs: # `raced_why` IS OPERATOR-FACING, so its initial value is a sentence rather than the bare # token `human`. The skip below prints it, and a log line reading "(human)" says nothing # to whoever has to act on it. - raced_why="a human verdict landed in the write window" + raced_why="$RACED_BY_HUMAN" repair_desc="$REPAIR_DESC" if [ "$ph_ok" = yes ] && [ "$ph_len_after" -eq 0 ]; then echo "::warning::The status history for ${SHA:0:7} came back empty after this job posted to it, which cannot be true, so a raced verdict could not be ruled out. Repairing ${CONTEXT} to pending." @@ -2125,7 +2170,7 @@ jobs: # the strongest marker this job writes — but the `::error::` below is the only place a # reviewer is told their verdict was buried and given the command to re-post it, and an # early `raced=0` used to swallow it. What is skipped is the WRITE, not the report. - if [ "$raced_why" = "a human verdict landed in the write window" ]; then + if [ "$raced_why" = "$RACED_BY_HUMAN" ]; then echo "::error::A human ${CONTEXT} verdict landed on ${SHA:0:7} while this job was writing its '${state}' status, and was overwritten. ${CONTEXT} already carries the strongest marker this job writes, so nothing is re-posted — but the verdict itself still needs re-posting: scripts/post-review-verdict.sh ${PR} " else echo "::notice::Post-write verification for ${SHA:0:7} could not clear the write window (${raced_why}), and the repair would write the description this run already POSTed, so nothing is re-posted. If another run has written since, its row stands — a convergence on its answer, not a silent loss: both sentinels block the merge." @@ -2143,7 +2188,7 @@ jobs: # STATE-NEUTRAL WORDING since ersatztv#849: this block now also runs after a `pending` # write, so a message naming "this exemption write" would be wrong on the very path # that was added, and wrong in the direction of understating what happened. - if [ "$raced_why" = "a human verdict landed in the write window" ]; then + if [ "$raced_why" = "$RACED_BY_HUMAN" ]; then echo "::error::A human ${CONTEXT} verdict landed on ${SHA:0:7} while this job was writing its '${state}' status, and was overwritten. Downgrading to 'pending' so an explicit human decision cannot be silently green, and marking the head so a later run cannot re-derive it. Re-post it with: scripts/post-review-verdict.sh ${PR} " else echo "::error::Could not verify that no human ${CONTEXT} verdict raced this job's '${state}' write on ${SHA:0:7} — ${raced_why}. Downgrading to 'pending' rather than leaving an unverified write standing; no verdict was necessarily overwritten. Clear it with: scripts/post-review-verdict.sh ${PR} " diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 0be8aaf80..c4c9d8d7b 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -1691,7 +1691,7 @@ payload, which a retarget cannot rewrite, and `edited` is in `types:` so a retar `success` after the reclassifying run posted `pending`. **That residual is now fenced — NARROWED, not resolved (ersatztv#706; correction ersatztv#849).** The pre-write fence does not re-count *after* the POST, so a retarget landing between its final pre-write count and the write used to yield a **permanent** forged green: the successor run consumes the `edited` event and exits on the existing status, and the stale run then posts last with nothing left to correct it. Since ersatztv#849 a SEPARATE post-POST re-count (below) makes that green transient rather than permanent. Runs are still not serialized — instead a run that was -overtaken *declines to write*. The job counts `change_target_branch` events on the PR's issue timeline +overtaken *declines to write its classification*. The job counts `change_target_branch` events on the PR's issue timeline at start and again immediately before its POST, and posts **nothing** if the count moved. The count is the key precisely because the branch *name* is ABA-vulnerable: `main → scratch → main` reads `main` at both ends, which is how the forged exemption was obtained in the first place. Abstaining never strands @@ -1759,7 +1759,7 @@ interchangeable (`ci.verdict-unverified-write-sentinel`): - `Human verdict raced this exemption write — re-post the verdict` **asserts** that a verdict existed and a write buried it. Only the arm that actually *counted* such a row may claim it, and only a human re-posting clears it. -- `Exemption write could not be verified — re-post the verdict` states only what was established — +- `Status write could not be verified — re-post the verdict` states only what was established — that the write could not be checked. Every "could not check" arm writes this one: an unreadable or over-cap history, an impossible empty history, an unusable count, no high-water mark at all, a post-POST retarget, and an unreadable combined-status read. It is equally sticky, and additionally @@ -1775,8 +1775,9 @@ re-derives; `$REPAIR_DESC` is not, being the stronger fact. **Every path that cannot establish what the head carries REPLACES the unknown state** rather than merely declining to write: the combined read (retried once first), all four page-2 completeness -refusals, and the fence branch that cannot trust its retarget count while holding a derived -`success`. Declining protects a real verdict and leaves a *forged* one standing, which is what an +refusals, a row or consumed field whose type the schema does not allow, the path-predicate failure, +the fence branch that cannot trust its retarget count while holding a derived `success`, and the two +OBSERVED-mutation arms when the head carries a row the run did not inherit. Declining protects a real verdict and leaves a *forged* one standing, which is what an off-list `success` is; the job went red on a status branch protection does not read, and the fence branch was reached only after the classification had DECLINED to inherit the very row it then left current. Nothing is destroyed — `/statuses/{sha}` keeps one row per POST, so the next run's diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 104146e99..f2b4eb3c3 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -69,7 +69,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `ci.toolchain-image-publish-is-a-dispatch` | A `push` trigger reachable from any ref other than `main` — that is `branches:` AND `tags:`, judged by the ref class it admits and never by which keyword is present — executes ref-supplied YAML, because Gitea resolves a `push` workflow's definition from the pushed ref — so `ci-image.yml` is `branches: [main]`, and publishing a toolchain image from a feature branch is a deliberate `workflow_dispatch` on that branch rather than a side effect of pushing. Be precise about what a `branches:` filter buys: it is loaded from the pushed ref like the rest of the file, so a branch that deletes it re-enables the route — this removes the DRIVE-BY case, and is not a boundary against a writer who intends to run their own YAML. The self-referencing trigger path `.gitea/workflows/ci-image.yml` came out of BOTH its own `paths:` and `ci-image-pin`'s `expected` in the same change — a DECIDED tradeoff, not a necessity: keeping it is workable via the branch dispatch, but prices every edit to that file, comments included, at a ~2GB publish plus a five-pin bump, redone after every rebase. The cost is stated, not assumed away — a change to HOW the image is built that lives only in `ci-image.yml` no longer republishes, and the ONLY remedy is to make it alongside a `docker/ci/**` edit: publishing after the merge and then pinning cannot work, because `expected` is the last `docker/ci` commit and would reject that pin. This closes the push route INTO THIS FILE, not the class: `docker-build.yml` remains reachable from an arbitrary ref by a `v*` tag push and by `pull_request`, and four workflows carry an unrestricted `workflow_dispatch`. The DISPATCH THIRD of that is settled — #853 probed it and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`): no ref restriction exists at Gitea 1.27.1, and restricting it would close nothing anyway, because the head-resolved `pull_request:` route runs attacker-authored YAML that reaches every secret in the store. The `v*` tag push and `pull_request:` rows are NOT settled and remain open in #885. Do not re-derive any of this. | 2026-08-27 | [link](records/ci/toolchain-image-publish-is-a-dispatch.md) | | `ci.ui-e2e-harness` | The UI-interactive E2E flows run as headless Playwright specs (`web/e2e/*.spec.ts`, driven by `scripts/e2e-ui.sh`) in a **second step of the existing advisory `functional-e2e` job**, never their own job; the browser is `chromium-headless-shell` **baked into the CI toolchain image** (`docker/ci/Dockerfile`, `PLAYWRIGHT_VERSION` kept equal to `web/package.json`'s EXACT `@playwright/test` pin), never installed per run; specs are `serial` with `retries: 0` and assert only contracts the curl harness structurally cannot reach. | 2026-07-25 | [link](records/ci/ui-e2e-harness.md) | | `ci.verdict-unverified-write-sentinel` | The `review-verdict/h10` job runs its post-write race check after EVERY status write, not only an exemption `success`, and where no mark exists to run it against, the write ITSELF becomes the sentinel — so every status this job writes is either verified against the history or marked as unverifiable: a GENERIC `pending` masks a rejection landing in its own write window exactly as a `success` does, and because it carries no marker a later run re-derives it into an exemption with the human row now below THAT run's high-water mark — the damage arrives one event later, not never. Where a write cannot be checked at all the job writes a SECOND sentinel, `UNVERIFIED_DESC`, distinct from the repair sentinel and never interchangeable with it: the repair sentinel ASSERTS that a verdict existed and was buried, which only the arm that actually COUNTED such a row may claim, while every "could not check" arm — an unreadable or over-cap history, an impossible empty history, an unusable count — states only what it established. Both are STICKY (the classification refuses to grant an exemption over either, and re-writes its own description verbatim, so each is a FIXED POINT a later run cannot re-derive into `success`); only the unverified one is RECONCILABLE. Reconciliation is what bounds the stall that got the #742 attempt withdrawn: a later run pages `/statuses/{sha}` IN FULL and, ONLY IF that history contains the sentinel's own row, either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it UPGRADES to the repair sentinel, clearable only by a human — or finds none, resolving the uncertainty and clearing it so the PR is classified normally. The reconciliation is sound because the two endpoints differ: a verdict this job masked is invisible on the COMBINED endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. It is deliberately BROADER than the inheritance test — no `H10_REVIEWERS` membership, no `(base: …)` match — because over-counting upgrades to a stall a human clears in one command while MISSING one re-exempts a head carrying a buried rejection. ONLY a COMPLETE walk CONTAINING THE CURRENT SENTINEL ROW — matched by the `id` the combined read reported, not merely by a row with the same description — may clear it. Description alone is satisfied by an OLDER identical sentinel, which is exactly what a fixed point produces: with an earlier sentinel and a buried verdict below the current one, a read carrying only the earlier row satisfies the test, the sentinel clears, and the verdict ends up below the fresh mark. Where the server omits `id` there is nothing to match on and the check degrades to the description, which is the pre-existing behaviour rather than a new hole. The witness is not optional decoration: `ex_unverified` means the combined endpoint just returned that row, and `/statuses/{sha}` keeps one row per POST, so a complete history WITHOUT it — an empty one included — contradicts a write that demonstrably happened. `page_statuses` accepts an empty page 1 as complete (correct for a first-run mark), which is what made that shape reachable, and clearing on it exempted a head whose sentinel may have been sitting on a rejection. This is NOT the withdrawn currency witness: that asked whether ANY row sat above the mark, which an unrelated row satisfied, and it made one anomaly permanent — this asks for a SPECIFIC row already known to exist. Failing it costs one run in the transient case; it is NOT bounded in general, because a history past the 20-page cap can never be walked completely, so such a head needs a human verdict and no later run will clear it. When NO high-water mark can be established the write is withheld BEFORE the POST rather than posted and repaired, since the defect is known in advance and publishing a green to take it back opens a window branch protection — and an already-scheduled auto-merge — can see. EVERY re-derivable state is downgraded there, not only `success`: an earlier draft restricted it to the exemption on the grounds that a sticky generic `pending` "withholds nothing, since an unreviewed PR is blocked already", which analysed the wrong PR — the damaging case is one that IS exemptible and got the generic `pending` only from a transient enumeration failure, whose unmarked description the next run re-derives into the exemption with the human row below its own mark. `$REPAIR_DESC` is the one exemption, being the stronger fact and not re-derivable. SEPARATELY, the retarget count is re-taken AFTER the POST on the exemption path, which closes the PERMANENT forged green `ci.verdict-write-retarget-fence` recorded as its residual: a retarget landing after the final pre-write count leaves a stale `success` with the `edited` event already consumed by a successor that short-circuited, so nothing remains to reclassify. Re-counting afterwards means a retarget later than the re-check necessarily queues a successor that STARTS after the stale `success` exists, and a machine-written `success` is re-derived rather than inherited. The RETARGET axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. An untrusted post-POST count repairs too — reaching that point with `success` means both earlier counts were trusted, so a third unreadable read is a fresh failure and "I cannot tell whether the base moved" must not resolve to leaving a green. FINALLY, every path that cannot establish what the head carries REPLACES the unknown state instead of merely declining to write — the combined read (retried once first), all four page-2 completeness refusals, and the fence branch that cannot trust its retarget count while holding a derived `success`. That last one is reached only AFTER the classification declined to inherit the row the head carries, so posting nothing left the declined row current; its message used to say the context "stays absent", which is true only of a head that had none. The two OBSERVED-mutation arms take the same decision for the same reason, scoped to a head that actually carries a declined row: they still refuse to post their CLASSIFICATION, which was computed against a base or head the PR may no longer have, but a row this run declined must not stay authoritative for the whole window until a successor finishes — and for a PR's FIRST push no successor is queued at all. Every element and every consumed field of the combined response is TYPE-CHECKED before extraction, and a schema failure routes to the same replacement rather than dying: `.statuses` being an array was checked and its ELEMENTS were not, so one scalar made `select(.context == $c)` hard-error and `set -e` took the step down before any path could mark the head. The path-predicate failure replaces too. And the write helper RETURNS a status: its first version ended the failure arm with a successful `echo`, so it reported 0 after both POSTs failed and the fence caller exited 0 as though the head had been marked. Declining protects a real verdict and leaves a FORGED one — an off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving it, and an unreadable read is the one thing that stops it while the job goes red on a status branch protection does not read. Nothing is destroyed: the per-POST history keeps the masked verdict and the next run's reconciliation upgrades to the repair sentinel, telling the reviewer to re-post rather than silently un-approving them. The page-2 refusals were briefly excluded on the reasoning that the probe fires when NO row for this context was on page 1, so there is no green of any provenance to leave standing — self-contradictory, since the ONLY reason page 2 is read is that the row MAY be beyond page 1. WRITING THE SENTINEL AND FAILING THE JOB ARE SEPARATE DECISIONS: the read refusals were already non-zero exits on `main` and stay red, while the fence branch exited 0 there and still does, because an unreadable timeline is an ordinary hiccup and reddening every one of them is noise this file elsewhere refuses to add. The repair also has a FLOOR — it may never write a description weaker than the one this run decided, or a transient post-write read rewrites a correct `$REPAIR_DESC` carry-forward with the machine-clearable sentinel — and it is skipped entirely when it would rewrite what is already there. | 2026-08-29 | [link](records/ci/verdict-unverified-write-sentinel.md) | -| `ci.verdict-write-retarget-fence` | The `review-verdict/h10` job counts BOTH `change_target_branch` AND `pull_push` events on the PR's issue timeline at run start and again immediately before its POST, and writes NOTHING if EITHER count moved. The COUNT is the key on both axes because the underlying VALUE is ABA-vulnerable — `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a forged exemption, and a force-push `H1 -> H2 -> H1` leaves `.head.sha` equal at both ends while the middle pages of `scripts/pr-changed-files.sh`'s enumeration came from `H2` (#803/#664) — while an event count is monotonic and cannot alias. ONE walk certifies BOTH counts, so an unreadable page abandons both; the two tallies are separate so the diagnostic names the axis that actually moved. Every push is counted and `is_force_push` is deliberately NOT read: an ordinary push also invalidates a mid-flight enumeration, and an `H1 -> H2 -> H1` restoration can have its second push non-forced when `H1` is an ancestor. The head fence does NOT abstain on its own triggering push — established FROM THE v1.27.1 SOURCE, since that would be a permanent stall rather than a fence: the push comment is created BEFORE the synchronize notification is emitted, so a run always sees its own causative event, and retries add no new event. The 26-102s margin measured across 20 triggered pairs on PRs #802/#834/#761 corroborates it and is a lower bound (the job runs a checkout first; 69s end to end on run 2385) — it is NOT the basis of the claim, which an earlier draft said it was. This NARROWS the residual rather than resolving it, and what CLOSED the permanent case is a separate mechanism recorded at `ci.verdict-unverified-write-sentinel`: the retarget count is re-taken AFTER the POST, so a retarget between the final pre-write count and the POST — which used to leave a PERMANENT forged green, the successor having consumed the `edited` event and exited before the stale run posted last — is now caught by the writing run itself, and one landing after the re-check necessarily queues a successor that starts with the stale `success` already visible and re-derivable (corrected 2026-08-27, closed 2026-08-29, #849). Abstaining is a handoff, not a stall, and that is the property the design rests on — for a run that ABSTAINS; it says nothing about one that already passed its final count and then posts (see the residual, corrected #849): 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 — but ONLY over runs that ABSTAIN. A run that already passed its final count is outside it: it writes whenever it gets there, so the run that writes LAST is not necessarily the one that classified last. That was the permanent residual until #849 gave the writing run a post-POST re-count of its own (`ci.verdict-unverified-write-sentinel`), which puts such a run back inside the induction — it either withdraws its own stale write or leaves a green a guaranteed successor re-derives. `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` blocks the merge immediately while withholding it would strand ordinary PRs whenever the timeline is unreadable — the right trade, but NOT a free one ("for no safety gain" retracted 2026-08-27): a GENERIC `pending` masks a rejection landing in its own write window just as a `success` does. What made that DURABLE — post-write verification skipping it, so a later run re-derived it into an exemption `success` — is closed since 2026-08-29: the check now runs after EVERY write (`ci.verdict-unverified-write-sentinel`), so such a write is repaired to the sticky repair sentinel and cannot be re-derived. The masking itself is still a cost, and a write the mark cannot cover is still unverified; that is what the second sentinel is for. SEPARATELY, and for the human-verdict race the fence does nothing about: after posting ANY status — every write since #849, not only an exemption `success` — the job re-reads `/statuses/{sha}` IN FULL (PAGED since #763) 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. BOTH `/statuses/{sha}` reads PAGE to a validated EMPTY page since #763 — `[]` on this endpoint, a THIRD terminator shape distinct from the timeline's bare `null` and the combined endpoint's `{"statuses": null}`, so each terminator is MEASURED per endpoint — never terminating on a SHORT page, retrying each page once, under a 20-page cap that validates at most 950 rows (the 20th request must be the empty terminator); correctness does not depend on the measured cap of 50, because any page size pages correctly. WHAT THE PAGING BUYS IS NOT WHAT #763 CLAIMED: under the DESC default page 1 already held the true maximum id AND every row newer than the mark, so a single-page read missed a raced verdict only if more than 50 rows were created INSIDE the write window — not merely on a head over 50 rows. What removed #761's stall is retiring the probe, not the walk; the walk's value is that the gate's one fail-toward-SUCCESS path no longer depends on an UNDOCUMENTED ordering the server honours only coarsely (page 1 came back `114,112,113,111,110`). This RETIRED #751's conservative page-2 probe, which treated "there are rows I did not read" as "assume raced" and so repaired every head that outgrew one page: it fired on Renovate PR #761, reporting a human verdict as overwritten when none existed and then, the sentinel being sticky, refusing to re-exempt that head on every later run. The two directions are NOT symmetric. POST-WRITE, uncertainty fails CLOSED — an unreadable history, an over-cap history, or a count that is not a number all repair to `pending`; previously an unreadable history warned and left the exemption green while the page-2 probe repaired on the same uncertainty, one check disagreeing with itself. PRE-WRITE, a PARTIAL list still yields a mark, because the mark gates the post-write check entirely and refusing one SKIPS that check, which is itself the fail-open; this rests on the DESC DEFAULT — the newest row, carrying the maximum id, is on page 1, so a walk that fails later still saw it — while a VALIDATED empty history is NOT abandoned (it yields a mark of 0, correct for a first run, since every later row is newer) — what abandons the mark is a read that both FAILED and returned nothing — which since 2026-08-29 WITHHOLDS the exemption before the POST and marks the head with the reconcilable sentinel, rather than posting a green nothing can check (#849) — and a NON-EMPTY history carrying no numeric id is reported unusable rather than collapsed to 0. BOTH id comparisons are NUMERIC-ONLY: jq orders strings above every number, so one `"id": "99999"` inflates the mark until nothing looks newer, and `.id > $since` reads any string id as newer than any mark — making a PRE-EXISTING base-mismatched verdict look raced on every run, a permanent per-sha stall (the twin was live on `main`). An EMPTY post-write history is REJECTED: the walk terminates on an empty page, which is correct before the write and impossible after it (one row per POST), and a well-formed "no statuses exist" is not retried — so accepting it would conclude `raced=0` from a list that cannot be real, silently. That is NOT the withdrawn currency witness, which asked whether ANY row sat above the mark and was satisfied by an unrelated newer row; this asks only whether the list is EMPTY, a state no unrelated row can produce. The `::error::` now names its own cause, of which there are THREE — a verdict actually FOUND, a read that could not be COMPLETED, and a read that completed but returned an IMPOSSIBLE answer (the third is not a variety of the second) — while WHICH sentinel description is written is itself part of the answer since #849: only an arm that actually COUNTED a verdict row may write the repair sentinel, and every "could not check" arm writes the reconcilable one (`ci.verdict-unverified-write-sentinel`). Both are fixed points the classification recognises. `.creator` is TYPE-TESTED before it is indexed: `.creator != null and .creator.login` hard-errors on a non-object creator, jq exits 5, and under `set -e` that took the step down AFTER the exemption was posted and BEFORE the repair — a forged green reported as an infrastructure error. | 2026-08-03 | [link](records/ci/verdict-write-retarget-fence.md) | +| `ci.verdict-write-retarget-fence` | The `review-verdict/h10` job counts BOTH `change_target_branch` AND `pull_push` events on the PR's issue timeline at run start and again immediately before its POST, and does not post its CLASSIFICATION if EITHER count moved. Since #849 it does not merely abstain either: when the head carries a row this run did not inherit — and that row is neither the repair sentinel nor an allow-listed reviewer's verdict — the arm REPLACES it with the unverified-write sentinel, because abstention is a handoff only when there is nothing to hand off (`ci.verdict-unverified-write-sentinel`). The COUNT is the key on both axes because the underlying VALUE is ABA-vulnerable — `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a forged exemption, and a force-push `H1 -> H2 -> H1` leaves `.head.sha` equal at both ends while the middle pages of `scripts/pr-changed-files.sh`'s enumeration came from `H2` (#803/#664) — while an event count is monotonic and cannot alias. ONE walk certifies BOTH counts, so an unreadable page abandons both; the two tallies are separate so the diagnostic names the axis that actually moved. Every push is counted and `is_force_push` is deliberately NOT read: an ordinary push also invalidates a mid-flight enumeration, and an `H1 -> H2 -> H1` restoration can have its second push non-forced when `H1` is an ancestor. The head fence does NOT abstain on its own triggering push — established FROM THE v1.27.1 SOURCE, since that would be a permanent stall rather than a fence: the push comment is created BEFORE the synchronize notification is emitted, so a run always sees its own causative event, and retries add no new event. The 26-102s margin measured across 20 triggered pairs on PRs #802/#834/#761 corroborates it and is a lower bound (the job runs a checkout first; 69s end to end on run 2385) — it is NOT the basis of the claim, which an earlier draft said it was. This NARROWS the residual rather than resolving it, and what CLOSED the permanent case is a separate mechanism recorded at `ci.verdict-unverified-write-sentinel`: the retarget count is re-taken AFTER the POST, so a retarget between the final pre-write count and the POST — which used to leave a PERMANENT forged green, the successor having consumed the `edited` event and exited before the stale run posted last — is now caught by the writing run itself, and one landing after the re-check necessarily queues a successor that starts with the stale `success` already visible and re-derivable (corrected 2026-08-27, closed 2026-08-29, #849). Abstaining is a handoff, not a stall, and that is the property the design rests on — for a run that ABSTAINS; it says nothing about one that already passed its final count and then posts (see the residual, corrected #849): 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 — but ONLY over runs that ABSTAIN. A run that already passed its final count is outside it: it writes whenever it gets there, so the run that writes LAST is not necessarily the one that classified last. That was the permanent residual until #849 gave the writing run a post-POST re-count of its own (`ci.verdict-unverified-write-sentinel`), which puts such a run back inside the induction — it either withdraws its own stale write or leaves a green a guaranteed successor re-derives. `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` blocks the merge immediately while withholding it would strand ordinary PRs whenever the timeline is unreadable — the right trade, but NOT a free one ("for no safety gain" retracted 2026-08-27): a GENERIC `pending` masks a rejection landing in its own write window just as a `success` does. What made that DURABLE — post-write verification skipping it, so a later run re-derived it into an exemption `success` — is closed since 2026-08-29: the check now runs after EVERY write (`ci.verdict-unverified-write-sentinel`), so such a write is repaired to the sticky repair sentinel and cannot be re-derived. The masking itself is still a cost, and a write the mark cannot cover is still unverified; that is what the second sentinel is for. SEPARATELY, and for the human-verdict race the fence does nothing about: after posting ANY status — every write since #849, not only an exemption `success` — the job re-reads `/statuses/{sha}` IN FULL (PAGED since #763) 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. BOTH `/statuses/{sha}` reads PAGE to a validated EMPTY page since #763 — `[]` on this endpoint, a THIRD terminator shape distinct from the timeline's bare `null` and the combined endpoint's `{"statuses": null}`, so each terminator is MEASURED per endpoint — never terminating on a SHORT page, retrying each page once, under a 20-page cap that validates at most 950 rows (the 20th request must be the empty terminator); correctness does not depend on the measured cap of 50, because any page size pages correctly. WHAT THE PAGING BUYS IS NOT WHAT #763 CLAIMED: under the DESC default page 1 already held the true maximum id AND every row newer than the mark, so a single-page read missed a raced verdict only if more than 50 rows were created INSIDE the write window — not merely on a head over 50 rows. What removed #761's stall is retiring the probe, not the walk; the walk's value is that the gate's one fail-toward-SUCCESS path no longer depends on an UNDOCUMENTED ordering the server honours only coarsely (page 1 came back `114,112,113,111,110`). This RETIRED #751's conservative page-2 probe, which treated "there are rows I did not read" as "assume raced" and so repaired every head that outgrew one page: it fired on Renovate PR #761, reporting a human verdict as overwritten when none existed and then, the sentinel being sticky, refusing to re-exempt that head on every later run. The two directions are NOT symmetric. POST-WRITE, uncertainty fails CLOSED — an unreadable history, an over-cap history, or a count that is not a number all repair to `pending`; previously an unreadable history warned and left the exemption green while the page-2 probe repaired on the same uncertainty, one check disagreeing with itself. PRE-WRITE, a PARTIAL list still yields a mark, because the mark gates the post-write check entirely and refusing one SKIPS that check, which is itself the fail-open; this rests on the DESC DEFAULT — the newest row, carrying the maximum id, is on page 1, so a walk that fails later still saw it — while a VALIDATED empty history is NOT abandoned (it yields a mark of 0, correct for a first run, since every later row is newer) — what abandons the mark is a read that both FAILED and returned nothing — which since 2026-08-29 WITHHOLDS the exemption before the POST and marks the head with the reconcilable sentinel, rather than posting a green nothing can check (#849) — and a NON-EMPTY history carrying no numeric id is reported unusable rather than collapsed to 0. BOTH id comparisons are NUMERIC-ONLY: jq orders strings above every number, so one `"id": "99999"` inflates the mark until nothing looks newer, and `.id > $since` reads any string id as newer than any mark — making a PRE-EXISTING base-mismatched verdict look raced on every run, a permanent per-sha stall (the twin was live on `main`). An EMPTY post-write history is REJECTED: the walk terminates on an empty page, which is correct before the write and impossible after it (one row per POST), and a well-formed "no statuses exist" is not retried — so accepting it would conclude `raced=0` from a list that cannot be real, silently. That is NOT the withdrawn currency witness, which asked whether ANY row sat above the mark and was satisfied by an unrelated newer row; this asks only whether the list is EMPTY, a state no unrelated row can produce. The `::error::` now names its own cause, of which there are THREE — a verdict actually FOUND, a read that could not be COMPLETED, and a read that completed but returned an IMPOSSIBLE answer (the third is not a variety of the second) — while WHICH sentinel description is written is itself part of the answer since #849: only an arm that actually COUNTED a verdict row may write the repair sentinel, and every "could not check" arm writes the reconcilable one (`ci.verdict-unverified-write-sentinel`). Both are fixed points the classification recognises. `.creator` is TYPE-TESTED before it is indexed: `.creator != null and .creator.login` hard-errors on a non-object creator, jq exits 5, and under `set -e` that took the step down AFTER the exemption was posted and BEFORE the repair — a forged green reported as an infrastructure error. | 2026-08-03 | [link](records/ci/verdict-write-retarget-fence.md) | | `ci.verify-locally-ci-confirms` | Treat the local build/verify/review pass as the decision point and CI as confirmation — don't idle waiting on a run you have no reason to doubt. | 2026-07-21 | [link](records/ci/verify-locally-ci-confirms.md) | | `ci.web-test-per-test-timeouts` | Give heavy-render web tests an explicit per-test vitest timeout (e.g. 15s); never raise the global default to fix one slow test. | 2026-07-21 | [link](records/ci/web-test-per-test-timeouts.md) | | `ci.workflow-dispatch-ref-unrestricted` | Gitea 1.27.1 offers NO mechanism to restrict `workflow_dispatch` by ref, and has no protected-environment concept at all — PROBED across the REST API, the loaded config and the CLI, not assumed (the WEB UI was not swept; the body says why that is acceptable here and where it would matter). The dispatch body schema `CreateActionWorkflowDispatch` makes `ref` a required free-form string with no allow-list or pattern field; zero of the 308 documented API paths contain "environment", and Actions secrets exist only at org/repo/user scope with no per-ref or per-environment gate; `/api/v1/settings/actions` 404s; the config file the running server actually loads (`/etc/gitea/app.ini`, named by its own `--config`) sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`; and the `gitea` CLI exposes exactly ONE Actions subcommand, `gitea actions generate-runner-token`, which registers a runner and restricts nothing. Treat the VERSION, not the `stale-after` date, as the real trigger to re-probe: an upgrade past 1.27.1 invalidates every capability claim here the day it lands, months before the date fires. The four unrestricted dispatches (`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml`, `renovate.yml`) are therefore ACCEPTED — but the operative reason is NOT "repository write access is the boundary", which is the argument to avoid because it is unfalsifiable and it hides the real route. The operative reason is that **dispatch is not the cheapest route to ANY of it**: `docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that route executes ATTACKER-AUTHORED YAML — and such YAML can name any secret in the repo store, not merely the ones the committed workflows happen to reference (`ci.gate-trigger-base-resolved`, verbatim: "any PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store"). Label that step honestly: it is INFERRED from the repo-scoped secret model plus that record, NOT measured here, because the measurement would print a live credential into a run log. That generalizing step is what makes the argument cover all four rather than just the registry pair: `renovate.yml`'s `RENOVATE_TOKEN`/`GH_COM_TOKEN` are reachable from a PR without dispatching `renovate.yml` at all, and `dependency-scan.yml` references no `secrets.` whatever — which corrects #853's own table row for it. On the registry credential as it stands, SIX jobs in `docker-build.yml` hold `REGISTRY_PASSWORD` and run on the PR route (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them — `test` and `migrations` — branch-protection required contexts per `.gitea/required-status-contexts.json`; carry that as the INVARIANT "every job on the PR route that NAMES `secrets.REGISTRY_PASSWORD`", never as the six-name list, because a remediation scoped to a stale list misses whatever lands next. Resist the tempting "every `container:` job" — `toolchain-preflight` is deliberately container-free and takes the credential through `ETV_REGISTRY_AUTH`, so that predicate names five of the six and reproduces on day one the exact staleness it was written to prevent. "Deliberate act" throughout carries `ci.toolchain-image-publish-is-a-dispatch`'s sense — an act OUTSIDE the ordinary contribution flow, not a raw step count: opening a PR costs zero such acts and a dispatch costs one. Restricting dispatch would therefore close the more visible route and change nothing. The residuals worth tracking are the PR route AND the `v*` tag push — a single act, explicitly outside `release.main-direct-push-disabled` — both in #885, not dispatch. | 2026-08-30 | [link](records/ci/workflow-dispatch-ref-unrestricted.md) | diff --git a/docs/decisions/records/ci/verdict-unverified-write-sentinel.md b/docs/decisions/records/ci/verdict-unverified-write-sentinel.md index 292de0316..a7e2ca882 100644 --- a/docs/decisions/records/ci/verdict-unverified-write-sentinel.md +++ b/docs/decisions/records/ci/verdict-unverified-write-sentinel.md @@ -7,7 +7,7 @@ supersedes: none superseded-by: none rule: 'The `review-verdict/h10` job runs its post-write race check after EVERY status write, not only an exemption `success`, and where no mark exists to run it against, the write ITSELF becomes the sentinel — so every status this job writes is either verified against the history or marked as unverifiable: a GENERIC `pending` masks a rejection landing in its own write window exactly as a `success` does, and because it carries no marker a later run re-derives it into an exemption with the human row now below THAT run''s high-water mark — the damage arrives one event later, not never. Where a write cannot be checked at all the job writes a SECOND sentinel, `UNVERIFIED_DESC`, distinct from the repair sentinel and never interchangeable with it: the repair sentinel ASSERTS that a verdict existed and was buried, which only the arm that actually COUNTED such a row may claim, while every "could not check" arm — an unreadable or over-cap history, an impossible empty history, an unusable count — states only what it established. Both are STICKY (the classification refuses to grant an exemption over either, and re-writes its own description verbatim, so each is a FIXED POINT a later run cannot re-derive into `success`); only the unverified one is RECONCILABLE. Reconciliation is what bounds the stall that got the #742 attempt withdrawn: a later run pages `/statuses/{sha}` IN FULL and, ONLY IF that history contains the sentinel''s own row, either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it UPGRADES to the repair sentinel, clearable only by a human — or finds none, resolving the uncertainty and clearing it so the PR is classified normally. The reconciliation is sound because the two endpoints differ: a verdict this job masked is invisible on the COMBINED endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. It is deliberately BROADER than the inheritance test — no `H10_REVIEWERS` membership, no `(base: …)` match — because over-counting upgrades to a stall a human clears in one command while MISSING one re-exempts a head carrying a buried rejection. ONLY a COMPLETE walk CONTAINING THE CURRENT SENTINEL ROW — matched by the `id` the combined read reported, not merely by a row with the same description — may clear it. Description alone is satisfied by an OLDER identical sentinel, which is exactly what a fixed point produces: with an earlier sentinel and a buried verdict below the current one, a read carrying only the earlier row satisfies the test, the sentinel clears, and the verdict ends up below the fresh mark. Where the server omits `id` there is nothing to match on and the check degrades to the description, which is the pre-existing behaviour rather than a new hole. The witness is not optional decoration: `ex_unverified` means the combined endpoint just returned that row, and `/statuses/{sha}` keeps one row per POST, so a complete history WITHOUT it — an empty one included — contradicts a write that demonstrably happened. `page_statuses` accepts an empty page 1 as complete (correct for a first-run mark), which is what made that shape reachable, and clearing on it exempted a head whose sentinel may have been sitting on a rejection. This is NOT the withdrawn currency witness: that asked whether ANY row sat above the mark, which an unrelated row satisfied, and it made one anomaly permanent — this asks for a SPECIFIC row already known to exist. Failing it costs one run in the transient case; it is NOT bounded in general, because a history past the 20-page cap can never be walked completely, so such a head needs a human verdict and no later run will clear it. When NO high-water mark can be established the write is withheld BEFORE the POST rather than posted and repaired, since the defect is known in advance and publishing a green to take it back opens a window branch protection — and an already-scheduled auto-merge — can see. EVERY re-derivable state is downgraded there, not only `success`: an earlier draft restricted it to the exemption on the grounds that a sticky generic `pending` "withholds nothing, since an unreviewed PR is blocked already", which analysed the wrong PR — the damaging case is one that IS exemptible and got the generic `pending` only from a transient enumeration failure, whose unmarked description the next run re-derives into the exemption with the human row below its own mark. `$REPAIR_DESC` is the one exemption, being the stronger fact and not re-derivable. SEPARATELY, the retarget count is re-taken AFTER the POST on the exemption path, which closes the PERMANENT forged green `ci.verdict-write-retarget-fence` recorded as its residual: a retarget landing after the final pre-write count leaves a stale `success` with the `edited` event already consumed by a successor that short-circuited, so nothing remains to reclassify. Re-counting afterwards means a retarget later than the re-check necessarily queues a successor that STARTS after the stale `success` exists, and a machine-written `success` is re-derived rather than inherited. The RETARGET axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. An untrusted post-POST count repairs too — reaching that point with `success` means both earlier counts were trusted, so a third unreadable read is a fresh failure and "I cannot tell whether the base moved" must not resolve to leaving a green. FINALLY, every path that cannot establish what the head carries REPLACES the unknown state instead of merely declining to write — the combined read (retried once first), all four page-2 completeness refusals, and the fence branch that cannot trust its retarget count while holding a derived `success`. That last one is reached only AFTER the classification declined to inherit the row the head carries, so posting nothing left the declined row current; its message used to say the context "stays absent", which is true only of a head that had none. The two OBSERVED-mutation arms take the same decision for the same reason, scoped to a head that actually carries a declined row: they still refuse to post their CLASSIFICATION, which was computed against a base or head the PR may no longer have, but a row this run declined must not stay authoritative for the whole window until a successor finishes — and for a PR''s FIRST push no successor is queued at all. Every element and every consumed field of the combined response is TYPE-CHECKED before extraction, and a schema failure routes to the same replacement rather than dying: `.statuses` being an array was checked and its ELEMENTS were not, so one scalar made `select(.context == $c)` hard-error and `set -e` took the step down before any path could mark the head. The path-predicate failure replaces too. And the write helper RETURNS a status: its first version ended the failure arm with a successful `echo`, so it reported 0 after both POSTs failed and the fence caller exited 0 as though the head had been marked. Declining protects a real verdict and leaves a FORGED one — an off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving it, and an unreadable read is the one thing that stops it while the job goes red on a status branch protection does not read. Nothing is destroyed: the per-POST history keeps the masked verdict and the next run''s reconciliation upgrades to the repair sentinel, telling the reviewer to re-post rather than silently un-approving them. The page-2 refusals were briefly excluded on the reasoning that the probe fires when NO row for this context was on page 1, so there is no green of any provenance to leave standing — self-contradictory, since the ONLY reason page 2 is read is that the row MAY be beyond page 1. WRITING THE SENTINEL AND FAILING THE JOB ARE SEPARATE DECISIONS: the read refusals were already non-zero exits on `main` and stay red, while the fence branch exited 0 there and still does, because an unreadable timeline is an ordinary hiccup and reddening every one of them is noise this file elsewhere refuses to add. The repair also has a FLOOR — it may never write a description weaker than the one this run decided, or a transient post-write read rewrites a correct `$REPAIR_DESC` carry-forward with the machine-clearable sentinel — and it is skipped entirely when it would rewrite what is already there.' signals: 'exemption success standing over a human failure, generic pending re-derived into an exemption, unverified write sentinel, reconcilable sentinel vs repair sentinel, no high-water mark could be established, post-write verification runs for every write, retarget after the POST leaves a permanent forged green, unreadable combined status read leaves an off-list green, why did my docs-only PR lose its exemption, why does the gate say the write could not be verified · paths: `.gitea/workflows/review-verdict.yml`, `scripts/tests/test_pr_changed_files.py` · issues: #849, #742, #763, #706, #803' -mechanics: '`UNVERIFIED_DESC` ("Exemption write could not be verified — re-post the verdict") beside `REPAIR_DESC`; `read_existing_verdict()` sets `ex_unverified` from a prefix match, retries the combined read once and on persistent failure calls `repair_status_to "$UNVERIFIED_DESC"` before `exit 1`; the RECONCILE block runs `page_statuses` and counts `Review-verdict:` rows with a non-null creator, clearing `ex_unverified`, upgrading to `ex_repair`, or carrying the sentinel forward on `ph_ok != yes`; the classification chain is `exempt` → `ex_repair` → `ex_unverified` → generic, so the repair sentinel outranks the unverified one; the no-mark downgrade sits AFTER the mark (referencing `$max_id_before` earlier is an unbound variable under `set -u`); the mid-run guard adds `[ "$ex_desc" != "$pre_desc" ]`, which the repair guard does not need because a pre-existing repair sentinel forces its own description while a pre-existing unverified one is deliberately replaced after reconciliation; the post-write gate is `if [ "$max_id_before" -ge 0 ]`, with the no-mark case handled before the POST instead; `replace_unknown_state()` is the shared writer for every path that cannot establish the head''s state and `replace_unknown_and_die()` wraps it for the two read refusals that were already non-zero exits; the reconciliation witness is a count of rows whose description equals `$UNVERIFIED_DESC`; `pre_id`/`ex_id` extend the mid-run changed-row comparison to row IDENTITY through a shared `row_replaced` flag, since two sentinel POSTs are byte-identical by design — and an id difference counts only when BOTH reads supplied one, because a response that omits `id` beside one that includes it would otherwise report a replacement that did not happen and make the run abstain over a row it had already declined (the combined endpoint carries `id` — measured 2026-08-29 at 1.27.1, head 736649b3, 8 rows, ids 14..30); `--arg own "$desc"` excludes the job''s OWN row from both machine-sentinel selectors, which became necessary the moment the block started running for every write; `.description` is TYPE-TESTED before `startswith`, since `(.description // "")` does not replace a NUMBER and `startswith` then hard-errors, killing the whole count and losing a genuine verdict beside the malformed row; `repair_status_to()` is the shared retry-once writer for all three repair sites; tests `test_a_verdict_racing_a_PENDING_write_is_repaired_to_the_repair_sentinel`, `test_a_raced_PENDING_write_repairs_and_the_repair_SURVIVES_the_next_run`, `test_the_unverified_sentinel_is_a_FIXED_POINT_while_it_cannot_be_reconciled`, `test_the_unverified_sentinel_is_RECONCILED_AWAY_once_the_history_can_be_read`, `test_the_reconciliation_UPGRADES_to_the_repair_sentinel_when_a_verdict_is_BURIED`, `test_a_RETARGET_AFTER_the_POST_replaces_the_exemption`, `test_an_UNREADABLE_retarget_count_AFTER_the_POST_also_replaces_the_exemption`, `test_a_MALFORMED_description_row_does_not_kill_the_post_write_count`, `test_a_transport_failure_on_the_STATUS_READ_REPLACES_the_unknown_state`, each paired with a `test_MUTATION_…` proof that disarms the shipped clause it names through `_run_classify(mutate=…)`, whose count assertion is the binding. Three of those mutants restore text this branch or `origin/main` actually shipped, and the rest disarm clauses that have no predecessor because the blocks they gate are new — the section header in that file enumerates which is which, because "restores the exact predecessor" is true of only some of them. ONE CLAUSE IS DELIBERATELY UNPROVEN and says so rather than being counted: the path-predicate failure branch, which fires only when `grep -c` exits above 1, has no fixture that can reach it — the same standing exception the post-write unusable-count arm already carries, and it is defence in depth behind a filter that makes the count numeric for every input a stub can pose.' +mechanics: '`UNVERIFIED_DESC` ("Status write could not be verified — re-post the verdict") beside `REPAIR_DESC`; `read_existing_verdict()` sets `ex_unverified` from a prefix match, retries the combined read once and on persistent failure calls `repair_status_to "$UNVERIFIED_DESC"` before `exit 1`; the RECONCILE block runs `page_statuses` and counts `Review-verdict:` rows with a non-null creator, clearing `ex_unverified`, upgrading to `ex_repair`, or carrying the sentinel forward on `ph_ok != yes`; the classification chain is `exempt` → `ex_repair` → `ex_unverified` → generic, so the repair sentinel outranks the unverified one; the no-mark downgrade sits AFTER the mark (referencing `$max_id_before` earlier is an unbound variable under `set -u`); the mid-run guard adds `[ "$ex_desc" != "$pre_desc" ]`, which the repair guard does not need because a pre-existing repair sentinel forces its own description while a pre-existing unverified one is deliberately replaced after reconciliation; the post-write gate is `if [ "$max_id_before" -ge 0 ]`, with the no-mark case handled before the POST instead; `replace_unknown_state()` is the shared writer for every path that cannot establish the head''s state and `replace_unknown_and_die()` wraps it for the two read refusals that were already non-zero exits; the reconciliation witness is a count of rows carrying the `id` the combined read reported, falling back to a description match only where the server omits `id`; `pre_id`/`ex_id` extend the mid-run changed-row comparison to row IDENTITY through a shared `row_replaced` flag, since two sentinel POSTs are byte-identical by design — and an id difference counts only when BOTH reads supplied one, because a response that omits `id` beside one that includes it would otherwise report a replacement that did not happen and make the run abstain over a row it had already declined (the combined endpoint carries `id` — measured 2026-08-29 at 1.27.1, head 736649b3, 8 rows, ids 14..30); `--arg own "$desc"` excludes the job''s OWN row from both machine-sentinel selectors, which became necessary the moment the block started running for every write; `.description` is TYPE-TESTED before `startswith`, since `(.description // "")` does not replace a NUMBER and `startswith` then hard-errors, killing the whole count and losing a genuine verdict beside the malformed row; `repair_status_to()` is the shared retry-once writer for all three repair sites; tests `test_a_verdict_racing_a_PENDING_write_is_repaired_to_the_repair_sentinel`, `test_a_raced_PENDING_write_repairs_and_the_repair_SURVIVES_the_next_run`, `test_the_unverified_sentinel_is_a_FIXED_POINT_while_it_cannot_be_reconciled`, `test_the_unverified_sentinel_is_RECONCILED_AWAY_once_the_history_can_be_read`, `test_the_reconciliation_UPGRADES_to_the_repair_sentinel_when_a_verdict_is_BURIED`, `test_a_RETARGET_AFTER_the_POST_replaces_the_exemption`, `test_an_UNREADABLE_retarget_count_AFTER_the_POST_also_replaces_the_exemption`, `test_a_MALFORMED_description_row_does_not_kill_the_post_write_count`, `test_a_transport_failure_on_the_STATUS_READ_REPLACES_the_unknown_state`, each paired with a `test_MUTATION_…` proof that disarms the shipped clause it names through `_run_classify(mutate=…)`, whose count assertion is the binding. Some of those mutants restore text this branch or `origin/main` actually shipped — every clause that HAS a predecessor is mutated back to it — and the rest disarm clauses that have none, because the blocks they gate are new — the section header in that file enumerates which is which, because "restores the exact predecessor" is true of only some of them. TWO CLAUSES ARE DELIBERATELY UNPROVEN and say so rather than being counted: the path-predicate failure branch, which fires only when `grep -c` exits above 1, and the empty-`row` refusal, which the type-safe filter makes unreachable because `first // {}` always yields an object for any body that passed the shape gate. Neither has a fixture that can reach it — the same standing exception the post-write unusable-count arm already carries, and it is defence in depth behind a filter that makes the count numeric for every input a stub can pose.' --- `ci.verdict-write-retarget-fence` fenced the write and verified it afterwards. Five routes survived diff --git a/docs/decisions/records/ci/verdict-write-retarget-fence.md b/docs/decisions/records/ci/verdict-write-retarget-fence.md index 8a8df6878..e25883e71 100644 --- a/docs/decisions/records/ci/verdict-write-retarget-fence.md +++ b/docs/decisions/records/ci/verdict-write-retarget-fence.md @@ -5,7 +5,7 @@ status: active since: '2026-08-03' supersedes: none superseded-by: none -rule: 'The `review-verdict/h10` job counts BOTH `change_target_branch` AND `pull_push` events on the PR''s issue timeline at run start and again immediately before its POST, and writes NOTHING if EITHER count moved. The COUNT is the key on both axes because the underlying VALUE is ABA-vulnerable — `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a forged exemption, and a force-push `H1 -> H2 -> H1` leaves `.head.sha` equal at both ends while the middle pages of `scripts/pr-changed-files.sh`''s enumeration came from `H2` (#803/#664) — while an event count is monotonic and cannot alias. ONE walk certifies BOTH counts, so an unreadable page abandons both; the two tallies are separate so the diagnostic names the axis that actually moved. Every push is counted and `is_force_push` is deliberately NOT read: an ordinary push also invalidates a mid-flight enumeration, and an `H1 -> H2 -> H1` restoration can have its second push non-forced when `H1` is an ancestor. The head fence does NOT abstain on its own triggering push — established FROM THE v1.27.1 SOURCE, since that would be a permanent stall rather than a fence: the push comment is created BEFORE the synchronize notification is emitted, so a run always sees its own causative event, and retries add no new event. The 26-102s margin measured across 20 triggered pairs on PRs #802/#834/#761 corroborates it and is a lower bound (the job runs a checkout first; 69s end to end on run 2385) — it is NOT the basis of the claim, which an earlier draft said it was. This NARROWS the residual rather than resolving it, and what CLOSED the permanent case is a separate mechanism recorded at `ci.verdict-unverified-write-sentinel`: the retarget count is re-taken AFTER the POST, so a retarget between the final pre-write count and the POST — which used to leave a PERMANENT forged green, the successor having consumed the `edited` event and exited before the stale run posted last — is now caught by the writing run itself, and one landing after the re-check necessarily queues a successor that starts with the stale `success` already visible and re-derivable (corrected 2026-08-27, closed 2026-08-29, #849). Abstaining is a handoff, not a stall, and that is the property the design rests on — for a run that ABSTAINS; it says nothing about one that already passed its final count and then posts (see the residual, corrected #849): 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 — but ONLY over runs that ABSTAIN. A run that already passed its final count is outside it: it writes whenever it gets there, so the run that writes LAST is not necessarily the one that classified last. That was the permanent residual until #849 gave the writing run a post-POST re-count of its own (`ci.verdict-unverified-write-sentinel`), which puts such a run back inside the induction — it either withdraws its own stale write or leaves a green a guaranteed successor re-derives. `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` blocks the merge immediately while withholding it would strand ordinary PRs whenever the timeline is unreadable — the right trade, but NOT a free one ("for no safety gain" retracted 2026-08-27): a GENERIC `pending` masks a rejection landing in its own write window just as a `success` does. What made that DURABLE — post-write verification skipping it, so a later run re-derived it into an exemption `success` — is closed since 2026-08-29: the check now runs after EVERY write (`ci.verdict-unverified-write-sentinel`), so such a write is repaired to the sticky repair sentinel and cannot be re-derived. The masking itself is still a cost, and a write the mark cannot cover is still unverified; that is what the second sentinel is for. SEPARATELY, and for the human-verdict race the fence does nothing about: after posting ANY status — every write since #849, not only an exemption `success` — the job re-reads `/statuses/{sha}` IN FULL (PAGED since #763) 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. BOTH `/statuses/{sha}` reads PAGE to a validated EMPTY page since #763 — `[]` on this endpoint, a THIRD terminator shape distinct from the timeline''s bare `null` and the combined endpoint''s `{"statuses": null}`, so each terminator is MEASURED per endpoint — never terminating on a SHORT page, retrying each page once, under a 20-page cap that validates at most 950 rows (the 20th request must be the empty terminator); correctness does not depend on the measured cap of 50, because any page size pages correctly. WHAT THE PAGING BUYS IS NOT WHAT #763 CLAIMED: under the DESC default page 1 already held the true maximum id AND every row newer than the mark, so a single-page read missed a raced verdict only if more than 50 rows were created INSIDE the write window — not merely on a head over 50 rows. What removed #761''s stall is retiring the probe, not the walk; the walk''s value is that the gate''s one fail-toward-SUCCESS path no longer depends on an UNDOCUMENTED ordering the server honours only coarsely (page 1 came back `114,112,113,111,110`). This RETIRED #751''s conservative page-2 probe, which treated "there are rows I did not read" as "assume raced" and so repaired every head that outgrew one page: it fired on Renovate PR #761, reporting a human verdict as overwritten when none existed and then, the sentinel being sticky, refusing to re-exempt that head on every later run. The two directions are NOT symmetric. POST-WRITE, uncertainty fails CLOSED — an unreadable history, an over-cap history, or a count that is not a number all repair to `pending`; previously an unreadable history warned and left the exemption green while the page-2 probe repaired on the same uncertainty, one check disagreeing with itself. PRE-WRITE, a PARTIAL list still yields a mark, because the mark gates the post-write check entirely and refusing one SKIPS that check, which is itself the fail-open; this rests on the DESC DEFAULT — the newest row, carrying the maximum id, is on page 1, so a walk that fails later still saw it — while a VALIDATED empty history is NOT abandoned (it yields a mark of 0, correct for a first run, since every later row is newer) — what abandons the mark is a read that both FAILED and returned nothing — which since 2026-08-29 WITHHOLDS the exemption before the POST and marks the head with the reconcilable sentinel, rather than posting a green nothing can check (#849) — and a NON-EMPTY history carrying no numeric id is reported unusable rather than collapsed to 0. BOTH id comparisons are NUMERIC-ONLY: jq orders strings above every number, so one `"id": "99999"` inflates the mark until nothing looks newer, and `.id > $since` reads any string id as newer than any mark — making a PRE-EXISTING base-mismatched verdict look raced on every run, a permanent per-sha stall (the twin was live on `main`). An EMPTY post-write history is REJECTED: the walk terminates on an empty page, which is correct before the write and impossible after it (one row per POST), and a well-formed "no statuses exist" is not retried — so accepting it would conclude `raced=0` from a list that cannot be real, silently. That is NOT the withdrawn currency witness, which asked whether ANY row sat above the mark and was satisfied by an unrelated newer row; this asks only whether the list is EMPTY, a state no unrelated row can produce. The `::error::` now names its own cause, of which there are THREE — a verdict actually FOUND, a read that could not be COMPLETED, and a read that completed but returned an IMPOSSIBLE answer (the third is not a variety of the second) — while WHICH sentinel description is written is itself part of the answer since #849: only an arm that actually COUNTED a verdict row may write the repair sentinel, and every "could not check" arm writes the reconcilable one (`ci.verdict-unverified-write-sentinel`). Both are fixed points the classification recognises. `.creator` is TYPE-TESTED before it is indexed: `.creator != null and .creator.login` hard-errors on a non-object creator, jq exits 5, and under `set -e` that took the step down AFTER the exemption was posted and BEFORE the repair — a forged green reported as an infrastructure error.' +rule: 'The `review-verdict/h10` job counts BOTH `change_target_branch` AND `pull_push` events on the PR''s issue timeline at run start and again immediately before its POST, and does not post its CLASSIFICATION if EITHER count moved. Since #849 it does not merely abstain either: when the head carries a row this run did not inherit — and that row is neither the repair sentinel nor an allow-listed reviewer''s verdict — the arm REPLACES it with the unverified-write sentinel, because abstention is a handoff only when there is nothing to hand off (`ci.verdict-unverified-write-sentinel`). The COUNT is the key on both axes because the underlying VALUE is ABA-vulnerable — `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a forged exemption, and a force-push `H1 -> H2 -> H1` leaves `.head.sha` equal at both ends while the middle pages of `scripts/pr-changed-files.sh`''s enumeration came from `H2` (#803/#664) — while an event count is monotonic and cannot alias. ONE walk certifies BOTH counts, so an unreadable page abandons both; the two tallies are separate so the diagnostic names the axis that actually moved. Every push is counted and `is_force_push` is deliberately NOT read: an ordinary push also invalidates a mid-flight enumeration, and an `H1 -> H2 -> H1` restoration can have its second push non-forced when `H1` is an ancestor. The head fence does NOT abstain on its own triggering push — established FROM THE v1.27.1 SOURCE, since that would be a permanent stall rather than a fence: the push comment is created BEFORE the synchronize notification is emitted, so a run always sees its own causative event, and retries add no new event. The 26-102s margin measured across 20 triggered pairs on PRs #802/#834/#761 corroborates it and is a lower bound (the job runs a checkout first; 69s end to end on run 2385) — it is NOT the basis of the claim, which an earlier draft said it was. This NARROWS the residual rather than resolving it, and what CLOSED the permanent case is a separate mechanism recorded at `ci.verdict-unverified-write-sentinel`: the retarget count is re-taken AFTER the POST, so a retarget between the final pre-write count and the POST — which used to leave a PERMANENT forged green, the successor having consumed the `edited` event and exited before the stale run posted last — is now caught by the writing run itself, and one landing after the re-check necessarily queues a successor that starts with the stale `success` already visible and re-derivable (corrected 2026-08-27, closed 2026-08-29, #849). Abstaining is a handoff, not a stall, and that is the property the design rests on — for a run that ABSTAINS; it says nothing about one that already passed its final count and then posts (see the residual, corrected #849): 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 — but ONLY over runs that ABSTAIN. A run that already passed its final count is outside it: it writes whenever it gets there, so the run that writes LAST is not necessarily the one that classified last. That was the permanent residual until #849 gave the writing run a post-POST re-count of its own (`ci.verdict-unverified-write-sentinel`), which puts such a run back inside the induction — it either withdraws its own stale write or leaves a green a guaranteed successor re-derives. `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` blocks the merge immediately while withholding it would strand ordinary PRs whenever the timeline is unreadable — the right trade, but NOT a free one ("for no safety gain" retracted 2026-08-27): a GENERIC `pending` masks a rejection landing in its own write window just as a `success` does. What made that DURABLE — post-write verification skipping it, so a later run re-derived it into an exemption `success` — is closed since 2026-08-29: the check now runs after EVERY write (`ci.verdict-unverified-write-sentinel`), so such a write is repaired to the sticky repair sentinel and cannot be re-derived. The masking itself is still a cost, and a write the mark cannot cover is still unverified; that is what the second sentinel is for. SEPARATELY, and for the human-verdict race the fence does nothing about: after posting ANY status — every write since #849, not only an exemption `success` — the job re-reads `/statuses/{sha}` IN FULL (PAGED since #763) 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. BOTH `/statuses/{sha}` reads PAGE to a validated EMPTY page since #763 — `[]` on this endpoint, a THIRD terminator shape distinct from the timeline''s bare `null` and the combined endpoint''s `{"statuses": null}`, so each terminator is MEASURED per endpoint — never terminating on a SHORT page, retrying each page once, under a 20-page cap that validates at most 950 rows (the 20th request must be the empty terminator); correctness does not depend on the measured cap of 50, because any page size pages correctly. WHAT THE PAGING BUYS IS NOT WHAT #763 CLAIMED: under the DESC default page 1 already held the true maximum id AND every row newer than the mark, so a single-page read missed a raced verdict only if more than 50 rows were created INSIDE the write window — not merely on a head over 50 rows. What removed #761''s stall is retiring the probe, not the walk; the walk''s value is that the gate''s one fail-toward-SUCCESS path no longer depends on an UNDOCUMENTED ordering the server honours only coarsely (page 1 came back `114,112,113,111,110`). This RETIRED #751''s conservative page-2 probe, which treated "there are rows I did not read" as "assume raced" and so repaired every head that outgrew one page: it fired on Renovate PR #761, reporting a human verdict as overwritten when none existed and then, the sentinel being sticky, refusing to re-exempt that head on every later run. The two directions are NOT symmetric. POST-WRITE, uncertainty fails CLOSED — an unreadable history, an over-cap history, or a count that is not a number all repair to `pending`; previously an unreadable history warned and left the exemption green while the page-2 probe repaired on the same uncertainty, one check disagreeing with itself. PRE-WRITE, a PARTIAL list still yields a mark, because the mark gates the post-write check entirely and refusing one SKIPS that check, which is itself the fail-open; this rests on the DESC DEFAULT — the newest row, carrying the maximum id, is on page 1, so a walk that fails later still saw it — while a VALIDATED empty history is NOT abandoned (it yields a mark of 0, correct for a first run, since every later row is newer) — what abandons the mark is a read that both FAILED and returned nothing — which since 2026-08-29 WITHHOLDS the exemption before the POST and marks the head with the reconcilable sentinel, rather than posting a green nothing can check (#849) — and a NON-EMPTY history carrying no numeric id is reported unusable rather than collapsed to 0. BOTH id comparisons are NUMERIC-ONLY: jq orders strings above every number, so one `"id": "99999"` inflates the mark until nothing looks newer, and `.id > $since` reads any string id as newer than any mark — making a PRE-EXISTING base-mismatched verdict look raced on every run, a permanent per-sha stall (the twin was live on `main`). An EMPTY post-write history is REJECTED: the walk terminates on an empty page, which is correct before the write and impossible after it (one row per POST), and a well-formed "no statuses exist" is not retried — so accepting it would conclude `raced=0` from a list that cannot be real, silently. That is NOT the withdrawn currency witness, which asked whether ANY row sat above the mark and was satisfied by an unrelated newer row; this asks only whether the list is EMPTY, a state no unrelated row can produce. The `::error::` now names its own cause, of which there are THREE — a verdict actually FOUND, a read that could not be COMPLETED, and a read that completed but returned an IMPOSSIBLE answer (the third is not a variety of the second) — while WHICH sentinel description is written is itself part of the answer since #849: only an arm that actually COUNTED a verdict row may write the repair sentinel, and every "could not check" arm writes the reconcilable one (`ci.verdict-unverified-write-sentinel`). Both are fixed points the classification recognises. `.creator` is TYPE-TESTED before it is indexed: `.creator != null and .creator.login` hard-errors on a non-object creator, jq exits 5, and under `set -e` that took the step down AFTER the exemption was posted and BEFORE the repair — a forged green reported as an infrastructure error.' signals: 'stale review-verdict run overwrites a fresher one, retarget ABA against the docs-only classifier, head ABA force-push H1 H2 H1 during paging, pull_push timeline event count, force-push during changed-file enumeration, docs-only exemption from a file list spanning two heads, 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, #803, #664, #849' mechanics: '`count_pr_mutations()` (named `count_retargets()` until #803 put the head axis on the same walk) pages `GET /repos/{repo}/issues/{pr}/timeline?limit=50&page=N` (cap 20) setting `rt_count` (`change_target_branch`), `hp_count` (`pull_push`) and the SHARED trust flag `rt_ok`, trusted only on a validated empty page, which is a page of EITHER `null` (what this endpoint really returns past the end) or `[]` — an `array`-only type gate read the real terminator as unreadable and withheld every exemption (#751); `retargets_before`/`pushes_before`/`retargets_before_ok` captured before enumeration, re-counted immediately before the POST, with a SEPARATE fence arm and diagnostic per axis (the base arm is evaluated first); the run log line is `Mutation fence: N retarget event(s) and M push event(s) ... (trusted=...)`, renamed from `Retarget fence: ...` by #803; `page_statuses()` pages `GET /repos/{repo}/statuses/{sha}?limit=50&page=N` (cap 20) setting `ph_rows`/`ph_ok`, trusted only on a validated EMPTY ARRAY page (this endpoint''s terminator, measured 2026-08-28 on PR #761''s 114-row head: pages 1-2 return 50, page 3 returns 14, page 4 is `[]`), retrying each page once with a 1s backoff; the server default `created_unix DESC` is KEPT, so a row inserted MID-WALK can be missed (it lands at position 0, on a page already read) — accepted and bounded, since a row arriving after this job''s POST is not one the job overwrote and wins on the combined endpoint anyway. `sort=highestindex` (index ASC, measured) closes that gap and was WITHDRAWN because ASC puts the OLDEST rows on page 1, which inverts the partial-mark fallback into a spurious-STICKY-repair engine — the #761 failure, re-introduced to close a smaller gap; `max_id_before` is the max over NUMERIC ids only in the paged list (jq orders strings above every number, so one `"id": "99999"` would otherwise pass the numeric gate and inflate the mark until nothing looked newer than it — a fail-open); this endpoint is a BARE ARRAY, unlike the combined `/commits/{sha}/status` object; repair POST is `pending`; tests `test_a_HEAD_ABA_DURING_the_run_posts_NOTHING`, `test_the_head_fence_holds_under_EITHER_terminator_shape`, `test_the_head_arm_DEFERS_to_the_trust_guard_when_the_second_count_fails`, `test_a_push_landing_BEYOND_page_1_is_still_counted`, `test_positive_control_a_MULTI_PAGE_quiet_timeline_still_exempts`, `test_an_EMPTY_FIRST_page_is_untrusted_WHICHEVER_empty_shape_it_is`, `test_a_timeline_row_with_NO_READABLE_TYPE_is_not_counted_as_nothing`, `test_a_SINGLE_head_push_during_the_run_also_posts_NOTHING`, `test_a_PR_PUSHED_BEFORE_the_run_but_QUIET_during_it_is_STILL_exempt`, `test_the_head_and_base_axes_are_counted_SEPARATELY`, `test_the_head_fence_reports_the_OBSERVED_counts_in_its_notice`, `test_a_PROTECTED_pr_pushed_mid_run_ALSO_posts_nothing`, `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`, `test_a_status_history_RUNNING_PAST_PAGE_1_is_PAGED_and_the_exemption_STANDS`, `test_a_raced_verdict_on_PAGE_2_is_detected_and_repaired`, `test_a_verdict_BEYOND_A_SHORT_PAGE_is_still_found`, `test_a_PRE_WRITE_paging_failure_still_yields_a_usable_high_water_mark`, `test_a_TRANSIENT_page_failure_is_retried_and_the_walk_still_completes`, `test_a_STRING_id_cannot_inflate_the_high_water_mark`, `test_a_MALFORMED_creator_row_does_not_kill_the_job_after_the_green_is_posted`, `test_a_STRING_id_on_a_PRE_EXISTING_row_is_not_read_as_raced`, `test_a_partial_mark_is_SAFE_because_the_newest_rows_are_on_page_1`, `test_an_EMPTY_post_write_history_is_not_evidence_that_nothing_raced`, `test_a_NULL_page_terminates_the_walk_as_an_empty_one`, `test_the_walk_does_not_request_a_SORT_order`' --- @@ -262,7 +262,7 @@ to every row it extracts since #643, and the walk whose counts gate the write di What the fence narrows is therefore the ABA on a PR whose timeline carries no such truncating block, which is every PR this repo has but is not a property anyone enforces. Narrows, not closes, - even for those PRs: residual 1 below applies to them too. Said here rather + even for those PRs: residual 1 above applies to them too. Said here rather than left in the issue because the whole of #803 is that a contract asserting more than its code does is worse than no contract — and a fence advertised as closing the head ABA would have been the fourth such contract, added by the change that removed three. diff --git a/docs/guard-inventory.md b/docs/guard-inventory.md index 8172f790c..727f565aa 100644 --- a/docs/guard-inventory.md +++ b/docs/guard-inventory.md @@ -331,7 +331,7 @@ job it covers, because a proof reference that covers a fraction must not read as | `pr-checks.yml::decisions-guard` | a PR whose decision records fail lifecycle validation, whose catalog is stale, or whose kickoff file drifted | GUARD | `scripts/decisions_validate.py`, `scripts/build_decisions_catalog.py`, `scripts/check-kickoff-guard.sh` | those scripts' rows above | | `pr-checks.yml::prove-fix` | a PR whose `Proves:` trailer names a test that passes without the fix | GUARD | inline + `scripts/prove-fix.sh` | that script's row above | | `pr-checks.yml::script-tests` | a PR failing ruff or the `scripts/tests` suite — this job is the RUNNER for every `scripts/tests/` row above | GUARD | inline (the ruff population guards) + pytest | the suite it runs | -| `review-verdict.yml::set-verdict-status` | a MERGE, by withholding the branch-protection-required `review-verdict/h10` status | GUARD | inline | `scripts/tests/test_pr_changed_files.py`, which EXECUTES the shipped `run:` body: it guards the changed-file derivation, the exemption classification, the timeline fence and — since #849 — the post-write verification state machine, each `test_MUTATION_…` there disarming through `_run_classify(mutate=…)` the shipped clause it names, bound by a count assertion — three restore text that actually shipped and the rest are counterfactual, which that file's own section header enumerates, and the one clause with no reachable fixture is named there rather than counted. It does NOT cover the runner's step wiring, which no local test can reach | +| `review-verdict.yml::set-verdict-status` | a MERGE, by withholding the branch-protection-required `review-verdict/h10` status | GUARD | inline | `scripts/tests/test_pr_changed_files.py`, which EXECUTES the shipped `run:` body: it guards the changed-file derivation, the exemption classification, the timeline fence and — since #849 — the post-write verification state machine, each `test_MUTATION_…` there disarming through `_run_classify(mutate=…)` the shipped clause it names, bound by a count assertion — every clause with a predecessor is mutated back to it and the rest are counterfactual, which that file's own section header enumerates, and the one clause with no reachable fixture is named there rather than counted. It does NOT cover the runner's step wiring, which no local test can reach | ### The four jobs with no dropped-step guard, decided per job (ersatztv#786) diff --git a/docs/remote-state-inventory.md b/docs/remote-state-inventory.md index 095b84a4a..3d56944ba 100644 --- a/docs/remote-state-inventory.md +++ b/docs/remote-state-inventory.md @@ -145,7 +145,7 @@ classifications differ; otherwise the strictest applies and the Note names the e | Site | Class | Note | |---|---|---| -| `.gitea/workflows/review-verdict.yml` — status read → status POST | `UNSAFE-KNOWN` | The residual this whole class reduces to. Gitea's status API has no ETag, no If-Match and no expected-previous-state, so read and write cannot be made one operation. Narrowed rather than claimed closed: a monotonic **event-count** fence refuses to write if the PR timeline's `change_target_branch` OR `pull_push` count moved (`ci.verdict-write-retarget-fence`; counts are used because the branch *name* and the head *sha* are both ABA-vulnerable — the head axis added by #803), and a high-water-mark re-read repairs a status posted over a human verdict back to `pending` — after EVERY write since #849, not only an exemption `success`, because a generic `pending` masks a rejection just as well and was then re-derived green by the next run. Since #849 the write side is also fenced on the far end: the retarget count is re-taken AFTER the POST on the exemption path (closing the permanent forged green that record listed as residual 1), a write no high-water mark can cover becomes the sticky sentinel rather than a status a later run re-derives, and every path that cannot establish what the head carries — the combined read, the four page-2 completeness refusals, and an untrusted fence holding a derived `success` — REPLACES that unknown state instead of leaving a possibly-forged green standing (`ci.verdict-unverified-write-sentinel`). The post-POST re-count makes the retarget green TRANSIENT, not absent: it is live between its POST and the repair. The file states the residual window explicitly rather than asserting safety. | +| `.gitea/workflows/review-verdict.yml` — status read → status POST | `UNSAFE-KNOWN` | The residual this whole class reduces to. Gitea's status API has no ETag, no If-Match and no expected-previous-state, so read and write cannot be made one operation. Narrowed rather than claimed closed: a monotonic **event-count** fence refuses to write its CLASSIFICATION if the PR timeline's `change_target_branch` OR `pull_push` count moved — and since #849 marks the head with the unverified-write sentinel when it carries a row that run did not inherit (`ci.verdict-write-retarget-fence`; counts are used because the branch *name* and the head *sha* are both ABA-vulnerable — the head axis added by #803), and a high-water-mark re-read repairs a status posted over a human verdict back to `pending` — after EVERY write since #849, not only an exemption `success`, because a generic `pending` masks a rejection just as well and was then re-derived green by the next run. Since #849 the write side is also fenced on the far end: the retarget count is re-taken AFTER the POST on the exemption path (closing the permanent forged green that record listed as residual 1), a write no high-water mark can cover becomes the sticky sentinel rather than a status a later run re-derives, and every path that cannot establish what the head carries — the combined read, the four page-2 completeness refusals, and an untrusted fence holding a derived `success` — REPLACES that unknown state instead of leaving a possibly-forged green standing (`ci.verdict-unverified-write-sentinel`). The post-POST re-count makes the retarget green TRANSIENT, not absent: it is live between its POST and the repair. The file states the residual window explicitly rather than asserting safety. | | `.gitea/workflows/review-verdict.yml` — base-ref checkout | `PINNED` | `ref: ${{ github.event.pull_request.base.sha }}` — a full sha from the fixed event payload, so the PR head cannot supply the workflow definition that judges it. | | `.gitea/workflows/review-verdict.yml` — changed-file enumeration | `UNSAFE-KNOWN` | Delegates to `scripts/pr-changed-files.sh` with the head sha and expected base, and therefore **inherits that row's residual, not a pin** — a cross-reference to another row's GRADE goes stale the moment that row is regraded, so this one names what it inherits. Accepted on better terms than the enumerator alone — this is the one caller that also runs the monotonic event-count fence (`ci.verdict-write-retarget-fence`), and since #803 that fence covers BOTH axes: `change_target_branch` for the base alias and `pull_push` for the HEAD alias described in the enumerator's row (`H1 -> H2 -> H1` during pagination). What remains is the residual the fence shares with the base axis — a mutation landing between the final pre-write count and the POST — not an unwatched axis, and since #849 the RETARGET half of it is caught by a post-POST re-count while the push half is deliberately not (a push moves the head, so the status no longer gates that PR). | | `.gitea/workflows/docker-build.yml` — CI toolchain image | `UNSAFE-KNOWN` | This file's own definition of `PINNED` names an image **digest**, and `ersatztv-ci:` is a mutable **tag**. A registry tag can be repointed after `ci-image-pin` verifies it and before a job pulls it, and a 7-hex short sha is additionally collision-prone. Accepted rather than fixed here because the exposure needs write access to our own LAN registry — i.e. an attacker already inside the trust boundary — and two controls bound it: jobs never consume `:latest`, and `pr-checks.yml`'s `ci-image-pin` fails the build if the tag drifts from the last commit touching `docker/ci/`. Consuming `image@sha256:…` is the real fix and is the natural companion to **#772**, which already covers the availability half of this tag's weakness. | diff --git a/scripts/tests/test_pr_changed_files.py b/scripts/tests/test_pr_changed_files.py index 00c7cdc93..172368813 100644 --- a/scripts/tests/test_pr_changed_files.py +++ b/scripts/tests/test_pr_changed_files.py @@ -1582,6 +1582,30 @@ if "/status" in url: "description": "Review-verdict: MERGEABLE @ a9e3e23 (base: main)"}] print(json.dumps({"state": "success", "total_count": len(rows), "statuses": rows})) sys.exit(0) + if mode.startswith("sentinel-with-id:"): + # THE CURRENT SENTINEL, AT A STABLE ID, on both reads. The reconciliation witness has to + # identify THIS row, so a fixture needs the combined endpoint to name an id that the seeded + # history may or may not contain — which is the whole discriminator between matching the row + # and matching its text. `:str` asks for a STRING id, for the numeric guard. + raw_id = mode.split(":", 1)[1] + row_id = raw_id[4:] if raw_id.startswith("str-") else int(raw_id) + rows = [ + {"context": "ci/decoy", "status": "pending", "creator": None, "description": "unrelated"}, + {"id": row_id, "context": "review-verdict/h10", "status": "pending", + "creator": None, "description": os.environ["STUB_UNVERIFIED_DESC"]}, + ] + print(json.dumps({"state": "pending", "total_count": len(rows), "statuses": rows})) + sys.exit(0) + if mode == "malformed-creator-field": + # THE EXISTING h10 ROW WITH A CORRUPT `creator`. `.statuses` is an array of objects and + # `total_count` agrees, so the shape gates pass; it is a CONSUMED FIELD whose type the schema + # does not allow. Reading it as "no creator" makes the row unattributable, which is a licence + # to re-derive — over a human `failure`. + rows = [{"id": 5, "context": "review-verdict/h10", "status": "failure", + "creator": 7, + "description": "Review-verdict: BLOCKED @ a9e3e23 (base: main)"}] + print(json.dumps({"state": "failure", "total_count": len(rows), "statuses": rows})) + sys.exit(0) if mode == "id-appears-on-second-read": # THE SAME ROW, reported once WITHOUT `id` and once WITH it. Nothing else about it moves. An # id comparison that does not require both sides to be present reads this as a replacement @@ -5039,7 +5063,9 @@ def test_an_UNREADABLE_history_page_2_also_repairs_rather_than_leaving_green(tmp # # * `test_MUTATION_a_SUCCESS_only_post_write_gate_...` and # `test_MUTATION_restoring_the_default_operator_on_the_description_...` DO restore `origin/main` -# text verbatim. +# text verbatim; `test_MUTATION_restoring_the_SUCCESS_only_no_mark_downgrade_...` and +# `test_MUTATION_reading_a_malformed_FIELD_as_absent_...` restore text an EARLIER COMMIT ON THIS +# BRANCH shipped, which is where two cold reviews found survivors. # * `test_MUTATION_a_GENERIC_pending_...` restores the shape #742 attempted and WITHDREW, not # `main` — which had no downgrade at all. # * `test_MUTATION_restoring_the_SUCCESS_only_no_mark_downgrade_...` restores the predicate this @@ -6077,6 +6103,232 @@ def test_MUTATION_comparing_ids_WITHOUT_requiring_both_makes_the_run_abstain(tmp ) +def test_a_MALFORMED_creator_FIELD_on_the_existing_row_is_unknown_state_not_an_absent_one(tmp_path): + """A wrong TYPE is not an absent value, and reading it as one is a licence to re-derive. + + Round 3 type-tested the four consumed fields and resolved a failure to `""`. For `.creator` that + means "no creator", i.e. unattributable, i.e. re-derive — so a head carrying a human `failure` + with a corrupt creator was greened. `origin/main` died on `.creator.login` BEFORE writing + anything, which is fail-closed, so this was a direction regression rather than a residual. + + The rationale that produced it came from #763, whose site is the POST-WRITE filter: there, dying + leaves a green already published, so dropping the row is the safe direction. Here the alternative + is dying before any write. The deferral rationale did not transfer. + """ + posted, r = _run_classify(tmp_path / "fixed", _emitting("docs/a.md"), status_mode="malformed-creator-field") + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + f"a corrupt creator on a human rejection produced {posted}, not the sentinel\n{r.stdout[-900:]}" + ) + assert r.returncode != 0, "an unreadable row must still fail the job" + + +def test_MUTATION_reading_a_malformed_FIELD_as_absent_greens_a_rejection(tmp_path): + """The round-3 form restored: type-test, then fall back to the empty string.""" + posted, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="malformed-creator-field", + mutate=( + 'elif (.creator | type) == "null" then "" else $f end\')', + 'else "" end\')', + ), + ) + assert posted is not None and posted["state"] == "success", ( + "the mutant did not green the rejection, so the schema-fault route is not what prevents it " + f"and the test above proves nothing about it: {posted}\n{rm.stdout[-900:]}" + ) + + +def test_an_observed_retarget_does_NOT_bury_the_repair_sentinel(tmp_path): + """The arms may mark, but never with a weaker description than the head already carries. + + `$REPAIR_DESC` is human-clearable only; `replace_unknown_state` writes the machine-clearable one. + A head carrying the repair sentinel has a non-empty `pre_state`, so the bare scope test fired on + it and inverted the ordering the repair site's own floor exists to protect — one mechanism, three + writers, and only two had the rule. + """ + posted, r = _run_classify( + tmp_path, + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=REPAIR_DESC, + timeline_mode="moves:0,1", + ) + assert posted is None, f"the repair sentinel was overwritten by an abstaining run: {posted}" + + +def test_an_observed_retarget_does_NOT_bury_an_ALLOWLISTED_reviewers_verdict(tmp_path): + """ "Declined" is decided against THIS event's base, so it is not a judgement about the row. + + A genuine verdict recorded for another base is declined here and is still the right answer for + the base it names — the successor run for that base short-circuits on it. Burying it costs a + manual re-post on an ordinary retarget-onto-the-reviewed-base flow. An OFF-list row is what the + marking exists for and is still marked, which the sibling test above asserts. + """ + posted, r = _run_classify( + tmp_path, + _emitting("docs/a.md"), + status_mode="existing:success", + status_creator="timothy", + status_desc="Review-verdict: MERGEABLE @ a9e3e23 (base: probe/scratch)", + timeline_mode="moves:0,1", + ) + assert posted is None, ( + f"a reviewer's verdict for another base was buried by an abstaining run: {posted}\n{r.stdout[-900:]}" + ) + + +def test_MUTATION_a_bare_scope_test_on_the_arms_buries_both(tmp_path): + """Both narrowings, disarmed together — they are one `if`, and the fixture is the repair + sentinel, which is the case with the sharper consequence.""" + posted, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=REPAIR_DESC, + timeline_mode="moves:0,1", + mutate=( + 'if [ "$desc" = "$REPAIR_DESC" ] || [ "$pre_desc" = "$REPAIR_DESC" ]; then return 0; fi', + ":", + ), + ) + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + "the mutant did not bury the repair sentinel, so the narrowing is not what protects it: " + f"{posted}\n{rm.stdout[-900:]}" + ) + + +def test_an_observed_PUSH_also_marks_a_row_this_run_did_not_inherit(tmp_path): + """The HEAD arm, not just the base arm. + + Two call sites of one helper, and only the retarget one had a fixture — the shape where a fix + lands on one caller and the other keeps the old behaviour unobserved. The head arm is the one + whose own message names the case with NO successor at all: a PR's first push. + """ + posted, r = _run_classify( + tmp_path, + _emitting("docs/a.md"), + status_mode="existing:success", + status_creator="mallory", + push_mode="moves:0,1", + ) + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + f"an off-list `success` was left authoritative while the head arm abstained: {posted}\n{r.stdout[-900:]}" + ) + assert "head branch was pushed while this job was classifying" in (r.stdout + r.stderr), ( + f"marked, but not by the head arm:\n{r.stdout[-900:]}" + ) + + +def test_MUTATION_dropping_the_HEAD_arms_mark_leaves_the_row_authoritative(tmp_path): + """Disarms the head arm's call site ALONE, so the base arm's fixture cannot cover for it.""" + posted, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="existing:success", + status_creator="mallory", + push_mode="moves:0,1", + mutate=( + 'mark_declined_row_if_any "PR #${PR}\'s head branch was pushed while this job was classifying." || exit 1', + ":", + ), + ) + assert posted is None, ( + "the mutant still marked, so the head arm's own call site is not what does it and the test " + f"above proves nothing about it: {posted}" + ) + + +def test_a_FAILED_mark_on_an_arm_FAILS_the_job(tmp_path): + """The helper's result propagates from the ARM call sites too, not only the fence branch.""" + posted, r = _run_classify( + tmp_path, + _emitting("docs/a.md"), + status_mode="existing:success", + status_creator="mallory", + timeline_mode="moves:0,1", + post_fails=True, + ) + assert posted is None, "the stub was supposed to reject every POST" + assert r.returncode != 0, ( + "the arm reported a clean abstention while the row it meant to mark is still authoritative " + f"and every POST had failed:\n{r.stdout[-900:]}" + ) + + +def test_MUTATION_swallowing_the_marks_result_reports_a_clean_abstention(tmp_path): + """The predecessor: the helper returning 0 whatever the write did.""" + _, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="existing:success", + status_creator="mallory", + timeline_mode="moves:0,1", + post_fails=True, + mutate=('no successor is queued at all." || return 1', 'no successor is queued at all." || true'), + ) + assert rm.returncode == 0, ( + "the mutant still failed the job, so the helper's `|| return 1` is not what propagates a " + f"failed write: rc={rm.returncode}\n{rm.stdout[-900:]}" + ) + + +def test_the_reconciliation_witness_needs_the_CURRENT_row_not_an_OLDER_identical_one(tmp_path): + """Two sentinels are byte-identical, so "a row with this description" is the wrong question. + + The head's CURRENT sentinel has id 100; the history carries only an OLDER identical one at 999, + and the verdict buried under the current sentinel is not in this (stale) read either. Matching by + description is satisfied, the sentinel clears, and the run exempts a head whose write was never + verified. Matching the id this read reported is not. + """ + seed = [_sentinel_row(999)] + posted, r = _run_classify( + tmp_path / "fixed", + _emitting("docs/a.md"), + status_mode="sentinel-with-id:100", + history_extra=seed, + ) + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + f"an older identical sentinel satisfied the witness: {posted}\n{r.stdout[-900:]}" + ) + + mutant, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="sentinel-with-id:100", + history_extra=seed, + mutate=('if [ -n "$ex_id" ]; then', "if false; then"), + ) + assert mutant is not None and mutant["state"] == "success", ( + "the mutant did not clear the sentinel, so the id branch is not what refuses an older " + f"identical row and the assertion above proves nothing about it: {mutant}\n{rm.stdout[-900:]}" + ) + + +def test_a_STRING_id_on_the_current_row_takes_the_SCHEMA_FAULT_route(tmp_path): + """The fault route is swept by FIELD, not written once for the field that produced it. + + `.id` is consumed by the reconciliation witness, and a string where the schema says number is the + same class of unreadable as the corrupt `.creator` above. Feeding it to `--argjson` is a jq parse + error, which would make the witness unusable and carry the sentinel forward for a reason nobody + can see; routing it to the fault path says so instead. + + The DESCRIPTION fallback in the witness is still live and still the documented behaviour — it is + reached when `.id` is legitimately ABSENT, which is every `existing:` fixture in this file, and + the id branch itself is pinned by the mutation in the test above. + """ + posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode="sentinel-with-id:str-77") + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + f"a string id did not take the fault route: {posted}\n{r.stdout[-900:]}" + ) + assert r.returncode != 0, "an unreadable row must still fail the job" + assert "carries a type the schema does not allow" in (r.stdout + r.stderr), ( + f"the sentinel was written, but not by the schema-fault route:\n{r.stdout[-900:]}" + ) + + # --- Workflow token scope (ersatztv#748) ------------------------------------------------------- # # Both assertions guard a property whose violation is SILENT and, for the first, unrecoverable. -- 2.47.3 From bbaf1d76f81c363a28f37ea2c4296b03752940dc Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 30 Aug 2026 03:02:22 +0200 Subject: [PATCH 05/11] =?UTF-8?q?fix(849):=20round=205+6=20=E2=80=94=20the?= =?UTF-8?q?=20survivors=20a=20mutation=20SWEEP=20found,=20and=20the=20six?= =?UTF-8?q?=20that=20cannot=20be=20reached?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex was unavailable for this round (usage quota), so the cross-family reviewer was replaced by a same-family agent doing one mechanical job: enumerate every security-bearing clause the diff adds, disarm each, and run the WHOLE suite per mutant. 60 mutants, 40 red, 20 survivors — a yield no per-finding review in this series came close to, because a review looks at what the diff says it does and a sweep looks at what the tests actually pin. ## Proved (nine) - the description type test in the RECONCILIATION `buried` filter — exact twin of the post-write one, which had a proof; without it a numeric description hard-errors `startswith`, the count comes back unusable, and the genuine verdict on the next row is lost with it; - the `.status` / `.description` / `.id` type tests, parametrised over all four consumed fields so a fifth cannot be added without a case (`.creator`'s was the only one proved); - both retry loops — the combined read and `repair_status_to`'s second POST. Against a stub that fails EVERY attempt a retrying reader and a one-shot reader are indistinguishable, which is how a retry ships unexercised; the fixtures now fail only the first attempt; - the mid-run guard's self-exemption, which is what stops a sentinel-writing run abstaining on the row it was about to replace with an equivalent one; - both repair-write failure paths (the repair and the post-POST replacement), reachable only with a stub that lets the FIRST post through and fails the rest — with every post failing the job dies on its own classification write and never reaches them; - the two `state=pending` updates after a repair. The first is load-bearing beyond tidiness: without it a repaired head re-enters the post-POST check and, on a retarget it then observes, replaces `$REPAIR_DESC` with the weaker reconcilable sentinel — the same ordering inversion the floor beside it exists to prevent, reached by another route. ## Declared unreachable (six), enumerated rather than counted The path-predicate failure branch; the empty-`row` refusal; page 2's non-numeric length; the `$witness` normalisation; and the two unusable-count arms. Each is defence in depth behind a filter that makes its input well-formed for every case a fixture can pose — the same standing exception the post-write unusable-count arm already carried. That set has gone two -> five -> six across three rounds as the sweep widened. Naming them is the point: an inventory that undercounts reads as a checked claim and talks the next reader out of verifying, which is the same defect as inventing coverage — and this branch has already had to correct that twice. refs #849 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF --- .../ci/verdict-unverified-write-sentinel.md | 2 +- docs/guard-inventory.md | 2 +- scripts/tests/test_pr_changed_files.py | 366 +++++++++++++++++- 3 files changed, 366 insertions(+), 4 deletions(-) diff --git a/docs/decisions/records/ci/verdict-unverified-write-sentinel.md b/docs/decisions/records/ci/verdict-unverified-write-sentinel.md index a7e2ca882..f89b51113 100644 --- a/docs/decisions/records/ci/verdict-unverified-write-sentinel.md +++ b/docs/decisions/records/ci/verdict-unverified-write-sentinel.md @@ -7,7 +7,7 @@ supersedes: none superseded-by: none rule: 'The `review-verdict/h10` job runs its post-write race check after EVERY status write, not only an exemption `success`, and where no mark exists to run it against, the write ITSELF becomes the sentinel — so every status this job writes is either verified against the history or marked as unverifiable: a GENERIC `pending` masks a rejection landing in its own write window exactly as a `success` does, and because it carries no marker a later run re-derives it into an exemption with the human row now below THAT run''s high-water mark — the damage arrives one event later, not never. Where a write cannot be checked at all the job writes a SECOND sentinel, `UNVERIFIED_DESC`, distinct from the repair sentinel and never interchangeable with it: the repair sentinel ASSERTS that a verdict existed and was buried, which only the arm that actually COUNTED such a row may claim, while every "could not check" arm — an unreadable or over-cap history, an impossible empty history, an unusable count — states only what it established. Both are STICKY (the classification refuses to grant an exemption over either, and re-writes its own description verbatim, so each is a FIXED POINT a later run cannot re-derive into `success`); only the unverified one is RECONCILABLE. Reconciliation is what bounds the stall that got the #742 attempt withdrawn: a later run pages `/statuses/{sha}` IN FULL and, ONLY IF that history contains the sentinel''s own row, either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it UPGRADES to the repair sentinel, clearable only by a human — or finds none, resolving the uncertainty and clearing it so the PR is classified normally. The reconciliation is sound because the two endpoints differ: a verdict this job masked is invisible on the COMBINED endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. It is deliberately BROADER than the inheritance test — no `H10_REVIEWERS` membership, no `(base: …)` match — because over-counting upgrades to a stall a human clears in one command while MISSING one re-exempts a head carrying a buried rejection. ONLY a COMPLETE walk CONTAINING THE CURRENT SENTINEL ROW — matched by the `id` the combined read reported, not merely by a row with the same description — may clear it. Description alone is satisfied by an OLDER identical sentinel, which is exactly what a fixed point produces: with an earlier sentinel and a buried verdict below the current one, a read carrying only the earlier row satisfies the test, the sentinel clears, and the verdict ends up below the fresh mark. Where the server omits `id` there is nothing to match on and the check degrades to the description, which is the pre-existing behaviour rather than a new hole. The witness is not optional decoration: `ex_unverified` means the combined endpoint just returned that row, and `/statuses/{sha}` keeps one row per POST, so a complete history WITHOUT it — an empty one included — contradicts a write that demonstrably happened. `page_statuses` accepts an empty page 1 as complete (correct for a first-run mark), which is what made that shape reachable, and clearing on it exempted a head whose sentinel may have been sitting on a rejection. This is NOT the withdrawn currency witness: that asked whether ANY row sat above the mark, which an unrelated row satisfied, and it made one anomaly permanent — this asks for a SPECIFIC row already known to exist. Failing it costs one run in the transient case; it is NOT bounded in general, because a history past the 20-page cap can never be walked completely, so such a head needs a human verdict and no later run will clear it. When NO high-water mark can be established the write is withheld BEFORE the POST rather than posted and repaired, since the defect is known in advance and publishing a green to take it back opens a window branch protection — and an already-scheduled auto-merge — can see. EVERY re-derivable state is downgraded there, not only `success`: an earlier draft restricted it to the exemption on the grounds that a sticky generic `pending` "withholds nothing, since an unreviewed PR is blocked already", which analysed the wrong PR — the damaging case is one that IS exemptible and got the generic `pending` only from a transient enumeration failure, whose unmarked description the next run re-derives into the exemption with the human row below its own mark. `$REPAIR_DESC` is the one exemption, being the stronger fact and not re-derivable. SEPARATELY, the retarget count is re-taken AFTER the POST on the exemption path, which closes the PERMANENT forged green `ci.verdict-write-retarget-fence` recorded as its residual: a retarget landing after the final pre-write count leaves a stale `success` with the `edited` event already consumed by a successor that short-circuited, so nothing remains to reclassify. Re-counting afterwards means a retarget later than the re-check necessarily queues a successor that STARTS after the stale `success` exists, and a machine-written `success` is re-derived rather than inherited. The RETARGET axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. An untrusted post-POST count repairs too — reaching that point with `success` means both earlier counts were trusted, so a third unreadable read is a fresh failure and "I cannot tell whether the base moved" must not resolve to leaving a green. FINALLY, every path that cannot establish what the head carries REPLACES the unknown state instead of merely declining to write — the combined read (retried once first), all four page-2 completeness refusals, and the fence branch that cannot trust its retarget count while holding a derived `success`. That last one is reached only AFTER the classification declined to inherit the row the head carries, so posting nothing left the declined row current; its message used to say the context "stays absent", which is true only of a head that had none. The two OBSERVED-mutation arms take the same decision for the same reason, scoped to a head that actually carries a declined row: they still refuse to post their CLASSIFICATION, which was computed against a base or head the PR may no longer have, but a row this run declined must not stay authoritative for the whole window until a successor finishes — and for a PR''s FIRST push no successor is queued at all. Every element and every consumed field of the combined response is TYPE-CHECKED before extraction, and a schema failure routes to the same replacement rather than dying: `.statuses` being an array was checked and its ELEMENTS were not, so one scalar made `select(.context == $c)` hard-error and `set -e` took the step down before any path could mark the head. The path-predicate failure replaces too. And the write helper RETURNS a status: its first version ended the failure arm with a successful `echo`, so it reported 0 after both POSTs failed and the fence caller exited 0 as though the head had been marked. Declining protects a real verdict and leaves a FORGED one — an off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving it, and an unreadable read is the one thing that stops it while the job goes red on a status branch protection does not read. Nothing is destroyed: the per-POST history keeps the masked verdict and the next run''s reconciliation upgrades to the repair sentinel, telling the reviewer to re-post rather than silently un-approving them. The page-2 refusals were briefly excluded on the reasoning that the probe fires when NO row for this context was on page 1, so there is no green of any provenance to leave standing — self-contradictory, since the ONLY reason page 2 is read is that the row MAY be beyond page 1. WRITING THE SENTINEL AND FAILING THE JOB ARE SEPARATE DECISIONS: the read refusals were already non-zero exits on `main` and stay red, while the fence branch exited 0 there and still does, because an unreadable timeline is an ordinary hiccup and reddening every one of them is noise this file elsewhere refuses to add. The repair also has a FLOOR — it may never write a description weaker than the one this run decided, or a transient post-write read rewrites a correct `$REPAIR_DESC` carry-forward with the machine-clearable sentinel — and it is skipped entirely when it would rewrite what is already there.' signals: 'exemption success standing over a human failure, generic pending re-derived into an exemption, unverified write sentinel, reconcilable sentinel vs repair sentinel, no high-water mark could be established, post-write verification runs for every write, retarget after the POST leaves a permanent forged green, unreadable combined status read leaves an off-list green, why did my docs-only PR lose its exemption, why does the gate say the write could not be verified · paths: `.gitea/workflows/review-verdict.yml`, `scripts/tests/test_pr_changed_files.py` · issues: #849, #742, #763, #706, #803' -mechanics: '`UNVERIFIED_DESC` ("Status write could not be verified — re-post the verdict") beside `REPAIR_DESC`; `read_existing_verdict()` sets `ex_unverified` from a prefix match, retries the combined read once and on persistent failure calls `repair_status_to "$UNVERIFIED_DESC"` before `exit 1`; the RECONCILE block runs `page_statuses` and counts `Review-verdict:` rows with a non-null creator, clearing `ex_unverified`, upgrading to `ex_repair`, or carrying the sentinel forward on `ph_ok != yes`; the classification chain is `exempt` → `ex_repair` → `ex_unverified` → generic, so the repair sentinel outranks the unverified one; the no-mark downgrade sits AFTER the mark (referencing `$max_id_before` earlier is an unbound variable under `set -u`); the mid-run guard adds `[ "$ex_desc" != "$pre_desc" ]`, which the repair guard does not need because a pre-existing repair sentinel forces its own description while a pre-existing unverified one is deliberately replaced after reconciliation; the post-write gate is `if [ "$max_id_before" -ge 0 ]`, with the no-mark case handled before the POST instead; `replace_unknown_state()` is the shared writer for every path that cannot establish the head''s state and `replace_unknown_and_die()` wraps it for the two read refusals that were already non-zero exits; the reconciliation witness is a count of rows carrying the `id` the combined read reported, falling back to a description match only where the server omits `id`; `pre_id`/`ex_id` extend the mid-run changed-row comparison to row IDENTITY through a shared `row_replaced` flag, since two sentinel POSTs are byte-identical by design — and an id difference counts only when BOTH reads supplied one, because a response that omits `id` beside one that includes it would otherwise report a replacement that did not happen and make the run abstain over a row it had already declined (the combined endpoint carries `id` — measured 2026-08-29 at 1.27.1, head 736649b3, 8 rows, ids 14..30); `--arg own "$desc"` excludes the job''s OWN row from both machine-sentinel selectors, which became necessary the moment the block started running for every write; `.description` is TYPE-TESTED before `startswith`, since `(.description // "")` does not replace a NUMBER and `startswith` then hard-errors, killing the whole count and losing a genuine verdict beside the malformed row; `repair_status_to()` is the shared retry-once writer for all three repair sites; tests `test_a_verdict_racing_a_PENDING_write_is_repaired_to_the_repair_sentinel`, `test_a_raced_PENDING_write_repairs_and_the_repair_SURVIVES_the_next_run`, `test_the_unverified_sentinel_is_a_FIXED_POINT_while_it_cannot_be_reconciled`, `test_the_unverified_sentinel_is_RECONCILED_AWAY_once_the_history_can_be_read`, `test_the_reconciliation_UPGRADES_to_the_repair_sentinel_when_a_verdict_is_BURIED`, `test_a_RETARGET_AFTER_the_POST_replaces_the_exemption`, `test_an_UNREADABLE_retarget_count_AFTER_the_POST_also_replaces_the_exemption`, `test_a_MALFORMED_description_row_does_not_kill_the_post_write_count`, `test_a_transport_failure_on_the_STATUS_READ_REPLACES_the_unknown_state`, each paired with a `test_MUTATION_…` proof that disarms the shipped clause it names through `_run_classify(mutate=…)`, whose count assertion is the binding. Some of those mutants restore text this branch or `origin/main` actually shipped — every clause that HAS a predecessor is mutated back to it — and the rest disarm clauses that have none, because the blocks they gate are new — the section header in that file enumerates which is which, because "restores the exact predecessor" is true of only some of them. TWO CLAUSES ARE DELIBERATELY UNPROVEN and say so rather than being counted: the path-predicate failure branch, which fires only when `grep -c` exits above 1, and the empty-`row` refusal, which the type-safe filter makes unreachable because `first // {}` always yields an object for any body that passed the shape gate. Neither has a fixture that can reach it — the same standing exception the post-write unusable-count arm already carries, and it is defence in depth behind a filter that makes the count numeric for every input a stub can pose.' +mechanics: '`UNVERIFIED_DESC` ("Status write could not be verified — re-post the verdict") beside `REPAIR_DESC`; `read_existing_verdict()` sets `ex_unverified` from a prefix match, retries the combined read once and on persistent failure calls `repair_status_to "$UNVERIFIED_DESC"` before `exit 1`; the RECONCILE block runs `page_statuses` and counts `Review-verdict:` rows with a non-null creator, clearing `ex_unverified`, upgrading to `ex_repair`, or carrying the sentinel forward on `ph_ok != yes`; the classification chain is `exempt` → `ex_repair` → `ex_unverified` → generic, so the repair sentinel outranks the unverified one; the no-mark downgrade sits AFTER the mark (referencing `$max_id_before` earlier is an unbound variable under `set -u`); the mid-run guard adds `[ "$ex_desc" != "$pre_desc" ]`, which the repair guard does not need because a pre-existing repair sentinel forces its own description while a pre-existing unverified one is deliberately replaced after reconciliation; the post-write gate is `if [ "$max_id_before" -ge 0 ]`, with the no-mark case handled before the POST instead; `replace_unknown_state()` is the shared writer for every path that cannot establish the head''s state and `replace_unknown_and_die()` wraps it for the two read refusals that were already non-zero exits; the reconciliation witness is a count of rows carrying the `id` the combined read reported, falling back to a description match only where the server omits `id`; `pre_id`/`ex_id` extend the mid-run changed-row comparison to row IDENTITY through a shared `row_replaced` flag, since two sentinel POSTs are byte-identical by design — and an id difference counts only when BOTH reads supplied one, because a response that omits `id` beside one that includes it would otherwise report a replacement that did not happen and make the run abstain over a row it had already declined (the combined endpoint carries `id` — measured 2026-08-29 at 1.27.1, head 736649b3, 8 rows, ids 14..30); `--arg own "$desc"` excludes the job''s OWN row from both machine-sentinel selectors, which became necessary the moment the block started running for every write; `.description` is TYPE-TESTED before `startswith`, since `(.description // "")` does not replace a NUMBER and `startswith` then hard-errors, killing the whole count and losing a genuine verdict beside the malformed row; `repair_status_to()` is the shared retry-once writer for all three repair sites; tests `test_a_verdict_racing_a_PENDING_write_is_repaired_to_the_repair_sentinel`, `test_a_raced_PENDING_write_repairs_and_the_repair_SURVIVES_the_next_run`, `test_the_unverified_sentinel_is_a_FIXED_POINT_while_it_cannot_be_reconciled`, `test_the_unverified_sentinel_is_RECONCILED_AWAY_once_the_history_can_be_read`, `test_the_reconciliation_UPGRADES_to_the_repair_sentinel_when_a_verdict_is_BURIED`, `test_a_RETARGET_AFTER_the_POST_replaces_the_exemption`, `test_an_UNREADABLE_retarget_count_AFTER_the_POST_also_replaces_the_exemption`, `test_a_MALFORMED_description_row_does_not_kill_the_post_write_count`, `test_a_transport_failure_on_the_STATUS_READ_REPLACES_the_unknown_state`, each paired with a `test_MUTATION_…` proof that disarms the shipped clause it names through `_run_classify(mutate=…)`, whose count assertion is the binding. Some of those mutants restore text this branch or `origin/main` actually shipped — every clause that HAS a predecessor is mutated back to it — and the rest disarm clauses that have none, because the blocks they gate are new — the section header in that file enumerates which is which, because "restores the exact predecessor" is true of only some of them. SIX CLAUSES ARE DELIBERATELY UNPROVEN and are ENUMERATED rather than counted, because an inventory that undercounts is the same overclaim as one that invents coverage: the path-predicate failure branch (fires only when `grep -c` exits above 1); the empty-`row` refusal (`first // {}` always yields an object for any body that passed the shape gate); page 2''s non-numeric length (`(.statuses // []) | length` is numeric for every body that reaches it, and a non-JSON body is caught one branch earlier by `more_kind`); the `$witness` non-numeric normalisation (`select(((.id | numbers) // -1) == $wid)` cannot error, and a non-numeric `.id` takes the schema-fault route BEFORE the witness runs); the unusable-`buried`-count arm (same argument, plus its own description type test); and the unusable-`raced_unverified`-count arm, whose filter is type-safe for every row a fixture can pose. Each is defence in depth behind a filter that makes its input well-formed for every case a fixture can pose — the same standing exception the post-write unusable-count arm already carries. They were found by a mutation SWEEP over every clause the diff adds, which is the technique that finds this class; a per-finding review does not — the same standing exception the post-write unusable-count arm already carries, and it is defence in depth behind a filter that makes the count numeric for every input a stub can pose.' --- `ci.verdict-write-retarget-fence` fenced the write and verified it afterwards. Five routes survived diff --git a/docs/guard-inventory.md b/docs/guard-inventory.md index 727f565aa..133280191 100644 --- a/docs/guard-inventory.md +++ b/docs/guard-inventory.md @@ -331,7 +331,7 @@ job it covers, because a proof reference that covers a fraction must not read as | `pr-checks.yml::decisions-guard` | a PR whose decision records fail lifecycle validation, whose catalog is stale, or whose kickoff file drifted | GUARD | `scripts/decisions_validate.py`, `scripts/build_decisions_catalog.py`, `scripts/check-kickoff-guard.sh` | those scripts' rows above | | `pr-checks.yml::prove-fix` | a PR whose `Proves:` trailer names a test that passes without the fix | GUARD | inline + `scripts/prove-fix.sh` | that script's row above | | `pr-checks.yml::script-tests` | a PR failing ruff or the `scripts/tests` suite — this job is the RUNNER for every `scripts/tests/` row above | GUARD | inline (the ruff population guards) + pytest | the suite it runs | -| `review-verdict.yml::set-verdict-status` | a MERGE, by withholding the branch-protection-required `review-verdict/h10` status | GUARD | inline | `scripts/tests/test_pr_changed_files.py`, which EXECUTES the shipped `run:` body: it guards the changed-file derivation, the exemption classification, the timeline fence and — since #849 — the post-write verification state machine, each `test_MUTATION_…` there disarming through `_run_classify(mutate=…)` the shipped clause it names, bound by a count assertion — every clause with a predecessor is mutated back to it and the rest are counterfactual, which that file's own section header enumerates, and the one clause with no reachable fixture is named there rather than counted. It does NOT cover the runner's step wiring, which no local test can reach | +| `review-verdict.yml::set-verdict-status` | a MERGE, by withholding the branch-protection-required `review-verdict/h10` status | GUARD | inline | `scripts/tests/test_pr_changed_files.py`, which EXECUTES the shipped `run:` body: it guards the changed-file derivation, the exemption classification, the timeline fence and — since #849 — the post-write verification state machine, each `test_MUTATION_…` there disarming through `_run_classify(mutate=…)` the shipped clause it names, bound by a count assertion — every clause with a predecessor is mutated back to it and the rest are counterfactual, which that file's own section header enumerates, and the six clauses with no reachable fixture are enumerated in that record rather than counted. It does NOT cover the runner's step wiring, which no local test can reach | ### The four jobs with no dropped-step guard, decided per job (ersatztv#786) diff --git a/scripts/tests/test_pr_changed_files.py b/scripts/tests/test_pr_changed_files.py index 172368813..40a54eefd 100644 --- a/scripts/tests/test_pr_changed_files.py +++ b/scripts/tests/test_pr_changed_files.py @@ -956,6 +956,24 @@ url = [a for a in args if a.startswith("http")][-1] out = pathlib.Path(os.environ["STUB_DIR"]) if "-X" in args and args[args.index("-X") + 1] == "POST": + if os.environ.get("STUB_POST_FAILS") == "after-first": + # THE FIRST POST SUCCEEDS AND EVERY LATER ONE FAILS. That is the only arrangement that + # reaches a REPAIR write's failure handling: with every POST failing, the job dies on the + # first one and never gets there, so the two behaviours are indistinguishable. + ctr = out / "post_attempts.txt" + seen = int(ctr.read_text()) if ctr.exists() else 0 + ctr.write_text(str(seen + 1)) + if seen > 0: + sys.exit(22) + if os.environ.get("STUB_POST_FAILS") == "first": + # ONLY THE FIRST ATTEMPT, so a writer that retries succeeds and one that does not fails. + # Against a stub that fails EVERY attempt the two are indistinguishable, which is how a + # retry loop ends up shipped and unexercised. + ctr = out / "post_attempts.txt" + seen = int(ctr.read_text()) if ctr.exists() else 0 + ctr.write_text(str(seen + 1)) + if seen == 0: + sys.exit(22) if os.environ.get("STUB_POST_FAILS") == "1": # EVERY POST FAILS, retries included. `gh` is `curl -sf`, so an HTTP error is exit 22 with # empty stdout. Without this the write helpers' failure arms are unreachable — and one of @@ -1582,6 +1600,30 @@ if "/status" in url: "description": "Review-verdict: MERGEABLE @ a9e3e23 (base: main)"}] print(json.dumps({"state": "success", "total_count": len(rows), "statuses": rows})) sys.exit(0) + if mode == "flaky-combined": + # THE FIRST ATTEMPT ONLY. The read is retried once; without the retry this head reads as + # unreadable and takes the replacement path, which is a real behavioural difference and the + # only thing that distinguishes a retrying read from a one-shot one. + ctr = out / "combined_attempts.txt" + seen = int(ctr.read_text()) if ctr.exists() else 0 + ctr.write_text(str(seen + 1)) + if seen == 0: + sys.exit(22) + print(empty_statuses()) + sys.exit(0) + if mode.startswith("malformed-field:"): + # ONE CONSUMED FIELD AT A TIME. The `.creator` case had a proof and the other three did not, + # which is the per-FIELD gap this repo has a record for: a route written once for the field + # that produced it, and the siblings left to the reader's assumption. + field = mode.split(":", 1)[1] + row = {"id": 5, "context": "review-verdict/h10", "status": "failure", + "creator": {"login": "timothy"}, + "description": "Review-verdict: BLOCKED @ a9e3e23 (base: main)"} + # `.id` is a NUMBER when well-formed, so corrupting it needs a string; the other three + # are strings or an object, so a number corrupts them. + row[field] = "seven" if field == "id" else 7 + print(json.dumps({"state": "failure", "total_count": 1, "statuses": [row]})) + sys.exit(0) if mode.startswith("sentinel-with-id:"): # THE CURRENT SENTINEL, AT A STABLE ID, on both reads. The reconciliation witness has to # identify THIS row, so a fixture needs the combined endpoint to name an id that the seeded @@ -1733,7 +1775,7 @@ def _run_classify( timeline_terminator: str = "null", status_empty_shape: str = "null", mutate: tuple[str, str] | None = None, - post_fails: bool = False, + post_fails: bool | str = False, ): """Execute the workflow's classify `run:` block with a stubbed enumeration script. @@ -1774,7 +1816,7 @@ def _run_classify( env["STUB_HISTORY_CREATOR"] = history_creator env["STUB_HISTORY_EXTRA"] = json.dumps(history_extra) if history_extra else "" env["STUB_UNVERIFIED_DESC"] = UNVERIFIED_DESC - env["STUB_POST_FAILS"] = "1" if post_fails else "" + env["STUB_POST_FAILS"] = post_fails if isinstance(post_fails, str) else ("1" if post_fails else "") env["STUB_MIDRUN_CREATOR"] = midrun_creator env["STUB_PRE_ROW"] = pre_row env["STUB_MIDRUN_ROW"] = midrun_row @@ -6329,6 +6371,326 @@ def test_a_STRING_id_on_the_current_row_takes_the_SCHEMA_FAULT_route(tmp_path): ) +@pytest.mark.parametrize("field", ["status", "creator", "description", "id"]) +def test_EVERY_consumed_field_of_the_existing_row_takes_the_SCHEMA_FAULT_route(tmp_path, field): + """Swept by FIELD, not written once for the field that produced it. + + The `.creator` case is the one a review measured, and a route written for it alone leaves the + other three to the reader's assumption — which is the per-field gap this repo keeps re-learning. + Each of the four is read by a decision: `.creator` and `.description` by the provenance test, + `.status` by both short-circuits, `.id` by the reconciliation witness. + """ + posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode=f"malformed-field:{field}") + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + f"a corrupt .{field} did not take the fault route: {posted}\n{r.stdout[-900:]}" + ) + assert r.returncode != 0, "an unreadable row must still fail the job" + + +@pytest.mark.parametrize( + "field,shipped,mutated", + [ + ("status", '(.status | type) == "null" then "" else $f end', 'true then "" else "" end'), + ( + "description", + '(.description | type) == "null" then "" else $f end', + 'true then "" else "" end', + ), + ], +) +def test_MUTATION_a_malformed_field_read_as_ABSENT_stops_reaching_the_fault_route(tmp_path, field, shipped, mutated): + """The two fields whose type test had no proof of its own. + + `.creator`'s is proved separately (it is the one whose failure greens a rejection outright). For + these two the mutant's damage is quieter and still real: the row reads as stateless or + descriptionless, so the provenance tests see nothing to inherit and the run re-derives a head it + cannot actually read. + """ + posted, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode=f"malformed-field:{field}", + mutate=(f"elif {shipped}", f"elif {mutated}"), + ) + assert posted is None or posted["description"] != UNVERIFIED_DESC, ( + f"the mutant still took the fault route for .{field}, so its own type test is not what sends " + f"it there: {posted}\n{rm.stdout[-900:]}" + ) + + +def test_a_MALFORMED_description_in_the_history_does_not_lose_the_verdict_beside_it(tmp_path): + """The reconciliation's `buried` filter, twin of the post-write one that already had a proof. + + `(.description | type) == "string"` is what keeps a numeric description from hard-erroring + `startswith`. Without it the whole count comes back unusable, the sentinel is carried forward, + and the genuine verdict on the next row — the one reconciliation exists to find — is never + counted, so the head never gets its human-only marker. + """ + seed = [ + _sentinel_row(), + { + "id": 4100, + "context": "review-verdict/h10", + "status": "failure", + "creator": {"login": "timothy"}, + "description": 7, + }, + { + "id": 4200, + "context": "review-verdict/h10", + "status": "failure", + "creator": {"login": "timothy"}, + "description": "Review-verdict: BLOCKED @ a9e3e23 (base: main)", + }, + ] + posted, r = _run_classify( + tmp_path / "fixed", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=UNVERIFIED_DESC, + history_extra=seed, + ) + assert posted is not None and posted["description"] == REPAIR_DESC, ( + f"the verdict beside a malformed row was lost with it: {posted}\n{r.stdout[-900:]}" + ) + + mutant, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=UNVERIFIED_DESC, + history_extra=seed, + mutate=( + '| select(((.description | type) == "string")\n' + ' and (.description | startswith("Review-verdict:")))] | length\') || buried=""', + '| select((.description // "") | startswith("Review-verdict:"))] | length\') || buried=""', + ), + ) + assert mutant is not None and mutant["description"] != REPAIR_DESC, ( + "the mutant still upgraded, so the description type test is not what keeps the verdict " + f"countable: {mutant}\n{rm.stdout[-900:]}" + ) + + +def test_the_combined_read_RETRIES_a_transient_failure(tmp_path): + """A retry that never retries is a silent degradation, and this one is load-bearing. + + It is what keeps the route-5 replacement attached to a PERSISTENT failure: without it a single + blip costs a head whatever verdict it carries, which is the trade the comment there explicitly + says the retry buys down. Against a stub that fails EVERY attempt a retrying read and a one-shot + read are indistinguishable, which is how it shipped unexercised. + """ + posted, r = _run_classify(tmp_path / "fixed", _emitting("docs/a.md"), status_mode="flaky-combined") + assert posted is not None and posted["state"] == "success", ( + f"a transient combined-read failure was not retried: {posted}\n{r.stdout[-900:]}" + ) + + mutant, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="flaky-combined", + mutate=(" for try in 1 2; do\n json=$(gh", " for try in 1; do\n json=$(gh"), + ) + assert mutant is not None and mutant["description"] == UNVERIFIED_DESC, ( + "the mutant did not take the replacement path, so the retry is not what absorbs the blip: " + f"{mutant}\n{rm.stdout[-900:]}" + ) + + +def test_the_sentinel_write_RETRIES_a_transient_POST_failure(tmp_path): + """`repair_status_to`'s second attempt, on the one write whose failure leaves the gate unmarked.""" + posted, r = _run_classify( + tmp_path / "fixed", + _emitting("docs/a.md"), + timeline_mode="unreadable", + post_fails="first", + ) + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + f"a transient POST failure lost the sentinel write: {posted}\n{r.stdout[-900:]}" + ) + + mutant, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + timeline_mode="unreadable", + post_fails="first", + # The SECOND attempt only. The block is byte-identical to the first, so the clause is + # that second copy TOGETHER WITH the `return 1` after it, which is what makes it unique. + mutate=( + " if gh -X POST -H 'Content-Type: application/json' -d \"$body\" \\\n" + ' "$BASE_URL/repos/$REPO/statuses/$SHA" >/dev/null 2>&1; then\n' + " return 0\n" + " fi\n" + " return 1", + " return 1", + ), + ) + assert mutant is None, ( + "the mutant still wrote the sentinel, so the second attempt is not what absorbs the blip: " + f"{mutant}\n{rm.stdout[-900:]}" + ) + + +def test_a_run_whose_OWN_write_is_the_sentinel_does_not_abstain_on_it(tmp_path): + """The mid-run sentinel guard exempts a run that is itself writing a sentinel. + + Without that exemption the run abstains on a row it was about to replace with an equivalent one, + posts nothing, and the head keeps whichever sentinel got there first — which is not wrong in + state but IS the deadlock shape: replacing a sentinel with a sentinel loses nothing, and refusing + to is how a fixed point stops converging. + + The fixture is the case that needs it: the head's sentinel is replaced mid-run (ids 100 -> 200, + byte-identical text), and the seed does NOT name id 100, so reconciliation cannot clear it and + this run's own write is the sentinel too. + """ + seed = [_sentinel_row(4321)] + posted, r = _run_classify( + tmp_path / "fixed", + _emitting("docs/a.md"), + status_mode="sentinel-replaced-mid-run", + history_extra=seed, + ) + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + f"the run abstained on a row its own write would have matched: {posted}\n{r.stdout[-900:]}" + ) + + mutant, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="sentinel-replaced-mid-run", + history_extra=seed, + mutate=( + ' && [ "$desc" != "$REPAIR_DESC" ] && [ "$desc" != "$UNVERIFIED_DESC" ]; then', + " ; then", + ), + ) + assert mutant is None, ( + "the mutant did not abstain, so the sentinel exemptions on that guard are not what lets a " + f"sentinel-writing run through: {mutant}\n{rm.stdout[-900:]}" + ) + + +def test_a_FAILED_repair_write_FAILS_the_job(tmp_path): + """The repair is the write whose failure leaves an unverified status standing. + + `post_fails="after-first"` is the only arrangement that reaches it: with every POST failing the + job dies on its own classification write and never gets here, so a job that ignores the repair's + result and one that acts on it look identical. + """ + posted, r = _run_classify( + tmp_path / "fixed", + "#!/usr/bin/env bash\n" + DOCS_ONLY + "exit 1\n", + history_mode="human-after-post", + post_fails="after-first", + ) + assert r.returncode != 0, f"a repair that never landed reported a clean run:\n{r.stdout[-900:]}" + assert "COULD NOT REPAIR" in (r.stdout + r.stderr), ( + f"the job went red, but not because the repair failed:\n{r.stdout[-900:]}" + ) + + _, rm = _run_classify( + tmp_path / "mutant", + "#!/usr/bin/env bash\n" + DOCS_ONLY + "exit 1\n", + history_mode="human-after-post", + post_fails="after-first", + mutate=(' if ! repair_status_to "$repair_desc"; then', " if false; then"), + ) + assert rm.returncode == 0, ( + "the mutant still failed the job, so the repair's result is not what reddens it: " + f"rc={rm.returncode}\n{rm.stdout[-900:]}" + ) + + +def test_MUTATION_a_repair_that_leaves_state_at_success_re_enters_the_post_POST_check(tmp_path): + """`state=pending` after the repair is what keeps the post-POST check off a repaired head. + + Without it the block still sees `success`, walks the timeline a third time, and — on a retarget + it then observes — replaces the REPAIR sentinel with the weaker reconcilable one. That is the + same ordering inversion the floor beside it exists to prevent, reached by a different route. + """ + posted, r = _run_classify( + tmp_path / "fixed", + _emitting("docs/a.md"), + history_mode="human-after-post", + timeline_mode="moves:0,0,1", + ) + seq = _posted_sequence(tmp_path / "fixed") + assert len(seq) == 2 and seq[-1]["description"] == REPAIR_DESC, ( + f"expected the exemption then the repair, and nothing after it: {seq}\n{r.stdout[-900:]}" + ) + + _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + history_mode="human-after-post", + timeline_mode="moves:0,0,1", + mutate=( + ' echo "Repaired ${CONTEXT} to pending on ${SHA:0:7} (${repair_desc})."\n state=pending', + ' echo "Repaired ${CONTEXT} to pending on ${SHA:0:7} (${repair_desc})."', + ), + ) + mseq = _posted_sequence(tmp_path / "mutant") + assert len(mseq) == 3 and mseq[-1]["description"] == UNVERIFIED_DESC, ( + "the mutant did not re-enter the post-POST check, so the state update is not what keeps it " + f"out and the assertion above proves nothing about it: {mseq}" + ) + + +def test_a_FAILED_post_POST_replacement_FAILS_the_job(tmp_path): + """The post-POST replacement is the write that takes back a green already published.""" + posted, r = _run_classify( + tmp_path / "fixed", + _emitting("docs/a.md"), + timeline_mode="moves:0,0,1", + post_fails="after-first", + ) + assert r.returncode != 0, f"a stale exemption was left standing and the job reported clean:\n{r.stdout[-900:]}" + assert "COULD NOT REPLACE" in (r.stdout + r.stderr), ( + f"the job went red, but not because the replacement failed:\n{r.stdout[-900:]}" + ) + + _, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + timeline_mode="moves:0,0,1", + post_fails="after-first", + mutate=(' if ! repair_status_to "$UNVERIFIED_DESC"; then', " if false; then"), + ) + assert rm.returncode == 0, ( + "the mutant still failed the job, so the replacement's result is not what reddens it: " + f"rc={rm.returncode}\n{rm.stdout[-900:]}" + ) + + +def test_MUTATION_a_post_POST_replacement_that_leaves_state_at_success_stops_asking_for_a_verdict( + tmp_path, +): + """`state=pending` after the replacement is what makes the job ask for a verdict. + + The closing notice — the line that names the command a reviewer runs — is keyed on `$state`. Left + at `success`, the head is pending and nothing on screen says so. + """ + posted, r = _run_classify(tmp_path / "fixed", _emitting("docs/a.md"), timeline_mode="moves:0,0,1") + assert "needs an H10 review verdict" in (r.stdout + r.stderr), ( + f"the head was replaced with the sentinel but the run never asked for a verdict:\n{r.stdout[-900:]}" + ) + + _, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + timeline_mode="moves:0,0,1", + mutate=( + ' echo "Replaced ${CONTEXT} with the unverified-write sentinel on ${SHA:0:7}."\n state=pending', + ' echo "Replaced ${CONTEXT} with the unverified-write sentinel on ${SHA:0:7}."', + ), + ) + assert "needs an H10 review verdict" not in (rm.stdout + rm.stderr), ( + "the mutant still asked for a verdict, so the state update is not what produces the notice" + ) + + # --- Workflow token scope (ersatztv#748) ------------------------------------------------------- # # Both assertions guard a property whose violation is SILENT and, for the first, unrecoverable. -- 2.47.3 From 6fdf48de2db7cab4e15b76539a9ceb8c6036abec Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 30 Aug 2026 03:46:04 +0200 Subject: [PATCH 06/11] docs(849): drop a duplicated clause from the record, and record the sweep's yield MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `mechanics:` field ended with the same sentence twice — the enumeration of the unreachable clauses was appended without removing the tail it replaced. Found by the mutation-sweep agent while reading the record it was checking its own results against. In its place, the number that makes the technique worth its cost: 60 mutants, 40 red, 20 survivors, on a tree that had already been through three per-finding review rounds by two model families. refs #849 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF --- docs/decisions/records/ci/verdict-unverified-write-sentinel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/decisions/records/ci/verdict-unverified-write-sentinel.md b/docs/decisions/records/ci/verdict-unverified-write-sentinel.md index f89b51113..cadec28fc 100644 --- a/docs/decisions/records/ci/verdict-unverified-write-sentinel.md +++ b/docs/decisions/records/ci/verdict-unverified-write-sentinel.md @@ -7,7 +7,7 @@ supersedes: none superseded-by: none rule: 'The `review-verdict/h10` job runs its post-write race check after EVERY status write, not only an exemption `success`, and where no mark exists to run it against, the write ITSELF becomes the sentinel — so every status this job writes is either verified against the history or marked as unverifiable: a GENERIC `pending` masks a rejection landing in its own write window exactly as a `success` does, and because it carries no marker a later run re-derives it into an exemption with the human row now below THAT run''s high-water mark — the damage arrives one event later, not never. Where a write cannot be checked at all the job writes a SECOND sentinel, `UNVERIFIED_DESC`, distinct from the repair sentinel and never interchangeable with it: the repair sentinel ASSERTS that a verdict existed and was buried, which only the arm that actually COUNTED such a row may claim, while every "could not check" arm — an unreadable or over-cap history, an impossible empty history, an unusable count — states only what it established. Both are STICKY (the classification refuses to grant an exemption over either, and re-writes its own description verbatim, so each is a FIXED POINT a later run cannot re-derive into `success`); only the unverified one is RECONCILABLE. Reconciliation is what bounds the stall that got the #742 attempt withdrawn: a later run pages `/statuses/{sha}` IN FULL and, ONLY IF that history contains the sentinel''s own row, either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it UPGRADES to the repair sentinel, clearable only by a human — or finds none, resolving the uncertainty and clearing it so the PR is classified normally. The reconciliation is sound because the two endpoints differ: a verdict this job masked is invisible on the COMBINED endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. It is deliberately BROADER than the inheritance test — no `H10_REVIEWERS` membership, no `(base: …)` match — because over-counting upgrades to a stall a human clears in one command while MISSING one re-exempts a head carrying a buried rejection. ONLY a COMPLETE walk CONTAINING THE CURRENT SENTINEL ROW — matched by the `id` the combined read reported, not merely by a row with the same description — may clear it. Description alone is satisfied by an OLDER identical sentinel, which is exactly what a fixed point produces: with an earlier sentinel and a buried verdict below the current one, a read carrying only the earlier row satisfies the test, the sentinel clears, and the verdict ends up below the fresh mark. Where the server omits `id` there is nothing to match on and the check degrades to the description, which is the pre-existing behaviour rather than a new hole. The witness is not optional decoration: `ex_unverified` means the combined endpoint just returned that row, and `/statuses/{sha}` keeps one row per POST, so a complete history WITHOUT it — an empty one included — contradicts a write that demonstrably happened. `page_statuses` accepts an empty page 1 as complete (correct for a first-run mark), which is what made that shape reachable, and clearing on it exempted a head whose sentinel may have been sitting on a rejection. This is NOT the withdrawn currency witness: that asked whether ANY row sat above the mark, which an unrelated row satisfied, and it made one anomaly permanent — this asks for a SPECIFIC row already known to exist. Failing it costs one run in the transient case; it is NOT bounded in general, because a history past the 20-page cap can never be walked completely, so such a head needs a human verdict and no later run will clear it. When NO high-water mark can be established the write is withheld BEFORE the POST rather than posted and repaired, since the defect is known in advance and publishing a green to take it back opens a window branch protection — and an already-scheduled auto-merge — can see. EVERY re-derivable state is downgraded there, not only `success`: an earlier draft restricted it to the exemption on the grounds that a sticky generic `pending` "withholds nothing, since an unreviewed PR is blocked already", which analysed the wrong PR — the damaging case is one that IS exemptible and got the generic `pending` only from a transient enumeration failure, whose unmarked description the next run re-derives into the exemption with the human row below its own mark. `$REPAIR_DESC` is the one exemption, being the stronger fact and not re-derivable. SEPARATELY, the retarget count is re-taken AFTER the POST on the exemption path, which closes the PERMANENT forged green `ci.verdict-write-retarget-fence` recorded as its residual: a retarget landing after the final pre-write count leaves a stale `success` with the `edited` event already consumed by a successor that short-circuited, so nothing remains to reclassify. Re-counting afterwards means a retarget later than the re-check necessarily queues a successor that STARTS after the stale `success` exists, and a machine-written `success` is re-derived rather than inherited. The RETARGET axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. An untrusted post-POST count repairs too — reaching that point with `success` means both earlier counts were trusted, so a third unreadable read is a fresh failure and "I cannot tell whether the base moved" must not resolve to leaving a green. FINALLY, every path that cannot establish what the head carries REPLACES the unknown state instead of merely declining to write — the combined read (retried once first), all four page-2 completeness refusals, and the fence branch that cannot trust its retarget count while holding a derived `success`. That last one is reached only AFTER the classification declined to inherit the row the head carries, so posting nothing left the declined row current; its message used to say the context "stays absent", which is true only of a head that had none. The two OBSERVED-mutation arms take the same decision for the same reason, scoped to a head that actually carries a declined row: they still refuse to post their CLASSIFICATION, which was computed against a base or head the PR may no longer have, but a row this run declined must not stay authoritative for the whole window until a successor finishes — and for a PR''s FIRST push no successor is queued at all. Every element and every consumed field of the combined response is TYPE-CHECKED before extraction, and a schema failure routes to the same replacement rather than dying: `.statuses` being an array was checked and its ELEMENTS were not, so one scalar made `select(.context == $c)` hard-error and `set -e` took the step down before any path could mark the head. The path-predicate failure replaces too. And the write helper RETURNS a status: its first version ended the failure arm with a successful `echo`, so it reported 0 after both POSTs failed and the fence caller exited 0 as though the head had been marked. Declining protects a real verdict and leaves a FORGED one — an off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving it, and an unreadable read is the one thing that stops it while the job goes red on a status branch protection does not read. Nothing is destroyed: the per-POST history keeps the masked verdict and the next run''s reconciliation upgrades to the repair sentinel, telling the reviewer to re-post rather than silently un-approving them. The page-2 refusals were briefly excluded on the reasoning that the probe fires when NO row for this context was on page 1, so there is no green of any provenance to leave standing — self-contradictory, since the ONLY reason page 2 is read is that the row MAY be beyond page 1. WRITING THE SENTINEL AND FAILING THE JOB ARE SEPARATE DECISIONS: the read refusals were already non-zero exits on `main` and stay red, while the fence branch exited 0 there and still does, because an unreadable timeline is an ordinary hiccup and reddening every one of them is noise this file elsewhere refuses to add. The repair also has a FLOOR — it may never write a description weaker than the one this run decided, or a transient post-write read rewrites a correct `$REPAIR_DESC` carry-forward with the machine-clearable sentinel — and it is skipped entirely when it would rewrite what is already there.' signals: 'exemption success standing over a human failure, generic pending re-derived into an exemption, unverified write sentinel, reconcilable sentinel vs repair sentinel, no high-water mark could be established, post-write verification runs for every write, retarget after the POST leaves a permanent forged green, unreadable combined status read leaves an off-list green, why did my docs-only PR lose its exemption, why does the gate say the write could not be verified · paths: `.gitea/workflows/review-verdict.yml`, `scripts/tests/test_pr_changed_files.py` · issues: #849, #742, #763, #706, #803' -mechanics: '`UNVERIFIED_DESC` ("Status write could not be verified — re-post the verdict") beside `REPAIR_DESC`; `read_existing_verdict()` sets `ex_unverified` from a prefix match, retries the combined read once and on persistent failure calls `repair_status_to "$UNVERIFIED_DESC"` before `exit 1`; the RECONCILE block runs `page_statuses` and counts `Review-verdict:` rows with a non-null creator, clearing `ex_unverified`, upgrading to `ex_repair`, or carrying the sentinel forward on `ph_ok != yes`; the classification chain is `exempt` → `ex_repair` → `ex_unverified` → generic, so the repair sentinel outranks the unverified one; the no-mark downgrade sits AFTER the mark (referencing `$max_id_before` earlier is an unbound variable under `set -u`); the mid-run guard adds `[ "$ex_desc" != "$pre_desc" ]`, which the repair guard does not need because a pre-existing repair sentinel forces its own description while a pre-existing unverified one is deliberately replaced after reconciliation; the post-write gate is `if [ "$max_id_before" -ge 0 ]`, with the no-mark case handled before the POST instead; `replace_unknown_state()` is the shared writer for every path that cannot establish the head''s state and `replace_unknown_and_die()` wraps it for the two read refusals that were already non-zero exits; the reconciliation witness is a count of rows carrying the `id` the combined read reported, falling back to a description match only where the server omits `id`; `pre_id`/`ex_id` extend the mid-run changed-row comparison to row IDENTITY through a shared `row_replaced` flag, since two sentinel POSTs are byte-identical by design — and an id difference counts only when BOTH reads supplied one, because a response that omits `id` beside one that includes it would otherwise report a replacement that did not happen and make the run abstain over a row it had already declined (the combined endpoint carries `id` — measured 2026-08-29 at 1.27.1, head 736649b3, 8 rows, ids 14..30); `--arg own "$desc"` excludes the job''s OWN row from both machine-sentinel selectors, which became necessary the moment the block started running for every write; `.description` is TYPE-TESTED before `startswith`, since `(.description // "")` does not replace a NUMBER and `startswith` then hard-errors, killing the whole count and losing a genuine verdict beside the malformed row; `repair_status_to()` is the shared retry-once writer for all three repair sites; tests `test_a_verdict_racing_a_PENDING_write_is_repaired_to_the_repair_sentinel`, `test_a_raced_PENDING_write_repairs_and_the_repair_SURVIVES_the_next_run`, `test_the_unverified_sentinel_is_a_FIXED_POINT_while_it_cannot_be_reconciled`, `test_the_unverified_sentinel_is_RECONCILED_AWAY_once_the_history_can_be_read`, `test_the_reconciliation_UPGRADES_to_the_repair_sentinel_when_a_verdict_is_BURIED`, `test_a_RETARGET_AFTER_the_POST_replaces_the_exemption`, `test_an_UNREADABLE_retarget_count_AFTER_the_POST_also_replaces_the_exemption`, `test_a_MALFORMED_description_row_does_not_kill_the_post_write_count`, `test_a_transport_failure_on_the_STATUS_READ_REPLACES_the_unknown_state`, each paired with a `test_MUTATION_…` proof that disarms the shipped clause it names through `_run_classify(mutate=…)`, whose count assertion is the binding. Some of those mutants restore text this branch or `origin/main` actually shipped — every clause that HAS a predecessor is mutated back to it — and the rest disarm clauses that have none, because the blocks they gate are new — the section header in that file enumerates which is which, because "restores the exact predecessor" is true of only some of them. SIX CLAUSES ARE DELIBERATELY UNPROVEN and are ENUMERATED rather than counted, because an inventory that undercounts is the same overclaim as one that invents coverage: the path-predicate failure branch (fires only when `grep -c` exits above 1); the empty-`row` refusal (`first // {}` always yields an object for any body that passed the shape gate); page 2''s non-numeric length (`(.statuses // []) | length` is numeric for every body that reaches it, and a non-JSON body is caught one branch earlier by `more_kind`); the `$witness` non-numeric normalisation (`select(((.id | numbers) // -1) == $wid)` cannot error, and a non-numeric `.id` takes the schema-fault route BEFORE the witness runs); the unusable-`buried`-count arm (same argument, plus its own description type test); and the unusable-`raced_unverified`-count arm, whose filter is type-safe for every row a fixture can pose. Each is defence in depth behind a filter that makes its input well-formed for every case a fixture can pose — the same standing exception the post-write unusable-count arm already carries. They were found by a mutation SWEEP over every clause the diff adds, which is the technique that finds this class; a per-finding review does not — the same standing exception the post-write unusable-count arm already carries, and it is defence in depth behind a filter that makes the count numeric for every input a stub can pose.' +mechanics: '`UNVERIFIED_DESC` ("Status write could not be verified — re-post the verdict") beside `REPAIR_DESC`; `read_existing_verdict()` sets `ex_unverified` from a prefix match, retries the combined read once and on persistent failure calls `repair_status_to "$UNVERIFIED_DESC"` before `exit 1`; the RECONCILE block runs `page_statuses` and counts `Review-verdict:` rows with a non-null creator, clearing `ex_unverified`, upgrading to `ex_repair`, or carrying the sentinel forward on `ph_ok != yes`; the classification chain is `exempt` → `ex_repair` → `ex_unverified` → generic, so the repair sentinel outranks the unverified one; the no-mark downgrade sits AFTER the mark (referencing `$max_id_before` earlier is an unbound variable under `set -u`); the mid-run guard adds `[ "$ex_desc" != "$pre_desc" ]`, which the repair guard does not need because a pre-existing repair sentinel forces its own description while a pre-existing unverified one is deliberately replaced after reconciliation; the post-write gate is `if [ "$max_id_before" -ge 0 ]`, with the no-mark case handled before the POST instead; `replace_unknown_state()` is the shared writer for every path that cannot establish the head''s state and `replace_unknown_and_die()` wraps it for the two read refusals that were already non-zero exits; the reconciliation witness is a count of rows carrying the `id` the combined read reported, falling back to a description match only where the server omits `id`; `pre_id`/`ex_id` extend the mid-run changed-row comparison to row IDENTITY through a shared `row_replaced` flag, since two sentinel POSTs are byte-identical by design — and an id difference counts only when BOTH reads supplied one, because a response that omits `id` beside one that includes it would otherwise report a replacement that did not happen and make the run abstain over a row it had already declined (the combined endpoint carries `id` — measured 2026-08-29 at 1.27.1, head 736649b3, 8 rows, ids 14..30); `--arg own "$desc"` excludes the job''s OWN row from both machine-sentinel selectors, which became necessary the moment the block started running for every write; `.description` is TYPE-TESTED before `startswith`, since `(.description // "")` does not replace a NUMBER and `startswith` then hard-errors, killing the whole count and losing a genuine verdict beside the malformed row; `repair_status_to()` is the shared retry-once writer for all three repair sites; tests `test_a_verdict_racing_a_PENDING_write_is_repaired_to_the_repair_sentinel`, `test_a_raced_PENDING_write_repairs_and_the_repair_SURVIVES_the_next_run`, `test_the_unverified_sentinel_is_a_FIXED_POINT_while_it_cannot_be_reconciled`, `test_the_unverified_sentinel_is_RECONCILED_AWAY_once_the_history_can_be_read`, `test_the_reconciliation_UPGRADES_to_the_repair_sentinel_when_a_verdict_is_BURIED`, `test_a_RETARGET_AFTER_the_POST_replaces_the_exemption`, `test_an_UNREADABLE_retarget_count_AFTER_the_POST_also_replaces_the_exemption`, `test_a_MALFORMED_description_row_does_not_kill_the_post_write_count`, `test_a_transport_failure_on_the_STATUS_READ_REPLACES_the_unknown_state`, each paired with a `test_MUTATION_…` proof that disarms the shipped clause it names through `_run_classify(mutate=…)`, whose count assertion is the binding. Some of those mutants restore text this branch or `origin/main` actually shipped — every clause that HAS a predecessor is mutated back to it — and the rest disarm clauses that have none, because the blocks they gate are new — the section header in that file enumerates which is which, because "restores the exact predecessor" is true of only some of them. SIX CLAUSES ARE DELIBERATELY UNPROVEN and are ENUMERATED rather than counted, because an inventory that undercounts is the same overclaim as one that invents coverage: the path-predicate failure branch (fires only when `grep -c` exits above 1); the empty-`row` refusal (`first // {}` always yields an object for any body that passed the shape gate); page 2''s non-numeric length (`(.statuses // []) | length` is numeric for every body that reaches it, and a non-JSON body is caught one branch earlier by `more_kind`); the `$witness` non-numeric normalisation (`select(((.id | numbers) // -1) == $wid)` cannot error, and a non-numeric `.id` takes the schema-fault route BEFORE the witness runs); the unusable-`buried`-count arm (same argument, plus its own description type test); and the unusable-`raced_unverified`-count arm, whose filter is type-safe for every row a fixture can pose. Each is defence in depth behind a filter that makes its input well-formed for every case a fixture can pose — the same standing exception the post-write unusable-count arm already carries. They were found by a mutation SWEEP over every clause the diff adds, which is the technique that finds this class; a per-finding review does not. Measured on this branch: 60 mutants, 40 red, 20 survivors, on a tree that had already been through three per-finding review rounds by two model families.' --- `ci.verdict-write-retarget-fence` fenced the write and verified it afterwards. Five routes survived -- 2.47.3 From 72cca9a28024e4ce2aef125d41e4756b5d66973f Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 30 Aug 2026 04:31:54 +0200 Subject: [PATCH 07/11] =?UTF-8?q?fix(849):=20round=207=20=E2=80=94=20the?= =?UTF-8?q?=20arms=20judged=20the=20wrong=20snapshot,=20and=20two=20commen?= =?UTF-8?q?ts=20that=20invited=20a=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fourth cold review of the tip. No Blockers, no High: it enumerated every POST site and every exit and could not construct an input where this branch writes a `success` that `origin/main` would not. ## The arms judged the wrong snapshot `mark_declined_row_if_any`'s three refusals all read `$pre_*` — the FIRST read — while the POST replaces whatever row is CURRENT. So a reviewer's verdict arriving between the two reads slipped past every refusal written to protect it: the base mismatch clears `ex_attributable` so the mid-run abstain declines, `pre_creator` is empty so the allow-list loop declines, and the arm marks a row nobody evaluated. Executed trace, control and case. Both snapshots are consulted now, and either one vetoes. Recovery was not free, which is why it mattered: the next run's reconciliation counts that `Review-verdict:` row as buried and upgrades to the human-only sentinel — exactly the cost the refusal exists to avoid. The arm also marked this job's OWN ordinary machine `pending`. Every PR past its first run carries one, so "kept off the commonest path in this job" was true only of a head with no status at all. Scoped on the DESCRIPTION rather than on `creator: null`, which would also exclude a machine `success` from another workflow — the row this marking exists for. ## Two comments that invited a bug - One still described the round-4 REGRESSION as the intended behaviour ("a malformed row reads as no creator, hence re-derived"), two lines below the block recording that it was fixed. Adjacent comments giving contradictory accounts of one line, and the stale one licenses reinstating it. - The fault token's justification said "no Gitea status field contains a NUL". The token is SOH (0x01). That is not pedantry: `$'\000…'` is the EMPTY STRING in bash, so an editor correcting the code to match the comment would make every legitimately-absent field compare equal to the token and send every clean head down the fail-closed route — the gate would stall every PR. ## Docs The record quoted a predicate that no longer exists (`[ "$ex_desc" != "$pre_desc" ]`, now `$row_replaced`); `docs/ci-cd.md` stated the reconciliation witness unconditionally when the code degrades to a description match where the server omits `id`; one of the six unproven clauses carried a wrong `because` (the conclusion holds via `(.id | numbers) // -1` over a validated array, not via the schema-fault route, which governs a different endpoint's row); and the record's own counts read as a contradiction cold — 20 surviving MUTANTS collapse onto 6 distinct CLAUSES, several clauses admitting more than one disarming edit. The run-by-run provenance moved to the issue, where `docs.no-session-narrative` says it belongs. Two existing mutation proofs lost their binding to the reworded clauses and failed loudly rather than measuring the unmutated body, which is what that count assertion is for. Rebound. refs #849 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF --- .gitea/workflows/review-verdict.yml | 39 ++++++-- docs/ci-cd.md | 4 +- .../ci/verdict-unverified-write-sentinel.md | 2 +- scripts/tests/test_pr_changed_files.py | 91 ++++++++++++++++++- 4 files changed, 124 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/review-verdict.yml b/.gitea/workflows/review-verdict.yml index 36648c1ac..47b046bba 100644 --- a/.gitea/workflows/review-verdict.yml +++ b/.gitea/workflows/review-verdict.yml @@ -700,14 +700,20 @@ jobs: # the `{}` no-verdict row), and a type the schema does not allow, which is unknown state # and takes the same route an unreadable ELEMENT already takes two lines up. # - # The token cannot collide with a real value: no Gitea status field contains a NUL. + # SOH (0x01), and the byte matters: `$'\000…'` is the EMPTY STRING in bash, so + # "correcting" this to NUL would make every legitimately-absent field compare equal to the + # token and send every clean head down the fail-closed route — the gate would stall every + # PR. A control character is what makes collision impossible; NUL is the one that cannot + # be used. SCHEMA_FAULT=$'\001schema-fault' ex_id=$(printf '%s' "$row" | jq -r --arg f "$SCHEMA_FAULT" 'if (.id | type) == "number" then (.id | tostring) elif (.id | type) == "null" then "" else $f end') ex_state=$(printf '%s' "$row" | jq -r --arg f "$SCHEMA_FAULT" 'if (.status | type) == "string" then .status elif (.status | type) == "null" then "" else $f end') # `.creator.login` HARD-ERRORS on any non-object creator — the same defect #763 fixed in # the post-write filter, still live on this read. `(.creator | type)` short-circuits it, - # so a malformed row reads as "no creator" (unattributable, hence re-derived) instead of - # killing the step after a `success` is already standing. + # so the step is not killed; the wrong TYPE then takes the fault route above rather than + # reading as "no creator", which is what made it re-derivable. (An earlier version of + # this comment described that re-derivation as the intended behaviour — it was the + # regression the block above records.) ex_creator=$(printf '%s' "$row" | jq -r --arg f "$SCHEMA_FAULT" 'if (.creator | type) == "object" then (.creator.login // "") elif (.creator | type) == "null" then "" else $f end') ex_desc=$(printf '%s' "$row" | jq -r --arg f "$SCHEMA_FAULT" 'if (.description | type) == "string" then .description elif (.description | type) == "null" then "" else $f end') for _f in "$ex_id" "$ex_state" "$ex_creator" "$ex_desc"; do @@ -1741,7 +1747,7 @@ jobs: # THE SAME RULE FOR THE UNVERIFIED SENTINEL (ersatztv#849), with one clause the repair # guard above does not need. # - # `[ "$ex_desc" != "$pre_desc" ]` IS LOAD-BEARING, and leaving it out deadlocks the PR. The + # `$row_replaced` IS LOAD-BEARING HERE, and leaving it out deadlocks the PR. The # repair guard gets its "arrived DURING this run" test for free: a repair sentinel present # at the FIRST read forces `desc="$REPAIR_DESC"`, so the guard cannot fire on a # pre-existing one. That is NOT true here — the reconciliation above deliberately CLEARS a @@ -1799,12 +1805,29 @@ jobs: # nothing, and a write there would be noise on the commonest path in this job. mark_declined_row_if_any() { # $1 = the ::notice:: this arm already emitted local rv - if [ -z "$pre_state" ]; then return 0; fi + # DECIDED ON THE ROW THIS POST ACTUALLY REPLACES, which is the one the LAST read saw. + # The refusals below read `$pre_*` — the FIRST read — and the write replaces `$ex_*`, so a + # reviewer's verdict arriving between the two reads was buried by the very refusal + # written to protect it: `ex_attributable` is cleared by a base mismatch so the mid-run + # abstain declines, `pre_creator` is empty so the allow-list loop declines, and the mark + # fires on a row nobody evaluated. Both snapshots are consulted, and either one vetoes. + if [ -z "$pre_state" ] && [ -z "$ex_state" ]; then return 0; fi + # THIS JOB'S OWN ORDINARY OUTPUT IS NOT WORTH MARKING. Every PR past its first run carries + # a machine `pending` with the generic description, so "kept off the commonest path in this + # job" was true only of a head with NO status at all. Replacing it costs a paged history + # walk and a spurious `::error::` on an entirely ordinary event, and withholds nothing — + # a generic `pending` blocks the merge already. Scoped to the description rather than to + # `creator: null`, which would also exclude a machine `success` from another workflow — + # exactly the row this marking exists for. + case "$ex_desc" in + "Awaiting review verdict"*) if [ "$ex_state" = pending ]; then return 0; fi ;; + esac # NEVER WEAKEN THE REPAIR SENTINEL. `replace_unknown_state` writes the machine-clearable # one, and a head carrying `$REPAIR_DESC` has a non-empty `pre_state`, so the bare scope # test fired on it — inverting the ordering this same change added a floor to protect at # the repair site. One mechanism, three writers, and only two had the rule. - if [ "$desc" = "$REPAIR_DESC" ] || [ "$pre_desc" = "$REPAIR_DESC" ]; then return 0; fi + if [ "$desc" = "$REPAIR_DESC" ] || [ "$pre_desc" = "$REPAIR_DESC" ] \ + || [ "$ex_desc" = "$REPAIR_DESC" ]; then return 0; fi # NEVER BURY A ROW AN ALLOW-LISTED REVIEWER WROTE. "Declined" is decided against THIS # event's `$BASE_REF`, so a genuine verdict recorded for another base is declined here and # is still the right answer for the base it names — the successor run for that base @@ -1814,9 +1837,9 @@ jobs: # wrote the row, not whether it governs this diff. An off-list row is exactly what this # marking exists for, and is left to it. for rv in $H10_REVIEWERS; do - if [ "$rv" = "$pre_creator" ]; then return 0; fi + if [ "$rv" = "$pre_creator" ] || [ "$rv" = "$ex_creator" ]; then return 0; fi done - replace_unknown_state "$1 This head already carried a ${CONTEXT} that this run did not inherit, so leaving it alone would keep it authoritative until a successor finishes — and for a PR's FIRST push no successor is queued at all." || return 1 + replace_unknown_state "$1 This head carries a ${CONTEXT} that this run did not inherit, so leaving it alone would keep it authoritative until a successor finishes — and for a PR's FIRST push no successor is queued at all." || return 1 return 0 } count_pr_mutations diff --git a/docs/ci-cd.md b/docs/ci-cd.md index c4c9d8d7b..3735e296a 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -1786,7 +1786,9 @@ failing the job are separate decisions: the read refusals were already non-zero the fence branch exited 0 and still does. **Reconciliation needs a witness.** It may clear the sentinel only over a complete history that -CONTAINS the sentinel's own row. `ex_unverified` means the combined endpoint just returned that row +CONTAINS the sentinel's own row, matched by the `id` the combined read reported — degrading to a +DESCRIPTION match, which identifies a row with the same text rather than that row, only where the +server omits `id`. `ex_unverified` means the combined endpoint just returned that row and `/statuses/{sha}` keeps one row per POST, so a complete-but-empty history contradicts a write that demonstrably happened — and `page_statuses` accepts an empty page 1 as complete, which is what made the shape reachable. The cost lands where the replacement actually cost something: on an diff --git a/docs/decisions/records/ci/verdict-unverified-write-sentinel.md b/docs/decisions/records/ci/verdict-unverified-write-sentinel.md index cadec28fc..b5fed479d 100644 --- a/docs/decisions/records/ci/verdict-unverified-write-sentinel.md +++ b/docs/decisions/records/ci/verdict-unverified-write-sentinel.md @@ -7,7 +7,7 @@ supersedes: none superseded-by: none rule: 'The `review-verdict/h10` job runs its post-write race check after EVERY status write, not only an exemption `success`, and where no mark exists to run it against, the write ITSELF becomes the sentinel — so every status this job writes is either verified against the history or marked as unverifiable: a GENERIC `pending` masks a rejection landing in its own write window exactly as a `success` does, and because it carries no marker a later run re-derives it into an exemption with the human row now below THAT run''s high-water mark — the damage arrives one event later, not never. Where a write cannot be checked at all the job writes a SECOND sentinel, `UNVERIFIED_DESC`, distinct from the repair sentinel and never interchangeable with it: the repair sentinel ASSERTS that a verdict existed and was buried, which only the arm that actually COUNTED such a row may claim, while every "could not check" arm — an unreadable or over-cap history, an impossible empty history, an unusable count — states only what it established. Both are STICKY (the classification refuses to grant an exemption over either, and re-writes its own description verbatim, so each is a FIXED POINT a later run cannot re-derive into `success`); only the unverified one is RECONCILABLE. Reconciliation is what bounds the stall that got the #742 attempt withdrawn: a later run pages `/statuses/{sha}` IN FULL and, ONLY IF that history contains the sentinel''s own row, either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it UPGRADES to the repair sentinel, clearable only by a human — or finds none, resolving the uncertainty and clearing it so the PR is classified normally. The reconciliation is sound because the two endpoints differ: a verdict this job masked is invisible on the COMBINED endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. It is deliberately BROADER than the inheritance test — no `H10_REVIEWERS` membership, no `(base: …)` match — because over-counting upgrades to a stall a human clears in one command while MISSING one re-exempts a head carrying a buried rejection. ONLY a COMPLETE walk CONTAINING THE CURRENT SENTINEL ROW — matched by the `id` the combined read reported, not merely by a row with the same description — may clear it. Description alone is satisfied by an OLDER identical sentinel, which is exactly what a fixed point produces: with an earlier sentinel and a buried verdict below the current one, a read carrying only the earlier row satisfies the test, the sentinel clears, and the verdict ends up below the fresh mark. Where the server omits `id` there is nothing to match on and the check degrades to the description, which is the pre-existing behaviour rather than a new hole. The witness is not optional decoration: `ex_unverified` means the combined endpoint just returned that row, and `/statuses/{sha}` keeps one row per POST, so a complete history WITHOUT it — an empty one included — contradicts a write that demonstrably happened. `page_statuses` accepts an empty page 1 as complete (correct for a first-run mark), which is what made that shape reachable, and clearing on it exempted a head whose sentinel may have been sitting on a rejection. This is NOT the withdrawn currency witness: that asked whether ANY row sat above the mark, which an unrelated row satisfied, and it made one anomaly permanent — this asks for a SPECIFIC row already known to exist. Failing it costs one run in the transient case; it is NOT bounded in general, because a history past the 20-page cap can never be walked completely, so such a head needs a human verdict and no later run will clear it. When NO high-water mark can be established the write is withheld BEFORE the POST rather than posted and repaired, since the defect is known in advance and publishing a green to take it back opens a window branch protection — and an already-scheduled auto-merge — can see. EVERY re-derivable state is downgraded there, not only `success`: an earlier draft restricted it to the exemption on the grounds that a sticky generic `pending` "withholds nothing, since an unreviewed PR is blocked already", which analysed the wrong PR — the damaging case is one that IS exemptible and got the generic `pending` only from a transient enumeration failure, whose unmarked description the next run re-derives into the exemption with the human row below its own mark. `$REPAIR_DESC` is the one exemption, being the stronger fact and not re-derivable. SEPARATELY, the retarget count is re-taken AFTER the POST on the exemption path, which closes the PERMANENT forged green `ci.verdict-write-retarget-fence` recorded as its residual: a retarget landing after the final pre-write count leaves a stale `success` with the `edited` event already consumed by a successor that short-circuited, so nothing remains to reclassify. Re-counting afterwards means a retarget later than the re-check necessarily queues a successor that STARTS after the stale `success` exists, and a machine-written `success` is re-derived rather than inherited. The RETARGET axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. An untrusted post-POST count repairs too — reaching that point with `success` means both earlier counts were trusted, so a third unreadable read is a fresh failure and "I cannot tell whether the base moved" must not resolve to leaving a green. FINALLY, every path that cannot establish what the head carries REPLACES the unknown state instead of merely declining to write — the combined read (retried once first), all four page-2 completeness refusals, and the fence branch that cannot trust its retarget count while holding a derived `success`. That last one is reached only AFTER the classification declined to inherit the row the head carries, so posting nothing left the declined row current; its message used to say the context "stays absent", which is true only of a head that had none. The two OBSERVED-mutation arms take the same decision for the same reason, scoped to a head that actually carries a declined row: they still refuse to post their CLASSIFICATION, which was computed against a base or head the PR may no longer have, but a row this run declined must not stay authoritative for the whole window until a successor finishes — and for a PR''s FIRST push no successor is queued at all. Every element and every consumed field of the combined response is TYPE-CHECKED before extraction, and a schema failure routes to the same replacement rather than dying: `.statuses` being an array was checked and its ELEMENTS were not, so one scalar made `select(.context == $c)` hard-error and `set -e` took the step down before any path could mark the head. The path-predicate failure replaces too. And the write helper RETURNS a status: its first version ended the failure arm with a successful `echo`, so it reported 0 after both POSTs failed and the fence caller exited 0 as though the head had been marked. Declining protects a real verdict and leaves a FORGED one — an off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving it, and an unreadable read is the one thing that stops it while the job goes red on a status branch protection does not read. Nothing is destroyed: the per-POST history keeps the masked verdict and the next run''s reconciliation upgrades to the repair sentinel, telling the reviewer to re-post rather than silently un-approving them. The page-2 refusals were briefly excluded on the reasoning that the probe fires when NO row for this context was on page 1, so there is no green of any provenance to leave standing — self-contradictory, since the ONLY reason page 2 is read is that the row MAY be beyond page 1. WRITING THE SENTINEL AND FAILING THE JOB ARE SEPARATE DECISIONS: the read refusals were already non-zero exits on `main` and stay red, while the fence branch exited 0 there and still does, because an unreadable timeline is an ordinary hiccup and reddening every one of them is noise this file elsewhere refuses to add. The repair also has a FLOOR — it may never write a description weaker than the one this run decided, or a transient post-write read rewrites a correct `$REPAIR_DESC` carry-forward with the machine-clearable sentinel — and it is skipped entirely when it would rewrite what is already there.' signals: 'exemption success standing over a human failure, generic pending re-derived into an exemption, unverified write sentinel, reconcilable sentinel vs repair sentinel, no high-water mark could be established, post-write verification runs for every write, retarget after the POST leaves a permanent forged green, unreadable combined status read leaves an off-list green, why did my docs-only PR lose its exemption, why does the gate say the write could not be verified · paths: `.gitea/workflows/review-verdict.yml`, `scripts/tests/test_pr_changed_files.py` · issues: #849, #742, #763, #706, #803' -mechanics: '`UNVERIFIED_DESC` ("Status write could not be verified — re-post the verdict") beside `REPAIR_DESC`; `read_existing_verdict()` sets `ex_unverified` from a prefix match, retries the combined read once and on persistent failure calls `repair_status_to "$UNVERIFIED_DESC"` before `exit 1`; the RECONCILE block runs `page_statuses` and counts `Review-verdict:` rows with a non-null creator, clearing `ex_unverified`, upgrading to `ex_repair`, or carrying the sentinel forward on `ph_ok != yes`; the classification chain is `exempt` → `ex_repair` → `ex_unverified` → generic, so the repair sentinel outranks the unverified one; the no-mark downgrade sits AFTER the mark (referencing `$max_id_before` earlier is an unbound variable under `set -u`); the mid-run guard adds `[ "$ex_desc" != "$pre_desc" ]`, which the repair guard does not need because a pre-existing repair sentinel forces its own description while a pre-existing unverified one is deliberately replaced after reconciliation; the post-write gate is `if [ "$max_id_before" -ge 0 ]`, with the no-mark case handled before the POST instead; `replace_unknown_state()` is the shared writer for every path that cannot establish the head''s state and `replace_unknown_and_die()` wraps it for the two read refusals that were already non-zero exits; the reconciliation witness is a count of rows carrying the `id` the combined read reported, falling back to a description match only where the server omits `id`; `pre_id`/`ex_id` extend the mid-run changed-row comparison to row IDENTITY through a shared `row_replaced` flag, since two sentinel POSTs are byte-identical by design — and an id difference counts only when BOTH reads supplied one, because a response that omits `id` beside one that includes it would otherwise report a replacement that did not happen and make the run abstain over a row it had already declined (the combined endpoint carries `id` — measured 2026-08-29 at 1.27.1, head 736649b3, 8 rows, ids 14..30); `--arg own "$desc"` excludes the job''s OWN row from both machine-sentinel selectors, which became necessary the moment the block started running for every write; `.description` is TYPE-TESTED before `startswith`, since `(.description // "")` does not replace a NUMBER and `startswith` then hard-errors, killing the whole count and losing a genuine verdict beside the malformed row; `repair_status_to()` is the shared retry-once writer for all three repair sites; tests `test_a_verdict_racing_a_PENDING_write_is_repaired_to_the_repair_sentinel`, `test_a_raced_PENDING_write_repairs_and_the_repair_SURVIVES_the_next_run`, `test_the_unverified_sentinel_is_a_FIXED_POINT_while_it_cannot_be_reconciled`, `test_the_unverified_sentinel_is_RECONCILED_AWAY_once_the_history_can_be_read`, `test_the_reconciliation_UPGRADES_to_the_repair_sentinel_when_a_verdict_is_BURIED`, `test_a_RETARGET_AFTER_the_POST_replaces_the_exemption`, `test_an_UNREADABLE_retarget_count_AFTER_the_POST_also_replaces_the_exemption`, `test_a_MALFORMED_description_row_does_not_kill_the_post_write_count`, `test_a_transport_failure_on_the_STATUS_READ_REPLACES_the_unknown_state`, each paired with a `test_MUTATION_…` proof that disarms the shipped clause it names through `_run_classify(mutate=…)`, whose count assertion is the binding. Some of those mutants restore text this branch or `origin/main` actually shipped — every clause that HAS a predecessor is mutated back to it — and the rest disarm clauses that have none, because the blocks they gate are new — the section header in that file enumerates which is which, because "restores the exact predecessor" is true of only some of them. SIX CLAUSES ARE DELIBERATELY UNPROVEN and are ENUMERATED rather than counted, because an inventory that undercounts is the same overclaim as one that invents coverage: the path-predicate failure branch (fires only when `grep -c` exits above 1); the empty-`row` refusal (`first // {}` always yields an object for any body that passed the shape gate); page 2''s non-numeric length (`(.statuses // []) | length` is numeric for every body that reaches it, and a non-JSON body is caught one branch earlier by `more_kind`); the `$witness` non-numeric normalisation (`select(((.id | numbers) // -1) == $wid)` cannot error, and a non-numeric `.id` takes the schema-fault route BEFORE the witness runs); the unusable-`buried`-count arm (same argument, plus its own description type test); and the unusable-`raced_unverified`-count arm, whose filter is type-safe for every row a fixture can pose. Each is defence in depth behind a filter that makes its input well-formed for every case a fixture can pose — the same standing exception the post-write unusable-count arm already carries. They were found by a mutation SWEEP over every clause the diff adds, which is the technique that finds this class; a per-finding review does not. Measured on this branch: 60 mutants, 40 red, 20 survivors, on a tree that had already been through three per-finding review rounds by two model families.' +mechanics: '`UNVERIFIED_DESC` ("Status write could not be verified — re-post the verdict") beside `REPAIR_DESC`; `read_existing_verdict()` sets `ex_unverified` from a prefix match, retries the combined read once and on persistent failure calls `repair_status_to "$UNVERIFIED_DESC"` before `exit 1`; the RECONCILE block runs `page_statuses` and counts `Review-verdict:` rows with a non-null creator, clearing `ex_unverified`, upgrading to `ex_repair`, or carrying the sentinel forward on `ph_ok != yes`; the classification chain is `exempt` → `ex_repair` → `ex_unverified` → generic, so the repair sentinel outranks the unverified one; the no-mark downgrade sits AFTER the mark (referencing `$max_id_before` earlier is an unbound variable under `set -u`); the mid-run guard is keyed on `$row_replaced`, which the repair guard does not need because a pre-existing repair sentinel forces its own description while a pre-existing unverified one is deliberately replaced after reconciliation; the post-write gate is `if [ "$max_id_before" -ge 0 ]`, with the no-mark case handled before the POST instead; `replace_unknown_state()` is the shared writer for every path that cannot establish the head''s state and `replace_unknown_and_die()` wraps it for the two read refusals that were already non-zero exits; the reconciliation witness is a count of rows carrying the `id` the combined read reported, falling back to a description match only where the server omits `id`; `pre_id`/`ex_id` extend the mid-run changed-row comparison to row IDENTITY through a shared `row_replaced` flag, since two sentinel POSTs are byte-identical by design — and an id difference counts only when BOTH reads supplied one, because a response that omits `id` beside one that includes it would otherwise report a replacement that did not happen and make the run abstain over a row it had already declined (the combined endpoint carries `id` — measured 2026-08-29 at 1.27.1, head 736649b3, 8 rows, ids 14..30); `--arg own "$desc"` excludes the job''s OWN row from both machine-sentinel selectors, which became necessary the moment the block started running for every write; `.description` is TYPE-TESTED before `startswith`, since `(.description // "")` does not replace a NUMBER and `startswith` then hard-errors, killing the whole count and losing a genuine verdict beside the malformed row; `repair_status_to()` is the shared retry-once writer for all three repair sites; tests `test_a_verdict_racing_a_PENDING_write_is_repaired_to_the_repair_sentinel`, `test_a_raced_PENDING_write_repairs_and_the_repair_SURVIVES_the_next_run`, `test_the_unverified_sentinel_is_a_FIXED_POINT_while_it_cannot_be_reconciled`, `test_the_unverified_sentinel_is_RECONCILED_AWAY_once_the_history_can_be_read`, `test_the_reconciliation_UPGRADES_to_the_repair_sentinel_when_a_verdict_is_BURIED`, `test_a_RETARGET_AFTER_the_POST_replaces_the_exemption`, `test_an_UNREADABLE_retarget_count_AFTER_the_POST_also_replaces_the_exemption`, `test_a_MALFORMED_description_row_does_not_kill_the_post_write_count`, `test_a_transport_failure_on_the_STATUS_READ_REPLACES_the_unknown_state`, each paired with a `test_MUTATION_…` proof that disarms the shipped clause it names through `_run_classify(mutate=…)`, whose count assertion is the binding. Some of those mutants restore text this branch or `origin/main` actually shipped — every clause that HAS a predecessor is mutated back to it — and the rest disarm clauses that have none, because the blocks they gate are new — the section header in that file enumerates which is which, because "restores the exact predecessor" is true of only some of them. SIX CLAUSES ARE DELIBERATELY UNPROVEN and are ENUMERATED rather than counted, because an inventory that undercounts is the same overclaim as one that invents coverage: the path-predicate failure branch (fires only when `grep -c` exits above 1); the empty-`row` refusal (`first // {}` always yields an object for any body that passed the shape gate); page 2''s non-numeric length (`(.statuses // []) | length` is numeric for every body that reaches it, and a non-JSON body is caught one branch earlier by `more_kind`); the `$witness` non-numeric normalisation — `select(((.id | numbers) // -1) == $wid)` cannot error and `$ph_rows` is a validated array, which is the operative reason; the schema-fault route is NOT (it governs the COMBINED endpoint''s row, while the `.id` inside that filter belongs to `/statuses/{sha}` rows, which nothing type-checks); the unusable-`buried`-count arm (same argument, plus its own description type test); and the unusable-`raced_unverified`-count arm, whose filter is type-safe for every row a fixture can pose. Each is defence in depth behind a filter that makes its input well-formed for every case a fixture can pose — the same standing exception the post-write unusable-count arm already carries. They were found by a mutation SWEEP over every clause the diff adds — disarm each, run the whole suite per mutant — which is the technique that finds this class; a per-finding review reads what the diff says it does, and a sweep measures what the tests pin. The sweep''s surviving MUTANTS outnumber the clauses, since several clauses admit more than one disarming edit; the six here are the distinct clauses those survivors collapse onto. Provenance and the run-by-run numbers are in ersatztv#849.' --- `ci.verdict-write-retarget-fence` fenced the write and verified it afterwards. Five routes survived diff --git a/scripts/tests/test_pr_changed_files.py b/scripts/tests/test_pr_changed_files.py index 40a54eefd..4a561a686 100644 --- a/scripts/tests/test_pr_changed_files.py +++ b/scripts/tests/test_pr_changed_files.py @@ -5870,7 +5870,10 @@ def test_MUTATION_not_marking_the_declined_row_leaves_it_authoritative(tmp_path) status_mode="existing:success", status_creator="mallory", timeline_mode="moves:0,1", - mutate=('if [ -z "$pre_state" ]; then return 0; fi', "return 0"), + mutate=( + 'if [ -z "$pre_state" ] && [ -z "$ex_state" ]; then return 0; fi', + "return 0", + ), ) seq = _posted_sequence(tmp_path / "mutant") assert seq == [], ( @@ -6232,7 +6235,8 @@ def test_MUTATION_a_bare_scope_test_on_the_arms_buries_both(tmp_path): status_desc=REPAIR_DESC, timeline_mode="moves:0,1", mutate=( - 'if [ "$desc" = "$REPAIR_DESC" ] || [ "$pre_desc" = "$REPAIR_DESC" ]; then return 0; fi', + 'if [ "$desc" = "$REPAIR_DESC" ] || [ "$pre_desc" = "$REPAIR_DESC" ] \\\n' + ' || [ "$ex_desc" = "$REPAIR_DESC" ]; then return 0; fi', ":", ), ) @@ -6691,6 +6695,89 @@ def test_MUTATION_a_post_POST_replacement_that_leaves_state_at_success_stops_ask ) +def test_a_reviewers_verdict_ARRIVING_MID_RUN_is_not_buried_by_an_abstaining_arm(tmp_path): + """The refusals must judge the row the POST replaces, not the one the run started with. + + All three read `$pre_*`, the FIRST read, while the write replaces whatever is current — so a + reviewer's verdict arriving BETWEEN the two reads slipped past every one of them: the base + mismatch clears `ex_attributable` so the mid-run abstain declines, `pre_creator` is empty so the + allow-list loop declines, and the arm marks a row nobody evaluated. The static case was covered; + this is the one that moves. + + Recovery is not free either: the next run's reconciliation counts that `Review-verdict:` row as + buried and upgrades to the human-only sentinel — exactly the cost the refusal exists to avoid. + """ + posted, r = _run_classify( + tmp_path, + _emitting("docs/a.md"), + status_mode="appears-on-read:2", + midrun_row="timothy|failure|Review-verdict: BLOCKED @ a9e3e23 (base: probe/scratch)", + push_mode="moves:0,1", + ) + assert posted is None, ( + f"a reviewer's verdict that landed mid-run was buried by an abstaining arm: {posted}\n{r.stdout[-900:]}" + ) + + +def test_MUTATION_deciding_the_arms_refusals_on_the_FIRST_read_buries_it(tmp_path): + """Restores the round-4 form: the allow-list veto reading only the opening snapshot.""" + posted, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="appears-on-read:2", + midrun_row="timothy|failure|Review-verdict: BLOCKED @ a9e3e23 (base: probe/scratch)", + push_mode="moves:0,1", + mutate=( + 'if [ "$rv" = "$pre_creator" ] || [ "$rv" = "$ex_creator" ]; then return 0; fi', + 'if [ "$rv" = "$pre_creator" ]; then return 0; fi', + ), + ) + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + "the mutant did not bury it, so consulting the CURRENT read is not what protects it and the " + f"test above proves nothing about it: {posted}\n{rm.stdout[-900:]}" + ) + + +def test_an_ORDINARY_machine_pending_is_not_worth_marking(tmp_path): + """Every PR past its first run carries one, so this is the commonest head there is. + + Replacing it costs a paged history walk and a spurious `::error::` on an entirely ordinary event, + and withholds nothing — a generic `pending` blocks the merge already. The scoping comment claimed + the write was kept off the common path, which was true only of a head with NO status at all. + """ + posted, r = _run_classify( + tmp_path, + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc="Awaiting review verdict for a9e3e23", + push_mode="moves:0,1", + ) + assert posted is None, ( + f"this job's own ordinary output was replaced with the sticky sentinel: {posted}\n{r.stdout[-900:]}" + ) + + +def test_positive_control_a_machine_SUCCESS_is_still_marked(tmp_path): + """The scoping is on the DESCRIPTION, not on `creator: null`. + + Excluding every machine-written row would exclude a `success` posted by another workflow — which + is precisely the row this marking exists for, and the reason the exclusion is not written the + obvious way. + """ + posted, r = _run_classify( + tmp_path, + _emitting("docs/a.md"), + status_mode="existing:success", + status_creator=None, + status_desc="Exempt: docs-only change (no code, no protected path)", + push_mode="moves:0,1", + ) + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + f"a machine `success` was left authoritative while the arm abstained: {posted}\n{r.stdout[-900:]}" + ) + + # --- Workflow token scope (ersatztv#748) ------------------------------------------------------- # # Both assertions guard a property whose violation is SILENT and, for the first, unrecoverable. -- 2.47.3 From 691a14d039fde230d9cc767f7b8ad7059997089d Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 30 Aug 2026 05:10:30 +0200 Subject: [PATCH 08/11] =?UTF-8?q?fix(849):=20round=208=20=E2=80=94=20a=20g?= =?UTF-8?q?reen=20`main`=20does=20not=20post,=20and=20two=20round-7=20fixe?= =?UTF-8?q?s=20that=20overshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cross-family review in five rounds (Codex/GPT-5.6, once its quota reset). It found a Blocker four same-family rounds had missed, and REVERSED two of round 7's fixes — which is the more useful result, because both were made in response to a review and both overshot in the direction the finding pointed. ## The Blocker: dropping unreadable elements became "no verdict exists" Round 3 added `select(type == "object")` so a malformed NEIGHBOUR could not kill the step. When it drops EVERY element, `first // {}` yields `{}`, all `ex_*` read empty, and the job concludes no verdict exists — so a docs-only PR walks straight to the exemption. Measured: `{"total_count":1,"statuses":[7]}` posts `Exempt: docs-only change` here and posted NOTHING on `origin/main`, which raised jq error 5 and aborted under `set -e` before any write. An input on which this branch greens a head that `main` fails closed on, and if that scalar is a mangled rendering of the head's human `failure`, the rejection is what gets greened. The asymmetry is now the rule: a malformed row BESIDE one we did read is noise; a malformed row where we found NOTHING is the only evidence there was. The absence conclusion has to be earned over a list with no unreadable elements in it. ## Two round-7 fixes that overshot - **The arms judged both snapshots.** Round 6's review said they judged `$pre_*` while the POST replaces `$ex_*`; I made both veto, which is the mirror defect — an opening row since REPLACED by a machine `success` still vetoed, so the arm left that success gating the head. They judge the current row alone now. The opening snapshot keeps exactly one job: it can make the write STRONGER, never suppress it. - **The "this job's own output" exclusion keyed on the DESCRIPTION.** A description is not provenance. Any workflow with `code: write` can POST a `creator: null` row and any repository writer can POST one with a creator, either wearing this job's text — so masking a human `failure` with a lookalike `pending` bought an abstention, and the successor re-derived it as ordinary machine output with the rejection below its own mark. Removed; the attempt is recorded because it is the tempting one, and there is no issuer field that could make it safe. ## A guard that could not be reached, folded into the one that can The repair veto turned out unreachable: an `$ex_desc` of `$REPAIR_DESC` with a different `$desc` is caught by the mid-run sentinel guard long before an arm runs, and when `$desc` IS `$REPAIR_DESC` the promotion writes the same string. Rather than keep a guard no fixture can reach — or delete it on the strength of a check three hundred lines away — the invariant is enforced where it is local and provable: the mark carries the strongest description any snapshot shows, then declines to write what is already there. `ci.exemption-provenance` still called the post-final-count window a PERMANENT forged green in its `rule:` frontmatter and body; the post-POST re-count made it transient two rounds ago. refs #849 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF --- .gitea/workflows/review-verdict.yml | 88 ++++++++--- .../records/ci/exemption-provenance.md | 8 +- scripts/tests/test_pr_changed_files.py | 144 +++++++++++++----- 3 files changed, 179 insertions(+), 61 deletions(-) diff --git a/.gitea/workflows/review-verdict.yml b/.gitea/workflows/review-verdict.yml index 47b046bba..735d8058f 100644 --- a/.gitea/workflows/review-verdict.yml +++ b/.gitea/workflows/review-verdict.yml @@ -648,6 +648,23 @@ jobs: if [ -z "${row//[[:space:]]/}" ]; then replace_unknown_and_die "The commit statuses for ${SHA:0:7} parsed as an array but could not be read row by row, so any ${CONTEXT} on this head can neither be read nor re-derived." fi + # DROPPING AN UNREADABLE ELEMENT IS SAFE ONLY ONCE THE TARGET ROW HAS BEEN FOUND. + # `select(type == "object")` was added so a malformed NEIGHBOUR could not kill the step — + # but when it drops every element, `first // {}` yields `{}`, every `ex_*` reads empty, + # and the job concludes NO VERDICT EXISTS. A docs-only PR then walks straight to the + # exemption. `origin/main` raised jq error 5 on the scalar and `set -e` aborted before any + # POST, so this is an input on which this branch posts a green that `main` does not — and + # if that scalar is a mangled rendering of the head's human `failure`, the green stands + # over a rejection. + # + # The asymmetry is the rule: a malformed row beside a row we DID read is noise, and a + # malformed row where we found nothing is the only evidence there was. So the absence + # conclusion has to be earned over a list with no unreadable elements in it. + unreadable=$(printf '%s' "$json" | jq -r '[(.statuses // [])[] | select(type != "object" or ((.context | type) != "string"))] | length' 2>/dev/null) || unreadable="" + case "$unreadable" in ''|*[!0-9]*) unreadable=1 ;; esac + if [ "$(printf '%s' "$row" | jq -r '.context // ""')" = "" ] && [ "$unreadable" -gt 0 ]; then + replace_unknown_and_die "No readable ${CONTEXT} row was found among the commit statuses for ${SHA:0:7}, and ${unreadable} element(s) could not be read at all — so 'no verdict exists' rests on rows this job cannot see, which is not a conclusion it may draw." + fi # THE COMPLETENESS PROBE, run only when page 1 shows no verdict — see the note above. An # unreadable or unexpected page 2 is treated as "cannot tell" and refuses, the same # direction as every other unreadable case here: concluding "no verdict exists" is what @@ -1804,30 +1821,42 @@ jobs: # Scoped to `pre_state` being non-empty: on a head that carried nothing, abstaining leaves # nothing, and a write there would be noise on the commonest path in this job. mark_declined_row_if_any() { # $1 = the ::notice:: this arm already emitted - local rv - # DECIDED ON THE ROW THIS POST ACTUALLY REPLACES, which is the one the LAST read saw. - # The refusals below read `$pre_*` — the FIRST read — and the write replaces `$ex_*`, so a - # reviewer's verdict arriving between the two reads was buried by the very refusal - # written to protect it: `ex_attributable` is cleared by a base mismatch so the mid-run - # abstain declines, `pre_creator` is empty so the allow-list loop declines, and the mark - # fires on a row nobody evaluated. Both snapshots are consulted, and either one vetoes. - if [ -z "$pre_state" ] && [ -z "$ex_state" ]; then return 0; fi - # THIS JOB'S OWN ORDINARY OUTPUT IS NOT WORTH MARKING. Every PR past its first run carries - # a machine `pending` with the generic description, so "kept off the commonest path in this - # job" was true only of a head with NO status at all. Replacing it costs a paged history - # walk and a spurious `::error::` on an entirely ordinary event, and withholds nothing — - # a generic `pending` blocks the merge already. Scoped to the description rather than to - # `creator: null`, which would also exclude a machine `success` from another workflow — - # exactly the row this marking exists for. - case "$ex_desc" in - "Awaiting review verdict"*) if [ "$ex_state" = pending ]; then return 0; fi ;; - esac + local rv mark_desc + # DECIDED ON THE ROW THIS POST ACTUALLY REPLACES — `$ex_*`, the LAST read — and on that + # ALONE. Two rounds got this wrong in opposite directions. First the refusals read only + # `$pre_*` while the write replaced `$ex_*`, so a reviewer's verdict arriving between the + # reads was buried by the refusal written to protect it. Then they read BOTH and either + # could veto, which is the mirror defect: an opening row that has since been REPLACED by a + # machine `success` still vetoed, so the arm left that success gating the head. A veto + # earned by a row that is no longer there protects nothing. + # + # `$pre_*` keeps exactly one job, below: it can only make this write STRONGER, never + # suppress it. + if [ -z "$ex_state" ]; then return 0; fi + # NO EXCLUSION FOR "THIS JOB'S OWN ORDINARY OUTPUT", and the attempt is recorded because + # it is the tempting one. A prefix test on the generic description would keep the write + # off the commonest head — but A DESCRIPTION IS NOT PROVENANCE. Any workflow holding + # `code: write` can POST a `creator: null` row, and any repository writer's user + # credential can POST one with a creator; either may choose `pending` and any description + # it likes, including this job's. The exclusion would therefore be spoofable by exactly + # the writers it must not trust: mask a human `failure` with a lookalike `pending`, and + # the arm declines to mark, the successor re-derives it as ordinary machine output, and + # the rejection ends up below that run's high-water mark where nothing can see it. + # There is no issuer field that separates this job's row from another workflow's, so text + # cannot establish ownership. The cost of marking an ordinary head is one paged walk and a + # sentinel the next run reconciles away. # NEVER WEAKEN THE REPAIR SENTINEL. `replace_unknown_state` writes the machine-clearable # one, and a head carrying `$REPAIR_DESC` has a non-empty `pre_state`, so the bare scope # test fired on it — inverting the ordering this same change added a floor to protect at # the repair site. One mechanism, three writers, and only two had the rule. - if [ "$desc" = "$REPAIR_DESC" ] || [ "$pre_desc" = "$REPAIR_DESC" ] \ - || [ "$ex_desc" = "$REPAIR_DESC" ]; then return 0; fi + # NO SEPARATE REPAIR VETO. There was one, and it was unreachable: an `$ex_desc` of + # `$REPAIR_DESC` with a different `$desc` is caught by the mid-run sentinel guard long + # before an arm runs, and when `$desc` IS `$REPAIR_DESC` the promotion below writes the + # same string, so the veto could only ever suppress a no-op. Rather than keep a guard no + # fixture can reach — and rather than delete it on the strength of a check three hundred + # lines away — the invariant it stood for is enforced HERE, where it is local and + # provable: pick the strongest description any snapshot shows, then decline to write what + # is already there. # NEVER BURY A ROW AN ALLOW-LISTED REVIEWER WROTE. "Declined" is decided against THIS # event's `$BASE_REF`, so a genuine verdict recorded for another base is declined here and # is still the right answer for the base it names — the successor run for that base @@ -1837,9 +1866,24 @@ jobs: # wrote the row, not whether it governs this diff. An off-list row is exactly what this # marking exists for, and is left to it. for rv in $H10_REVIEWERS; do - if [ "$rv" = "$pre_creator" ] || [ "$rv" = "$ex_creator" ]; then return 0; fi + if [ "$rv" = "$ex_creator" ]; then return 0; fi done - replace_unknown_state "$1 This head carries a ${CONTEXT} that this run did not inherit, so leaving it alone would keep it authoritative until a successor finishes — and for a PR's FIRST push no successor is queued at all." || return 1 + # THE STRONGEST FACT ANY SNAPSHOT SHOWS. If the opening read, the current read, or this + # run's own decision carried the repair sentinel, the mark carries it too: a human-only + # marker must never be replaced by the machine-clearable one, and this is the only write + # in the helper that may be stronger than the reconcilable sentinel. It can only ever + # withhold an exemption. + mark_desc="$UNVERIFIED_DESC" + if [ "$pre_desc" = "$REPAIR_DESC" ] || [ "$ex_desc" = "$REPAIR_DESC" ] \ + || [ "$desc" = "$REPAIR_DESC" ]; then mark_desc="$REPAIR_DESC"; fi + # A MARK THAT WOULD WRITE WHAT IS ALREADY THERE IS NOT A MARK. This is what the deleted + # veto was really doing on every path that could reach it. + if [ "$mark_desc" = "$ex_desc" ]; then return 0; fi + if ! repair_status_to "$mark_desc"; then + echo "::error::COULD NOT MARK ${CONTEXT} on ${SHA:0:7}. $1 The head carries a ${CONTEXT} that this run did not inherit and it is still authoritative. Post a verdict immediately: scripts/post-review-verdict.sh ${PR} " + return 1 + fi + echo "::error::$1 This head carries a ${CONTEXT} that this run did not inherit, so leaving it alone would keep it authoritative until a successor finishes — and for a PR's FIRST push no successor is queued at all. Marked it with '${mark_desc}'." return 0 } count_pr_mutations diff --git a/docs/decisions/records/ci/exemption-provenance.md b/docs/decisions/records/ci/exemption-provenance.md index 87c0e219c..92c5e64ad 100644 --- a/docs/decisions/records/ci/exemption-provenance.md +++ b/docs/decisions/records/ci/exemption-provenance.md @@ -48,9 +48,11 @@ non-cancelling concurrency group — the fix this record's residual implied and measured doing nothing at all. The paragraph above stands as written; only its last sentence is overtaken, and the sub-round-trip window it describes survives, because Gitea's status API has no compare-and-set. **That is not the only window** — see the correction to -`ci.verdict-write-retarget-fence` (2026-08-27, #849): the fence never re-counts after its final -pre-write read, so a retarget landing between that read and the POST leaves a PERMANENT forged green -rather than a transient one, whenever the successor run consumes the `edited` event and exits first. +`ci.verdict-write-retarget-fence` (2026-08-27, #849): the PRE-WRITE fence does not re-count after its +final read, so a retarget landing between that read and the POST left a PERMANENT forged green +whenever the successor run consumed the `edited` event and exited first. #849 added a re-count AFTER +the POST, so the writing run now withdraws its own stale `success`; what remains is the observable +window between the POST and that repair, which is transient rather than permanent. **Route 2 — a bot ACCOUNT does not attribute the CODE.** `pull_request.user.login` is the PR's immutable *creator*; its head is not. Push application code onto an open Renovate branch and the PR is diff --git a/scripts/tests/test_pr_changed_files.py b/scripts/tests/test_pr_changed_files.py index 4a561a686..563e99c27 100644 --- a/scripts/tests/test_pr_changed_files.py +++ b/scripts/tests/test_pr_changed_files.py @@ -1589,6 +1589,11 @@ if "/status" in url: ] print(json.dumps({"state": "pending", "total_count": len(mid_rows), "statuses": mid_rows})) sys.exit(0) + if mode == "scalar-statuses-only": + # EVERY element unreadable and `total_count` agreeing, so both shape gates pass. This is the + # shape where dropping unreadable elements turns into "no verdict exists". + print(json.dumps({"state": "pending", "total_count": 1, "statuses": [7]})) + sys.exit(0) if mode == "malformed-row-beside-verdict": # A SCALAR IN `.statuses` BESIDE A REAL ROW. `.statuses` is an array and `total_count` agrees, # so the response passes the shape gate; it is the ELEMENTS that cannot be read. An untyped @@ -5870,10 +5875,7 @@ def test_MUTATION_not_marking_the_declined_row_leaves_it_authoritative(tmp_path) status_mode="existing:success", status_creator="mallory", timeline_mode="moves:0,1", - mutate=( - 'if [ -z "$pre_state" ] && [ -z "$ex_state" ]; then return 0; fi', - "return 0", - ), + mutate=('if [ -z "$ex_state" ]; then return 0; fi', "return 0"), ) seq = _posted_sequence(tmp_path / "mutant") assert seq == [], ( @@ -6224,25 +6226,63 @@ def test_an_observed_retarget_does_NOT_bury_an_ALLOWLISTED_reviewers_verdict(tmp ) -def test_MUTATION_a_bare_scope_test_on_the_arms_buries_both(tmp_path): - """Both narrowings, disarmed together — they are one `if`, and the fixture is the repair - sentinel, which is the case with the sharper consequence.""" - posted, rm = _run_classify( - tmp_path / "mutant", - _emitting("docs/a.md"), +def test_an_observed_retarget_does_NOT_downgrade_a_head_carrying_the_repair_sentinel(tmp_path): + """The mark carries the strongest fact any snapshot shows, and declines a no-op write. + + A head carrying `$REPAIR_DESC` is human-clearable only; the mark's default is the reconcilable + sentinel. Promoting on any snapshot that shows the repair fact, then refusing to write what is + already there, is what keeps an abstaining arm from weakening it — and the two halves are one + mechanism, so this pins them together. + """ + fixture = dict( status_mode="existing:pending", status_creator=None, status_desc=REPAIR_DESC, timeline_mode="moves:0,1", + ) + posted, r = _run_classify(tmp_path / "fixed", _emitting("docs/a.md"), **fixture) + assert posted is None, ( + f"an abstaining arm rewrote a head that already carried the repair sentinel: {posted}\n{r.stdout[-900:]}" + ) + + mutant, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + **fixture, mutate=( - 'if [ "$desc" = "$REPAIR_DESC" ] || [ "$pre_desc" = "$REPAIR_DESC" ] \\\n' - ' || [ "$ex_desc" = "$REPAIR_DESC" ]; then return 0; fi', + ' if [ "$pre_desc" = "$REPAIR_DESC" ] || [ "$ex_desc" = "$REPAIR_DESC" ] \\\n' + ' || [ "$desc" = "$REPAIR_DESC" ]; then mark_desc="$REPAIR_DESC"; fi', ":", ), ) + assert mutant is not None and mutant["description"] == UNVERIFIED_DESC, ( + "the mutant did not downgrade, so the promotion is not what protects the repair sentinel " + f"and the assertion above proves nothing about it: {mutant}\n{rm.stdout[-900:]}" + ) + + +def test_a_FOREIGN_status_cannot_spoof_its_way_out_of_being_marked(tmp_path): + """A description is not provenance, so there is no "this job's own output" exclusion. + + The tempting exclusion is a prefix test on the generic `Awaiting review verdict …` text, to keep + the mark off the commonest head. It would be spoofable by exactly the writers it must not trust: + any workflow with `code: write` can POST a `creator: null` row and any repository writer can POST + one with a creator, either choosing that description. Masking a human `failure` with a lookalike + `pending` would then buy an abstention, and the successor would re-derive it as ordinary machine + output with the rejection below its own high-water mark. + + So a row wearing this job's own description is marked like any other. + """ + posted, r = _run_classify( + tmp_path, + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator="mallory", + status_desc="Awaiting review verdict for a9e3e23", + push_mode="moves:0,1", + ) assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( - "the mutant did not bury the repair sentinel, so the narrowing is not what protects it: " - f"{posted}\n{rm.stdout[-900:]}" + f"a foreign row wearing this job's description escaped the mark: {posted}\n{r.stdout[-900:]}" ) @@ -6313,7 +6353,10 @@ def test_MUTATION_swallowing_the_marks_result_reports_a_clean_abstention(tmp_pat status_creator="mallory", timeline_mode="moves:0,1", post_fails=True, - mutate=('no successor is queued at all." || return 1', 'no successor is queued at all." || true'), + mutate=( + 'if ! repair_status_to "$mark_desc"; then', + "if false; then", + ), ) assert rm.returncode == 0, ( "the mutant still failed the job, so the helper's `|| return 1` is not what propagates a " @@ -6728,7 +6771,7 @@ def test_MUTATION_deciding_the_arms_refusals_on_the_FIRST_read_buries_it(tmp_pat midrun_row="timothy|failure|Review-verdict: BLOCKED @ a9e3e23 (base: probe/scratch)", push_mode="moves:0,1", mutate=( - 'if [ "$rv" = "$pre_creator" ] || [ "$rv" = "$ex_creator" ]; then return 0; fi', + 'if [ "$rv" = "$ex_creator" ]; then return 0; fi', 'if [ "$rv" = "$pre_creator" ]; then return 0; fi', ), ) @@ -6738,26 +6781,6 @@ def test_MUTATION_deciding_the_arms_refusals_on_the_FIRST_read_buries_it(tmp_pat ) -def test_an_ORDINARY_machine_pending_is_not_worth_marking(tmp_path): - """Every PR past its first run carries one, so this is the commonest head there is. - - Replacing it costs a paged history walk and a spurious `::error::` on an entirely ordinary event, - and withholds nothing — a generic `pending` blocks the merge already. The scoping comment claimed - the write was kept off the common path, which was true only of a head with NO status at all. - """ - posted, r = _run_classify( - tmp_path, - _emitting("docs/a.md"), - status_mode="existing:pending", - status_creator=None, - status_desc="Awaiting review verdict for a9e3e23", - push_mode="moves:0,1", - ) - assert posted is None, ( - f"this job's own ordinary output was replaced with the sticky sentinel: {posted}\n{r.stdout[-900:]}" - ) - - def test_positive_control_a_machine_SUCCESS_is_still_marked(tmp_path): """The scoping is on the DESCRIPTION, not on `creator: null`. @@ -6778,6 +6801,55 @@ def test_positive_control_a_machine_SUCCESS_is_still_marked(tmp_path): ) +def test_UNREADABLE_status_elements_cannot_license_a_no_verdict_conclusion(tmp_path): + """Dropping a malformed element is safe only once the target row has been FOUND. + + `select(type == "object")` was added so a malformed NEIGHBOUR could not kill the step. When it + drops every element, `first // {}` yields `{}`, all `ex_*` read empty, and the job concludes NO + VERDICT EXISTS — so a docs-only PR walks straight to the exemption. `origin/main` raised jq error + 5 on the scalar and `set -e` aborted before any POST, so this was an input on which the branch + posted a green that `main` did not; if the scalar is a mangled rendering of the head's human + `failure`, that rejection is greened. + + The asymmetry is the rule: a malformed row beside one we DID read is noise; a malformed row where + we found nothing is the only evidence there was. + """ + posted, r = _run_classify(tmp_path / "fixed", _emitting("docs/a.md"), status_mode="scalar-statuses-only") + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + f"an all-unreadable status list was read as 'no verdict exists': {posted}\n{r.stdout[-900:]}" + ) + assert r.returncode != 0, "an unreadable list must still fail the job" + + +def test_MUTATION_concluding_absence_over_unreadable_elements_greens_the_head(tmp_path): + """Disarms the absence guard alone; the type-safe filter above it is untouched.""" + posted, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="scalar-statuses-only", + mutate=( + 'if [ "$(printf \'%s\' "$row" | jq -r \'.context // ""\')" = "" ] && [ "$unreadable" -gt 0 ]; then', + "if false; then", + ), + ) + assert posted is not None and posted["state"] == "success", ( + "the mutant did not green the head, so the absence guard is not what prevents it and the " + f"test above proves nothing about it: {posted}\n{rm.stdout[-900:]}" + ) + + +def test_positive_control_a_malformed_element_BESIDE_a_readable_row_is_still_ignored(tmp_path): + """The guard must not fire once the target row was actually found. + + Otherwise every head carrying one odd neighbour would stall, which is the behaviour the type-safe + filter was added to remove in the first place. + """ + posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode="malformed-row-beside-verdict") + assert posted is not None and posted["state"] == "success", ( + f"a readable row beside a malformed neighbour was not judged: {posted}\n{r.stdout[-900:]}" + ) + + # --- Workflow token scope (ersatztv#748) ------------------------------------------------------- # # Both assertions guard a property whose violation is SILENT and, for the first, unrecoverable. -- 2.47.3 From 879208d5541662cc8e1b140e711c3f0edf3e25f1 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 30 Aug 2026 05:23:07 +0200 Subject: [PATCH 09/11] docs(849): re-apply the CLAUDE.md correction the rebase resolution dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase onto #889 resolved a CLAUDE.md hunk in favour of upstream, which kept #845's new clause and discarded #849's — leaving the file asserting that a rejection landing inside a run's own write window is "a separate and still-open route". Both edits belong: they touch one sentence for different reasons. This message also repairs the TRAILER BLOCK for the whole branch, which CI caught and local runs did not. Every commit here ended: refs #849 Decisions-Edit: yes Co-Authored-By: ... Git parses only the LAST paragraph as trailers, so the blank line put `Decisions-Edit: yes` in the second-to-last one and it was never a trailer at all — `git log --format=%(trailers)` showed only the Co-Authored-By pair. `refs #849` without a colon disqualifies that paragraph independently. `decisions_validate.py` arms its rationale-prose exemption from ANY non-merge commit in the range, so one correctly-formed block repairs all nine. Refs: #849 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1d677a614..1f89cadae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,7 @@ main in) and re-run the local gate whenever the fetch shows movement. Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use `/done ` to run through this automatically. **Merge-consent is derived from state, not asserted (`## Done-when` convention — ersatztv#303 H6 + H10).** Any issue whose PR will merge to `main` should carry a `## Done-when` section in its **issue body** — a checklist of completion criteria (always include an "adversarial review passed" box; add per-issue criteria like tests-green, docs-updated, live-E2E). Two hooks derive merge-consent from it so a premature merge is blocked *by construction*, not by memory: -- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **auto-grants** a merge (emits `permissionDecision: allow`, so **no** redundant mechanical prompt fires) only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked **and** a `Review-verdict:` comment references the PR's *current head sha* (**H10**); **denies** on an unticked box, red CI, or a stale/negative review verdict; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no `Review-verdict:` comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state **is** the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate **asks** (ersatztv#314). **The H10 review-verdict convention**: after an adversarial/Codex review of a PR (or its latest fix commit), run **`scripts/post-review-verdict.sh [note]`** — it posts both the `Review-verdict: … @ ` comment and the sha-bound `review-verdict/h10` commit status, proving the *latest* commit was reviewed rather than a stale earlier diff (ersatztv#242). Do not hand-write the comment: the **status** is the required check branch protection enforces, and a comment alone leaves it absent. **The credential you post with must be an account on `H10_REVIEWERS` in `.gitea/workflows/review-verdict.yml`** (`timothy` today) — since ersatztv#742 the gate inherits an existing `success` only from an allow-listed creator (an existing `failure` is left alone on a weaker attributability test, so an attributable rejection VISIBLE AT THE FIRST READ is not re-derived into a green — a rejection landing later, inside a run's own write window, is a separate and still-open route, ersatztv#849), and since ersatztv#845 the script ENFORCES that coupling rather than assuming it: it reads its own status back and refuses, before writing the verdict comment, unless the recorded `.creator.login` is on that allow-list — so a POSITIVE verdict posted with any other account fails loudly at your terminal instead of being reported as success. The gate still re-derives such a status on the next PR event — that part is unchanged; what the check removes is the tool telling you it worked. **The membership requirement is `success`-only**, mirroring the gate: a `BLOCKED` verdict is honoured from ANY attributable account, so an off-list reviewer can still record a rejection. **The status is still written** — the check runs after the POST, because it measures the creator Gitea recorded rather than what the credential claims — and what is withheld is the verdict COMMENT, which leaves the merge hook at condition (c) with nothing to classify, i.e. an `ask`. So a refused positive verdict leaves a green `review-verdict/h10` standing on that head that the gate itself will not inherit; branch protection binds the context NAME and not its issuer, so do not read that green as consent. The allow-list is derived from the workflow by `scripts/lib/h10-reviewers.sh`; it is never restated. +- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **auto-grants** a merge (emits `permissionDecision: allow`, so **no** redundant mechanical prompt fires) only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked **and** a `Review-verdict:` comment references the PR's *current head sha* (**H10**); **denies** on an unticked box, red CI, or a stale/negative review verdict; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no `Review-verdict:` comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state **is** the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate **asks** (ersatztv#314). **The H10 review-verdict convention**: after an adversarial/Codex review of a PR (or its latest fix commit), run **`scripts/post-review-verdict.sh [note]`** — it posts both the `Review-verdict: … @ ` comment and the sha-bound `review-verdict/h10` commit status, proving the *latest* commit was reviewed rather than a stale earlier diff (ersatztv#242). Do not hand-write the comment: the **status** is the required check branch protection enforces, and a comment alone leaves it absent. **The credential you post with must be an account on `H10_REVIEWERS` in `.gitea/workflows/review-verdict.yml`** (`timothy` today) — since ersatztv#742 the gate inherits an existing `success` only from an allow-listed creator (an existing `failure` is left alone on a weaker attributability test, so an attributable rejection VISIBLE AT THE FIRST READ is not re-derived into a green — a rejection landing later, inside a run's own write window, was a separate route and is NARROWED since ersatztv#849 — every path that cannot establish what the head carries now replaces that unknown state with a sticky sentinel instead of leaving it standing; see `ci.verdict-unverified-write-sentinel` for the residuals it names), and since ersatztv#845 the script ENFORCES that coupling rather than assuming it: it reads its own status back and refuses, before writing the verdict comment, unless the recorded `.creator.login` is on that allow-list — so a POSITIVE verdict posted with any other account fails loudly at your terminal instead of being reported as success. The gate still re-derives such a status on the next PR event — that part is unchanged; what the check removes is the tool telling you it worked. **The membership requirement is `success`-only**, mirroring the gate: a `BLOCKED` verdict is honoured from ANY attributable account, so an off-list reviewer can still record a rejection. **The status is still written** — the check runs after the POST, because it measures the creator Gitea recorded rather than what the credential claims — and what is withheld is the verdict COMMENT, which leaves the merge hook at condition (c) with nothing to classify, i.e. an `ask`. So a refused positive verdict leaves a green `review-verdict/h10` standing on that head that the gate itself will not inherit; branch protection binds the context NAME and not its issuer, so do not read that green as consent. The allow-list is derived from the workflow by `scripts/lib/h10-reviewers.sh`; it is never restated. - **The gate is enforced server-side, per sha (ersatztv#622).** `review-verdict/h10` is a required status check on `main`. Because a commit status belongs to one sha, a commit pushed *after* an auto-merge is scheduled clears it and blocks the merge — closing the hole where `merge_when_checks_succeed` froze consent at scheduling time and Gitea later merged an unreviewed head. Renovate-authored and docs-only PRs are auto-passed by `.gitea/workflows/review-verdict.yml`, **except** when they touch `.claude/`, `.codex/`, `.gitea/`, `.husky/`, `scripts/` or `docker/ci/`. See `docs/ci-cd.md` → Review-verdict gate. - `.husky/pre-push` → `prepush-donewhen.sh` — a fail-open backstop that blocks a direct `git push origin main` whose commits `fix #N` an issue with unticked boxes. **Since ersatztv#743 that push can no longer happen at all** (see below), so this hook is now belt-and-braces for a path the server refuses. -- 2.47.3 From c2c70e50ad3c4f398ab89af091c63c56426118ef Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 30 Aug 2026 06:05:02 +0200 Subject: [PATCH 10/11] =?UTF-8?q?fix(849):=20round=209=20=E2=80=94=20senti?= =?UTF-8?q?nel=20TEXT=20is=20not=20sentinel=20STATE,=20and=20an=20unreadab?= =?UTF-8?q?le=20neighbour=20is=20not=20noise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-8 cross-family review found two more Blockers. Both are cases where a principle this branch had already established was applied in one place and not the adjacent one. ## Sentinel text is not sentinel state `ex_repair` and `ex_unverified` were set from the DESCRIPTION alone. A `success` carrying `$REPAIR_DESC` verbatim — from a machine or an off-list account — therefore read as a sentinel: the mid-run guard exited on it, and the mark's already-there test matched it and returned without POSTing. A green stood on an unreviewed head, on a first-push event with no successor guaranteed. This is the same reasoning that removed the "this job's own output" exclusion one round earlier: a description is not provenance. It is not state either. Both sentinels this job writes are `pending` by construction, so requiring it costs nothing. ## An unreadable neighbour cannot be shown to be unrelated Round 8 refused only when NO readable target row was found, reasoning that a malformed row beside a good one is noise. An element whose `.context` cannot be read cannot be shown to be a DIFFERENT context — so it may be a mangled rendering of this head's own rejection, and the one-row-per-context invariant that would rule that out is exactly what a schema-corrupt response has already broken. The branch's own POSITIVE CONTROL encoded the failing case: a scalar beside an off-list `success`, which this branch re-derived and greened where `origin/main` errored on the scalar and posted nothing. That test is inverted, not adjusted. The cost is a stall on any head carrying a malformed element — the correct direction for a required check, since it withholds a green rather than granting one. ## Two clauses deleted rather than proved Chasing a proof for the mark's repair promotion showed its three clauses were MUTUALLY REDUNDANT: each alone produces the outcome, so no single-clause mutation could show harm. Tracing why revealed that two are unreachable as a sole cause — a repair sentinel at the first read sets `ex_repair`, which forces `desc="$REPAIR_DESC"`, and one arriving mid-run is caught by the sentinel guard unless this run is itself writing that string. So they are redundant rather than unprovable, and they are gone. One clause, one mechanism, one proof. refs #849 Refs: #849 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF --- .gitea/workflows/review-verdict.yml | 57 +++- scripts/tests/test_pr_changed_files.py | 400 +++++-------------------- 2 files changed, 116 insertions(+), 341 deletions(-) diff --git a/.gitea/workflows/review-verdict.yml b/.gitea/workflows/review-verdict.yml index 735d8058f..964e3aaa2 100644 --- a/.gitea/workflows/review-verdict.yml +++ b/.gitea/workflows/review-verdict.yml @@ -662,8 +662,24 @@ jobs: # conclusion has to be earned over a list with no unreadable elements in it. unreadable=$(printf '%s' "$json" | jq -r '[(.statuses // [])[] | select(type != "object" or ((.context | type) != "string"))] | length' 2>/dev/null) || unreadable="" case "$unreadable" in ''|*[!0-9]*) unreadable=1 ;; esac - if [ "$(printf '%s' "$row" | jq -r '.context // ""')" = "" ] && [ "$unreadable" -gt 0 ]; then - replace_unknown_and_die "No readable ${CONTEXT} row was found among the commit statuses for ${SHA:0:7}, and ${unreadable} element(s) could not be read at all — so 'no verdict exists' rests on rows this job cannot see, which is not a conclusion it may draw." + # ANY UNREADABLE ELEMENT, whether or not a target row was also found. Round 8 scoped this + # to "no readable row was found", on the reasoning that a malformed row BESIDE one we did + # read is noise. That reasoning does not hold: an element whose `.context` cannot be read + # cannot be shown to be a different context, so it may be a mangled rendering of this + # head's own rejection — and the one-row-per-context invariant, which is the only thing + # that would rule it out, is exactly what a schema-corrupt response has already broken. + # + # The branch's own positive-control fixture encoded the failing case: a scalar beside an + # off-list `success`. It re-derived the off-list row and POSTed an exemption, where + # `origin/main` errored on the scalar and posted nothing. + # + # A failure of the count itself is treated the same way — `unreadable=1` above — because + # "I could not count what I could not read" is the same answer. + # + # THE COST IS A STALL on any head carrying a malformed element, and that is the correct + # direction for a required check: it withholds a green rather than granting one. + if [ "$unreadable" -gt 0 ]; then + replace_unknown_and_die "${unreadable} element(s) of the commit statuses for ${SHA:0:7} could not be read, and an element whose context is unreadable cannot be shown to be a different context — so neither 'no verdict exists' nor 'the row I found is the only one' is a conclusion this job may draw." fi # THE COMPLETENESS PROBE, run only when page 1 shows no verdict — see the note above. An # unreadable or unexpected page 2 is treated as "cannot tell" and refuses, the same @@ -775,15 +791,23 @@ jobs: ex_attributable=no ex_repair=no ex_unverified=no + # A SENTINEL IS A `pending` STATE WEARING THAT DESCRIPTION, never the text alone. Both + # flags were set from the description by itself, which is the same mistake the removed + # "this job's own output" exclusion was removed FOR — a description is not provenance, + # and it is not state either. A `success` carrying `$REPAIR_DESC` verbatim, from a machine + # or off-list account, therefore read as a sentinel: the mid-run guard exited on it and + # the mark's already-there test matched it, so a green stood on an unreviewed head with + # no successor guaranteed. Both sentinels this job writes are `pending` by construction, + # so requiring it costs nothing and closes the impersonation. case "$ex_desc" in - "$REPAIR_DESC"*) ex_repair=yes ;; + "$REPAIR_DESC"*) if [ "$ex_state" = pending ]; then ex_repair=yes; fi ;; esac # THE TWO SENTINELS ARE TESTED SEPARATELY, never as one "is it a sentinel" flag. They # carry different facts and clear by different means (see UNVERIFIED_DESC above), and a # single flag would let the reconciliation below clear the one that must never be cleared # by anything but a human. case "$ex_desc" in - "$UNVERIFIED_DESC"*) ex_unverified=yes ;; + "$UNVERIFIED_DESC"*) if [ "$ex_state" = pending ]; then ex_unverified=yes; fi ;; esac case "$ex_desc" in "Review-verdict:"*) @@ -1818,8 +1842,8 @@ jobs: # So the arms mark the head instead of merely leaving it. The sentinel is not a # classification — it asserts nothing about the diff, only that this head carries something # unverified — so writing it does not reintroduce what the fence exists to prevent. - # Scoped to `pre_state` being non-empty: on a head that carried nothing, abstaining leaves - # nothing, and a write there would be noise on the commonest path in this job. + # Scoped to `ex_state` being non-empty — the CURRENT row, since that is what the write + # would replace: on a head that carries nothing, abstaining leaves nothing. mark_declined_row_if_any() { # $1 = the ::notice:: this arm already emitted local rv mark_desc # DECIDED ON THE ROW THIS POST ACTUALLY REPLACES — `$ex_*`, the LAST read — and on that @@ -1844,7 +1868,10 @@ jobs: # the rejection ends up below that run's high-water mark where nothing can see it. # There is no issuer field that separates this job's row from another workflow's, so text # cannot establish ownership. The cost of marking an ordinary head is one paged walk and a - # sentinel the next run reconciles away. + # sentinel the next run CLEARS OR UPGRADES — not "reconciles away", which overstates it: + # the reconciliation is deliberately broad, so a history carrying any `Review-verdict:` + # row, off-list or for another base, upgrades to the human-only sentinel instead, and an + # over-cap or unreadable history retains it (see the record's residuals). # NEVER WEAKEN THE REPAIR SENTINEL. `replace_unknown_state` writes the machine-clearable # one, and a head carrying `$REPAIR_DESC` has a non-empty `pre_state`, so the bare scope # test fired on it — inverting the ordering this same change added a floor to protect at @@ -1874,11 +1901,21 @@ jobs: # in the helper that may be stronger than the reconcilable sentinel. It can only ever # withhold an exemption. mark_desc="$UNVERIFIED_DESC" - if [ "$pre_desc" = "$REPAIR_DESC" ] || [ "$ex_desc" = "$REPAIR_DESC" ] \ - || [ "$desc" = "$REPAIR_DESC" ]; then mark_desc="$REPAIR_DESC"; fi + # `$desc` ALONE, because the classification has already collected the others. Round 8 + # promoted on `$pre_desc` and `$ex_desc` as well, and both are unreachable as a sole + # cause: a repair sentinel at the FIRST read sets `ex_repair`, which forces + # `desc="$REPAIR_DESC"`; one arriving mid-run is caught by the sentinel guard unless this + # run is itself writing that string, which is the same condition. Keeping them meant two + # clauses no fixture could ever distinguish — the shape this branch has spent several + # rounds either proving or declaring, and here the honest answer is that they are + # redundant rather than unprovable. `$desc` needs no state test: it is this run''s own + # decision, and it is `pending` whenever it is that string. + if [ "$desc" = "$REPAIR_DESC" ]; then mark_desc="$REPAIR_DESC"; fi # A MARK THAT WOULD WRITE WHAT IS ALREADY THERE IS NOT A MARK. This is what the deleted # veto was really doing on every path that could reach it. - if [ "$mark_desc" = "$ex_desc" ]; then return 0; fi + # THE STATE IS PART OF "ALREADY THERE". Matching text over a `success` does not mean the + # pending write is present — it means something is impersonating it. + if [ "$mark_desc" = "$ex_desc" ] && [ "$ex_state" = pending ]; then return 0; fi if ! repair_status_to "$mark_desc"; then echo "::error::COULD NOT MARK ${CONTEXT} on ${SHA:0:7}. $1 The head carries a ${CONTEXT} that this run did not inherit and it is still authoritative. Post a verdict immediately: scripts/post-review-verdict.sh ${PR} " return 1 diff --git a/scripts/tests/test_pr_changed_files.py b/scripts/tests/test_pr_changed_files.py index 563e99c27..db27b3b99 100644 --- a/scripts/tests/test_pr_changed_files.py +++ b/scripts/tests/test_pr_changed_files.py @@ -5884,345 +5884,102 @@ def test_MUTATION_not_marking_the_declined_row_leaves_it_authoritative(tmp_path) ) -def test_a_MALFORMED_combined_ROW_does_not_kill_the_read_before_anything_can_mark_the_head(tmp_path): - """`.statuses` being an array was checked; its ELEMENTS were not. +def test_a_MALFORMED_element_BESIDE_a_readable_row_still_refuses(tmp_path): + """An element whose `.context` cannot be read cannot be shown to be a DIFFERENT context. - A scalar beside a real row makes an untyped `select(.context == $c)` hard-error, jq exits 5, and - under `set -euo pipefail` the unguarded assignment takes the step down — before any of the paths - that replace an unknown state, and with an off-list `success` still authoritative on the head. - The job goes red, but its own status is not a required check. + Round 8 dropped malformed neighbours once a target row was found, reasoning that a bad row beside + a good one is noise. It is not: the unreadable element may be a mangled rendering of this head's + own rejection, and the one-row-per-context invariant that would rule that out is exactly what a + schema-corrupt response has already broken. - The type-safe filter drops the unreadable element and judges what is left, so the real row is - still found, still declined (`mallory` is off the allow-list), and still re-derived. + This fixture is the one round 8 shipped as a POSITIVE control — a scalar beside an off-list + `success` — and it was the failing case: the branch re-derived the off-list row and POSTed an + exemption where `origin/main` errored on the scalar and posted nothing. The assertion is + therefore inverted, not adjusted. + + The cost is a stall on any head carrying a malformed element. That is the correct direction for a + required check: it withholds a green rather than granting one. """ posted, r = _run_classify(tmp_path / "fixed", _emitting("docs/a.md"), status_mode="malformed-row-beside-verdict") - assert posted is not None, f"a malformed neighbour row stopped the job writing anything: {r.stdout[-900:]}" - assert posted["state"] == "success" and posted["description"].startswith("Exempt:"), ( - f"expected the off-list row to be re-derived into the docs-only exemption; got {posted}" + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + f"a malformed element beside a readable row was ignored and the head judged: {posted}\n{r.stdout[-900:]}" ) + assert r.returncode != 0, "an unreadable element must still fail the job" - mutant, rm = _run_classify( + +def test_MUTATION_ignoring_a_malformed_element_when_a_row_was_found_greens_the_head(tmp_path): + """Round 8's own scoping, restored: refuse only when NO readable row was found.""" + posted, rm = _run_classify( tmp_path / "mutant", _emitting("docs/a.md"), status_mode="malformed-row-beside-verdict", mutate=( - '\'[(.statuses // [])[] | select(type == "object") | select(.context? == $c)] | first // {}\') || row=""', - "'[(.statuses // [])[] | select(.context == $c)] | first // {}')", - ), - ) - assert mutant is None and rm.returncode != 0, ( - "the mutant did not die on the malformed row, so the type test is not what keeps this read " - f"alive: {mutant} rc={rm.returncode}" - ) - - -def test_a_generic_PENDING_with_no_mark_also_becomes_the_sentinel(tmp_path): - """The no-mark downgrade covers every re-derivable write, not only the exemption. - - The damaging PR is one that IS exemptible and got the generic `pending` only from a transient - enumeration failure. Its description carries no marker, the post-write check does not run without - a mark, so a verdict landing in the write window is buried and the NEXT run re-derives that - `pending` into the exemption with the human row below its own mark. - - The fixture is that PR: the enumerator exits 1 (generic `pending`) AND the status history cannot - be read (no mark). - """ - posted, r = _run_classify( - tmp_path, - "#!/usr/bin/env bash\n" + DOCS_ONLY + "exit 1\n", - history_mode="premark-page1-error", - ) - assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( - "a generic `pending` nothing could verify was posted with its re-derivable description " - f"intact: {posted}\n{r.stdout[-900:]}" - ) - - -def test_MUTATION_restoring_the_SUCCESS_only_no_mark_downgrade_leaves_a_re_derivable_pending(tmp_path): - """The exact predecessor from this branch's own previous commit, restored. - - This is the mutation the round-2 suite did not have: the nearest proof mutated the DESCRIPTION - the downgrade writes, not its SCOPE, and its fixture ran a succeeding enumeration — so - `state=success` there and the `success`-only predicate fired identically. Nothing reached the - downgrade with `state=pending`, and the predecessor survived the whole suite. - """ - posted, r = _run_classify( - tmp_path / "mutant", - "#!/usr/bin/env bash\n" + DOCS_ONLY + "exit 1\n", - history_mode="premark-page1-error", - mutate=( - '[ "$max_id_before" -lt 0 ] && [ "$desc" != "$REPAIR_DESC" ]; then', - '[ "$state" = "success" ] && [ "$max_id_before" -lt 0 ]; then', - ), - ) - assert posted is not None and posted["description"] != UNVERIFIED_DESC, ( - "the `success`-only predecessor still wrote the sentinel, so this fixture does not reach the " - f"downgrade with state=pending and the test above proves nothing about its scope: {posted}" - f"\n{r.stdout[-900:]}" - ) - - -def test_MUTATION_dropping_the_no_op_repair_SKIP_re_posts_what_is_already_there(tmp_path): - """The skip, isolated: a repair that would write what this run already wrote is not a repair. - - The fixture reaches it the ordinary way — a carry-forward `$REPAIR_DESC` write whose post-write - walk then fails, so the floor pins `repair_desc` to the description just POSTed. - """ - _run_classify( - tmp_path / "fixed", - _emitting("docs/a.md"), - status_mode="existing:pending", - status_creator=None, - status_desc=REPAIR_DESC, - history_mode="postwrite-page1-error", - ) - assert len(_posted_sequence(tmp_path / "fixed")) == 1, ( - f"the shipped code re-posted: {_posted_sequence(tmp_path / 'fixed')}" - ) - - _run_classify( - tmp_path / "mutant", - _emitting("docs/a.md"), - status_mode="existing:pending", - status_creator=None, - status_desc=REPAIR_DESC, - history_mode="postwrite-page1-error", - mutate=('if [ "$raced" -gt 0 ] && [ "$repair_desc" = "$desc" ]; then', "if false; then"), - ) - mseq = _posted_sequence(tmp_path / "mutant") - assert len(mseq) == 2 and mseq[0] == mseq[1], ( - "the mutant did not duplicate the row, so the skip is not what suppresses it and the " - f"assertion above proves nothing about it: {mseq}" - ) - - -def test_MUTATION_dropping_the_OWN_row_exclusion_reports_a_race_that_did_not_happen(tmp_path): - """`--arg own "$desc"` keeps this job from counting the row it has just written. - - The two exclusions are OUTCOME-redundant with the no-op skip above — drop one and the other - still suppresses the duplicate POST — which is exactly the shape where two guards hide each - other. What the exclusion alone decides is the REPORT, and after the skip learned to keep the - human `::error::` that report is a false alarm: a run whose own carry-forward row is counted - tells a reviewer their verdict was overwritten when nothing raced it. - - So this asserts the log, not the post sequence, and that is the honest discriminator rather than - a weaker one. The head carries `$REPAIR_DESC`, the history is otherwise empty (mark 0), and this - run's own POST lands above the mark. - """ - _, r = _run_classify( - tmp_path / "fixed", - _emitting("docs/a.md"), - status_mode="existing:pending", - status_creator=None, - status_desc=REPAIR_DESC, - ) - # `::error::A human` and not the bare phrase "was overwritten": the classification's own REASON - # string for a carry-forward run also contains that phrase, so matching it would report the - # shipped code as failing on text it is supposed to print. - assert "::error::A human" not in (r.stdout + r.stderr), ( - f"the shipped code reported a race against its own row:\n{r.stdout[-900:]}" - ) - - _, rm = _run_classify( - tmp_path / "mutant", - _emitting("docs/a.md"), - status_mode="existing:pending", - status_creator=None, - status_desc=REPAIR_DESC, - mutate=( - 'or ((.creator == null) and ((.description // "") == $rd)\n' - ' and ((.description // "") != $own))', - 'or ((.creator == null) and ((.description // "") == $rd))', - ), - ) - assert "::error::A human" in (rm.stdout + rm.stderr), ( - "the mutant did not report a false race, so the `$own` exclusion on the repair-sentinel arm " - f"is not what prevents it:\n{rm.stdout[-900:]}" - ) - - -def test_MUTATION_dropping_the_OWN_exclusion_on_the_UNVERIFIED_arm_reports_a_phantom_other_run(tmp_path): - """The twin, on the arm that counts the reconcilable sentinel. - - A run carrying the unverified sentinel forward POSTs it, then its own row sits above the mark. If - the arm does not exclude it, the job reports that ANOTHER run recorded an unverified write on this - head — a second run that does not exist. - """ - _, r = _run_classify( - tmp_path / "fixed", - _emitting("docs/a.md"), - status_mode="existing:pending", - status_creator=None, - status_desc=UNVERIFIED_DESC, - ) - assert "another run recorded an unverified write" not in (r.stdout + r.stderr), ( - f"the shipped code reported a phantom second run:\n{r.stdout[-900:]}" - ) - - _, rm = _run_classify( - tmp_path / "mutant", - _emitting("docs/a.md"), - status_mode="existing:pending", - status_creator=None, - status_desc=UNVERIFIED_DESC, - mutate=( - 'select((.creator == null) and ((.description // "") == $ud)\n' - ' and ((.description // "") != $own))] | length\')', - 'select((.creator == null) and ((.description // "") == $ud))] | length\')', - ), - ) - assert "another run recorded an unverified write" in (rm.stdout + rm.stderr), ( - "the mutant did not report a phantom run, so the `$own` exclusion on the unverified arm is " - f"not what prevents it:\n{rm.stdout[-900:]}" - ) - - -def test_a_FAILED_sentinel_write_on_the_fence_path_FAILS_the_job(tmp_path): - """The write helper reports whether it wrote, and the fence caller acts on it. - - Its first version ended the failure arm with a successful `echo`, so it returned 0 after BOTH - POST attempts failed and the caller's `exit 0` beside it reported an abstention that had not - happened — while whatever the head carried stayed authoritative. - """ - posted, r = _run_classify(tmp_path / "fixed", _emitting("docs/a.md"), timeline_mode="unreadable", post_fails=True) - assert posted is None, "the stub was supposed to reject every POST" - assert r.returncode != 0, ( - "the job reported a clean abstention while the head was left unmarked and its POSTs had all " - f"failed:\n{r.stdout[-900:]}" - ) - assert "COULD NOT WRITE THE UNVERIFIED SENTINEL" in (r.stdout + r.stderr), ( - f"the job went red, but not because the sentinel write failed:\n{r.stdout[-900:]}" - ) - - -def test_MUTATION_ignoring_the_write_result_reports_a_clean_abstention(tmp_path): - """The predecessor: `replace_unknown_state` followed by an unconditional `exit 0`.""" - _, rm = _run_classify( - tmp_path / "mutant", - _emitting("docs/a.md"), - timeline_mode="unreadable", - post_fails=True, - # MUTATES THE CALLER'S REACTION, not the `if` itself: dropping the `if` keyword leaves a - # dangling `then`/`fi` and the step dies on a syntax error, which is a red for the wrong - # reason. Turning the failure exit into a clean one is exactly the predecessor's OUTCOME and - # isolates the decision. - mutate=( - " # THE SENTINEL WRITE FAILED, so nothing marked this head and whatever it carries is still\n" - " # authoritative. Exiting 0 here would report an abstention that did not happen.\n" - " exit 1", - " exit 0", - ), - ) - assert rm.returncode == 0, ( - "the mutant still failed the job, so the caller is not what turns a failed write into a red " - f"run and the test above proves nothing about it: rc={rm.returncode}\n{rm.stdout[-900:]}" - ) - - -def test_an_ID_that_appears_on_only_ONE_read_is_not_a_replacement(tmp_path): - """One response omitting `id` beside one that includes it is not evidence of a mid-run write. - - The row, its state, its creator and its description are all unchanged; only the SERVER's - reporting differs. Treating that as a replacement makes the run abstain — leaving current a row - the classification had already declined to inherit, which is the direction that costs something. - """ - posted, r = _run_classify(tmp_path / "fixed", _emitting("docs/a.md"), status_mode="id-appears-on-second-read") - assert posted is not None, f"an asymmetric id report was read as a mid-run replacement:\n{r.stdout[-900:]}" - assert posted["state"] == "success" and posted["description"].startswith("Exempt:"), ( - f"expected the off-list row to be re-derived into the docs-only exemption; got {posted}" - ) - - -def test_MUTATION_comparing_ids_WITHOUT_requiring_both_makes_the_run_abstain(tmp_path): - """Disarming the presence guards alone, leaving the inequality.""" - mutant, rm = _run_classify( - tmp_path / "mutant", - _emitting("docs/a.md"), - status_mode="id-appears-on-second-read", - mutate=( - 'if [ -n "$ex_id" ] && [ -n "$pre_id" ] && [ "$ex_id" != "$pre_id" ]; then', - 'if [ "$ex_id" != "$pre_id" ]; then', - ), - ) - assert mutant is None, ( - "the mutant did not abstain, so the both-present requirement is not what keeps an asymmetric " - f"id report from reading as a replacement: {mutant}\n{rm.stdout[-900:]}" - ) - - -def test_a_MALFORMED_creator_FIELD_on_the_existing_row_is_unknown_state_not_an_absent_one(tmp_path): - """A wrong TYPE is not an absent value, and reading it as one is a licence to re-derive. - - Round 3 type-tested the four consumed fields and resolved a failure to `""`. For `.creator` that - means "no creator", i.e. unattributable, i.e. re-derive — so a head carrying a human `failure` - with a corrupt creator was greened. `origin/main` died on `.creator.login` BEFORE writing - anything, which is fail-closed, so this was a direction regression rather than a residual. - - The rationale that produced it came from #763, whose site is the POST-WRITE filter: there, dying - leaves a green already published, so dropping the row is the safe direction. Here the alternative - is dying before any write. The deferral rationale did not transfer. - """ - posted, r = _run_classify(tmp_path / "fixed", _emitting("docs/a.md"), status_mode="malformed-creator-field") - assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( - f"a corrupt creator on a human rejection produced {posted}, not the sentinel\n{r.stdout[-900:]}" - ) - assert r.returncode != 0, "an unreadable row must still fail the job" - - -def test_MUTATION_reading_a_malformed_FIELD_as_absent_greens_a_rejection(tmp_path): - """The round-3 form restored: type-test, then fall back to the empty string.""" - posted, rm = _run_classify( - tmp_path / "mutant", - _emitting("docs/a.md"), - status_mode="malformed-creator-field", - mutate=( - 'elif (.creator | type) == "null" then "" else $f end\')', - 'else "" end\')', + 'if [ "$unreadable" -gt 0 ]; then', + 'if [ "$(printf \'%s\' "$row" | jq -r \'.context // ""\')" = "" ] && [ "$unreadable" -gt 0 ]; then', ), ) assert posted is not None and posted["state"] == "success", ( - "the mutant did not green the rejection, so the schema-fault route is not what prevents it " - f"and the test above proves nothing about it: {posted}\n{rm.stdout[-900:]}" + "the mutant did not green the head, so the unconditional refusal is not what prevents it: " + f"{posted}\n{rm.stdout[-900:]}" ) -def test_an_observed_retarget_does_NOT_bury_the_repair_sentinel(tmp_path): - """The arms may mark, but never with a weaker description than the head already carries. +def test_a_SUCCESS_wearing_the_repair_sentinel_text_is_not_treated_as_a_sentinel(tmp_path): + """A sentinel is a `pending` STATE wearing that description, never the text alone. - `$REPAIR_DESC` is human-clearable only; `replace_unknown_state` writes the machine-clearable one. - A head carrying the repair sentinel has a non-empty `pre_state`, so the bare scope test fired on - it and inverted the ordering the repair site's own floor exists to protect — one mechanism, three - writers, and only two had the rule. + Both flags were set from the description by itself — the same mistake the removed "this job's own + output" exclusion was removed FOR. A description is not provenance, and it is not state either. + + Trace without the state test: a machine or off-list `success` carries `$REPAIR_DESC` verbatim, + `ex_repair` is set from the text, the mark's already-there test matches it, and the arm returns + without POSTing — leaving a green on an unreviewed head, on a first-push event with no successor + guaranteed. """ - posted, r = _run_classify( - tmp_path, - _emitting("docs/a.md"), - status_mode="existing:pending", + fixture = dict( + status_mode="existing:success", status_creator=None, status_desc=REPAIR_DESC, - timeline_mode="moves:0,1", + push_mode="moves:0,1", + ) + posted, r = _run_classify(tmp_path / "fixed", _emitting("docs/a.md"), **fixture) + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + "a `success` wearing the sentinel's text was taken for a sentinel, so the mark claimed a " + f"human verdict was lost when nothing established one: {posted}\n{r.stdout[-900:]}" + ) + + mutant, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + **fixture, + mutate=( + '"$REPAIR_DESC"*) if [ "$ex_state" = pending ]; then ex_repair=yes; fi ;;', + '"$REPAIR_DESC"*) ex_repair=yes ;;', + ), + ) + assert mutant is not None and mutant["description"] == REPAIR_DESC, ( + "the mutant did not mistake the impersonating row for a sentinel, so the flag's state test " + f"is not what stops it: {mutant}\n{rm.stdout[-900:]}" ) - assert posted is None, f"the repair sentinel was overwritten by an abstaining run: {posted}" -def test_an_observed_retarget_does_NOT_bury_an_ALLOWLISTED_reviewers_verdict(tmp_path): - """ "Declined" is decided against THIS event's base, so it is not a judgement about the row. +def test_a_SUCCESS_wearing_the_sentinel_text_ARRIVING_MID_RUN_does_not_stop_the_run(tmp_path): + """The same impersonation at the second read, where it exits the job entirely. - A genuine verdict recorded for another base is declined here and is still the right answer for - the base it names — the successor run for that base short-circuits on it. Burying it costs a - manual re-post on an ordinary retarget-onto-the-reviewed-base flow. An OFF-list row is what the - marking exists for and is still marked, which the sibling test above asserts. + The mid-run guard treats a repair sentinel appearing between the reads as another run's repair and + abstains. A `success` wearing that text would therefore stop this run from writing anything, and + the green it was impersonating a sentinel with stays current. """ posted, r = _run_classify( tmp_path, _emitting("docs/a.md"), - status_mode="existing:success", - status_creator="timothy", - status_desc="Review-verdict: MERGEABLE @ a9e3e23 (base: probe/scratch)", - timeline_mode="moves:0,1", + status_mode="appears-on-read:2", + midrun_row=f"|success|{REPAIR_DESC}", ) - assert posted is None, ( - f"a reviewer's verdict for another base was buried by an abstaining run: {posted}\n{r.stdout[-900:]}" + assert posted is not None, ( + f"a `success` wearing the sentinel's text stopped the run from writing:\n{r.stdout[-900:]}" + ) + assert posted["state"] == "success" and posted["description"].startswith("Exempt:"), ( + f"expected the impersonating row to be re-derived, got {posted}" ) @@ -6249,11 +6006,7 @@ def test_an_observed_retarget_does_NOT_downgrade_a_head_carrying_the_repair_sent tmp_path / "mutant", _emitting("docs/a.md"), **fixture, - mutate=( - ' if [ "$pre_desc" = "$REPAIR_DESC" ] || [ "$ex_desc" = "$REPAIR_DESC" ] \\\n' - ' || [ "$desc" = "$REPAIR_DESC" ]; then mark_desc="$REPAIR_DESC"; fi', - ":", - ), + mutate=('if [ "$desc" = "$REPAIR_DESC" ]; then mark_desc="$REPAIR_DESC"; fi', ":"), ) assert mutant is not None and mutant["description"] == UNVERIFIED_DESC, ( "the mutant did not downgrade, so the promotion is not what protects the repair sentinel " @@ -6827,10 +6580,7 @@ def test_MUTATION_concluding_absence_over_unreadable_elements_greens_the_head(tm tmp_path / "mutant", _emitting("docs/a.md"), status_mode="scalar-statuses-only", - mutate=( - 'if [ "$(printf \'%s\' "$row" | jq -r \'.context // ""\')" = "" ] && [ "$unreadable" -gt 0 ]; then', - "if false; then", - ), + mutate=('if [ "$unreadable" -gt 0 ]; then', "if false; then"), ) assert posted is not None and posted["state"] == "success", ( "the mutant did not green the head, so the absence guard is not what prevents it and the " @@ -6838,18 +6588,6 @@ def test_MUTATION_concluding_absence_over_unreadable_elements_greens_the_head(tm ) -def test_positive_control_a_malformed_element_BESIDE_a_readable_row_is_still_ignored(tmp_path): - """The guard must not fire once the target row was actually found. - - Otherwise every head carrying one odd neighbour would stall, which is the behaviour the type-safe - filter was added to remove in the first place. - """ - posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_mode="malformed-row-beside-verdict") - assert posted is not None and posted["state"] == "success", ( - f"a readable row beside a malformed neighbour was not judged: {posted}\n{r.stdout[-900:]}" - ) - - # --- Workflow token scope (ersatztv#748) ------------------------------------------------------- # # Both assertions guard a property whose violation is SILENT and, for the first, unrecoverable. -- 2.47.3 From d4b36ac232367fd4a785e2e22ed560eae3cdb7af Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 30 Aug 2026 07:10:21 +0200 Subject: [PATCH 11/11] fix(849): restore 13 proofs round 9 deleted by accident, and one comment that argued both sides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-9 cross-family review found no Blockers and no Highs, and independently confirmed the clause deletion it was asked to check. What it did find is that round 9 removed FIFTEEN test definitions and added four — a net loss of eleven — where the commit message claimed two. Verified against the parent: 227 definitions before, 216 after. The cause is mechanical and worth naming, because it produces a green suite: the round-9 edits replaced whole source RANGES (`s[:start] + new + s[end:]`) whose end anchor was the next test rather than the end of the one being rewritten, so everything in between went with it. The suite then passed because the tests were GONE, not because the code was right — the exact shape this issue exists to prevent, reproduced in its own test file. Among the casualties were round 4's proofs for two earlier BLOCKERS: - `test_a_generic_PENDING_with_no_mark_also_becomes_the_sentinel` and its mutation, which pin the no-mark downgrade covering every re-derivable write rather than only `success`; - `test_a_MALFORMED_creator_FIELD_...` and its mutation, which pin a wrong-typed field taking the fault route rather than reading as absent and licensing a re-derive. Also lost: both `$own`-exclusion proofs, the no-op-repair skip proof, the id-asymmetry pair (the reviewer's named example), and two write-failure propagation proofs. All 13 unintended deletions are restored verbatim from the parent commit and ALL PASS against round 9's code, so nothing had regressed — the harm was the missing evidence, not the behaviour. The two deletions that WERE intended stay deleted: a test superseded by `..._still_refuses`, and the positive control round 9 inverted. Prose: the comment above the unreadable-element guard still argued a malformed neighbour is safe noise once the target row was found, eleven lines above code that now refuses unconditionally — two adjacent blocks giving opposite accounts of one rule, and the stale one licenses reinstating the Blocker. Refs: #849 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF --- .gitea/workflows/review-verdict.yml | 28 ++- scripts/tests/test_pr_changed_files.py | 316 ++++++++++++++++++++++++- 2 files changed, 330 insertions(+), 14 deletions(-) diff --git a/.gitea/workflows/review-verdict.yml b/.gitea/workflows/review-verdict.yml index 964e3aaa2..ea2ce0408 100644 --- a/.gitea/workflows/review-verdict.yml +++ b/.gitea/workflows/review-verdict.yml @@ -648,18 +648,24 @@ jobs: if [ -z "${row//[[:space:]]/}" ]; then replace_unknown_and_die "The commit statuses for ${SHA:0:7} parsed as an array but could not be read row by row, so any ${CONTEXT} on this head can neither be read nor re-derived." fi - # DROPPING AN UNREADABLE ELEMENT IS SAFE ONLY ONCE THE TARGET ROW HAS BEEN FOUND. - # `select(type == "object")` was added so a malformed NEIGHBOUR could not kill the step — - # but when it drops every element, `first // {}` yields `{}`, every `ex_*` reads empty, - # and the job concludes NO VERDICT EXISTS. A docs-only PR then walks straight to the - # exemption. `origin/main` raised jq error 5 on the scalar and `set -e` aborted before any - # POST, so this is an input on which this branch posts a green that `main` does not — and - # if that scalar is a mangled rendering of the head's human `failure`, the green stands - # over a rejection. + # NO UNREADABLE ELEMENT MAY BE DROPPED SILENTLY, whether or not a target row was found. + # `select(type == "object")` above exists so a malformed element cannot CRASH the read — + # that part is right, and it is all it does. What it must not become is a licence to + # judge the head over the rows that happened to survive it. # - # The asymmetry is the rule: a malformed row beside a row we DID read is noise, and a - # malformed row where we found nothing is the only evidence there was. So the absence - # conclusion has to be earned over a list with no unreadable elements in it. + # Two ways that went wrong, both measured. When the filter drops EVERY element, + # `first // {}` yields `{}`, every `ex_*` reads empty, and the job concludes NO VERDICT + # EXISTS, so a docs-only PR walks to the exemption. And when it drops one element beside + # a row it DID read, that element still cannot be shown to be a different context — so it + # may be a mangled rendering of this head's own rejection, and the one-row-per-context + # invariant that would rule that out is exactly what a schema-corrupt response has + # already broken. `origin/main` raised jq error 5 on such a body and `set -e` aborted + # before any POST; both shapes were inputs on which this job posted a green that `main` + # did not. + # + # So the rule is unconditional: any unreadable element, or a failure to count them, + # refuses. The cost is a stall on a head the registry serves badly — the correct + # direction for a required check, which must withhold a green rather than grant one. unreadable=$(printf '%s' "$json" | jq -r '[(.statuses // [])[] | select(type != "object" or ((.context | type) != "string"))] | length' 2>/dev/null) || unreadable="" case "$unreadable" in ''|*[!0-9]*) unreadable=1 ;; esac # ANY UNREADABLE ELEMENT, whether or not a target row was also found. Round 8 scoped this diff --git a/scripts/tests/test_pr_changed_files.py b/scripts/tests/test_pr_changed_files.py index db27b3b99..a6892352b 100644 --- a/scripts/tests/test_pr_changed_files.py +++ b/scripts/tests/test_pr_changed_files.py @@ -5888,9 +5888,9 @@ def test_a_MALFORMED_element_BESIDE_a_readable_row_still_refuses(tmp_path): """An element whose `.context` cannot be read cannot be shown to be a DIFFERENT context. Round 8 dropped malformed neighbours once a target row was found, reasoning that a bad row beside - a good one is noise. It is not: the unreadable element may be a mangled rendering of this head's - own rejection, and the one-row-per-context invariant that would rule that out is exactly what a - schema-corrupt response has already broken. + a good one is noise. It is not, and the rule is now unconditional: the unreadable element may be a + mangled rendering of this head's own rejection, and the one-row-per-context invariant that would + rule that out is exactly what a schema-corrupt response has already broken. This fixture is the one round 8 shipped as a POSITIVE control — a scalar beside an off-list `success` — and it was the failing case: the branch re-derived the off-list row and POSTed an @@ -6588,6 +6588,316 @@ def test_MUTATION_concluding_absence_over_unreadable_elements_greens_the_head(tm ) +def test_a_FAILED_sentinel_write_on_the_fence_path_FAILS_the_job(tmp_path): + """The write helper reports whether it wrote, and the fence caller acts on it. + + Its first version ended the failure arm with a successful `echo`, so it returned 0 after BOTH + POST attempts failed and the caller's `exit 0` beside it reported an abstention that had not + happened — while whatever the head carried stayed authoritative. + """ + posted, r = _run_classify(tmp_path / "fixed", _emitting("docs/a.md"), timeline_mode="unreadable", post_fails=True) + assert posted is None, "the stub was supposed to reject every POST" + assert r.returncode != 0, ( + "the job reported a clean abstention while the head was left unmarked and its POSTs had all " + f"failed:\n{r.stdout[-900:]}" + ) + assert "COULD NOT WRITE THE UNVERIFIED SENTINEL" in (r.stdout + r.stderr), ( + f"the job went red, but not because the sentinel write failed:\n{r.stdout[-900:]}" + ) + + +def test_a_generic_PENDING_with_no_mark_also_becomes_the_sentinel(tmp_path): + """The no-mark downgrade covers every re-derivable write, not only the exemption. + + The damaging PR is one that IS exemptible and got the generic `pending` only from a transient + enumeration failure. Its description carries no marker, the post-write check does not run without + a mark, so a verdict landing in the write window is buried and the NEXT run re-derives that + `pending` into the exemption with the human row below its own mark. + + The fixture is that PR: the enumerator exits 1 (generic `pending`) AND the status history cannot + be read (no mark). + """ + posted, r = _run_classify( + tmp_path, + "#!/usr/bin/env bash\n" + DOCS_ONLY + "exit 1\n", + history_mode="premark-page1-error", + ) + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + "a generic `pending` nothing could verify was posted with its re-derivable description " + f"intact: {posted}\n{r.stdout[-900:]}" + ) + + +def test_a_MALFORMED_creator_FIELD_on_the_existing_row_is_unknown_state_not_an_absent_one(tmp_path): + """A wrong TYPE is not an absent value, and reading it as one is a licence to re-derive. + + Round 3 type-tested the four consumed fields and resolved a failure to `""`. For `.creator` that + means "no creator", i.e. unattributable, i.e. re-derive — so a head carrying a human `failure` + with a corrupt creator was greened. `origin/main` died on `.creator.login` BEFORE writing + anything, which is fail-closed, so this was a direction regression rather than a residual. + + The rationale that produced it came from #763, whose site is the POST-WRITE filter: there, dying + leaves a green already published, so dropping the row is the safe direction. Here the alternative + is dying before any write. The deferral rationale did not transfer. + """ + posted, r = _run_classify(tmp_path / "fixed", _emitting("docs/a.md"), status_mode="malformed-creator-field") + assert posted is not None and posted["description"] == UNVERIFIED_DESC, ( + f"a corrupt creator on a human rejection produced {posted}, not the sentinel\n{r.stdout[-900:]}" + ) + assert r.returncode != 0, "an unreadable row must still fail the job" + + +def test_an_ID_that_appears_on_only_ONE_read_is_not_a_replacement(tmp_path): + """One response omitting `id` beside one that includes it is not evidence of a mid-run write. + + The row, its state, its creator and its description are all unchanged; only the SERVER's + reporting differs. Treating that as a replacement makes the run abstain — leaving current a row + the classification had already declined to inherit, which is the direction that costs something. + """ + posted, r = _run_classify(tmp_path / "fixed", _emitting("docs/a.md"), status_mode="id-appears-on-second-read") + assert posted is not None, f"an asymmetric id report was read as a mid-run replacement:\n{r.stdout[-900:]}" + assert posted["state"] == "success" and posted["description"].startswith("Exempt:"), ( + f"expected the off-list row to be re-derived into the docs-only exemption; got {posted}" + ) + + +def test_an_observed_retarget_does_NOT_bury_an_ALLOWLISTED_reviewers_verdict(tmp_path): + """ "Declined" is decided against THIS event's base, so it is not a judgement about the row. + + A genuine verdict recorded for another base is declined here and is still the right answer for + the base it names — the successor run for that base short-circuits on it. Burying it costs a + manual re-post on an ordinary retarget-onto-the-reviewed-base flow. An OFF-list row is what the + marking exists for and is still marked, which the sibling test above asserts. + """ + posted, r = _run_classify( + tmp_path, + _emitting("docs/a.md"), + status_mode="existing:success", + status_creator="timothy", + status_desc="Review-verdict: MERGEABLE @ a9e3e23 (base: probe/scratch)", + timeline_mode="moves:0,1", + ) + assert posted is None, ( + f"a reviewer's verdict for another base was buried by an abstaining run: {posted}\n{r.stdout[-900:]}" + ) + + +def test_an_observed_retarget_does_NOT_bury_the_repair_sentinel(tmp_path): + """The arms may mark, but never with a weaker description than the head already carries. + + `$REPAIR_DESC` is human-clearable only; `replace_unknown_state` writes the machine-clearable one. + A head carrying the repair sentinel has a non-empty `pre_state`, so the bare scope test fired on + it and inverted the ordering the repair site's own floor exists to protect — one mechanism, three + writers, and only two had the rule. + """ + posted, r = _run_classify( + tmp_path, + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=REPAIR_DESC, + timeline_mode="moves:0,1", + ) + assert posted is None, f"the repair sentinel was overwritten by an abstaining run: {posted}" + + +def test_MUTATION_comparing_ids_WITHOUT_requiring_both_makes_the_run_abstain(tmp_path): + """Disarming the presence guards alone, leaving the inequality.""" + mutant, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="id-appears-on-second-read", + mutate=( + 'if [ -n "$ex_id" ] && [ -n "$pre_id" ] && [ "$ex_id" != "$pre_id" ]; then', + 'if [ "$ex_id" != "$pre_id" ]; then', + ), + ) + assert mutant is None, ( + "the mutant did not abstain, so the both-present requirement is not what keeps an asymmetric " + f"id report from reading as a replacement: {mutant}\n{rm.stdout[-900:]}" + ) + + +def test_MUTATION_dropping_the_no_op_repair_SKIP_re_posts_what_is_already_there(tmp_path): + """The skip, isolated: a repair that would write what this run already wrote is not a repair. + + The fixture reaches it the ordinary way — a carry-forward `$REPAIR_DESC` write whose post-write + walk then fails, so the floor pins `repair_desc` to the description just POSTed. + """ + _run_classify( + tmp_path / "fixed", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=REPAIR_DESC, + history_mode="postwrite-page1-error", + ) + assert len(_posted_sequence(tmp_path / "fixed")) == 1, ( + f"the shipped code re-posted: {_posted_sequence(tmp_path / 'fixed')}" + ) + + _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=REPAIR_DESC, + history_mode="postwrite-page1-error", + mutate=('if [ "$raced" -gt 0 ] && [ "$repair_desc" = "$desc" ]; then', "if false; then"), + ) + mseq = _posted_sequence(tmp_path / "mutant") + assert len(mseq) == 2 and mseq[0] == mseq[1], ( + "the mutant did not duplicate the row, so the skip is not what suppresses it and the " + f"assertion above proves nothing about it: {mseq}" + ) + + +def test_MUTATION_dropping_the_OWN_exclusion_on_the_UNVERIFIED_arm_reports_a_phantom_other_run(tmp_path): + """The twin, on the arm that counts the reconcilable sentinel. + + A run carrying the unverified sentinel forward POSTs it, then its own row sits above the mark. If + the arm does not exclude it, the job reports that ANOTHER run recorded an unverified write on this + head — a second run that does not exist. + """ + _, r = _run_classify( + tmp_path / "fixed", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=UNVERIFIED_DESC, + ) + assert "another run recorded an unverified write" not in (r.stdout + r.stderr), ( + f"the shipped code reported a phantom second run:\n{r.stdout[-900:]}" + ) + + _, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=UNVERIFIED_DESC, + mutate=( + 'select((.creator == null) and ((.description // "") == $ud)\n' + ' and ((.description // "") != $own))] | length\')', + 'select((.creator == null) and ((.description // "") == $ud))] | length\')', + ), + ) + assert "another run recorded an unverified write" in (rm.stdout + rm.stderr), ( + "the mutant did not report a phantom run, so the `$own` exclusion on the unverified arm is " + f"not what prevents it:\n{rm.stdout[-900:]}" + ) + + +def test_MUTATION_dropping_the_OWN_row_exclusion_reports_a_race_that_did_not_happen(tmp_path): + """`--arg own "$desc"` keeps this job from counting the row it has just written. + + The two exclusions are OUTCOME-redundant with the no-op skip above — drop one and the other + still suppresses the duplicate POST — which is exactly the shape where two guards hide each + other. What the exclusion alone decides is the REPORT, and after the skip learned to keep the + human `::error::` that report is a false alarm: a run whose own carry-forward row is counted + tells a reviewer their verdict was overwritten when nothing raced it. + + So this asserts the log, not the post sequence, and that is the honest discriminator rather than + a weaker one. The head carries `$REPAIR_DESC`, the history is otherwise empty (mark 0), and this + run's own POST lands above the mark. + """ + _, r = _run_classify( + tmp_path / "fixed", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=REPAIR_DESC, + ) + # `::error::A human` and not the bare phrase "was overwritten": the classification's own REASON + # string for a carry-forward run also contains that phrase, so matching it would report the + # shipped code as failing on text it is supposed to print. + assert "::error::A human" not in (r.stdout + r.stderr), ( + f"the shipped code reported a race against its own row:\n{r.stdout[-900:]}" + ) + + _, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="existing:pending", + status_creator=None, + status_desc=REPAIR_DESC, + mutate=( + 'or ((.creator == null) and ((.description // "") == $rd)\n' + ' and ((.description // "") != $own))', + 'or ((.creator == null) and ((.description // "") == $rd))', + ), + ) + assert "::error::A human" in (rm.stdout + rm.stderr), ( + "the mutant did not report a false race, so the `$own` exclusion on the repair-sentinel arm " + f"is not what prevents it:\n{rm.stdout[-900:]}" + ) + + +def test_MUTATION_ignoring_the_write_result_reports_a_clean_abstention(tmp_path): + """The predecessor: `replace_unknown_state` followed by an unconditional `exit 0`.""" + _, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + timeline_mode="unreadable", + post_fails=True, + # MUTATES THE CALLER'S REACTION, not the `if` itself: dropping the `if` keyword leaves a + # dangling `then`/`fi` and the step dies on a syntax error, which is a red for the wrong + # reason. Turning the failure exit into a clean one is exactly the predecessor's OUTCOME and + # isolates the decision. + mutate=( + " # THE SENTINEL WRITE FAILED, so nothing marked this head and whatever it carries is still\n" + " # authoritative. Exiting 0 here would report an abstention that did not happen.\n" + " exit 1", + " exit 0", + ), + ) + assert rm.returncode == 0, ( + "the mutant still failed the job, so the caller is not what turns a failed write into a red " + f"run and the test above proves nothing about it: rc={rm.returncode}\n{rm.stdout[-900:]}" + ) + + +def test_MUTATION_reading_a_malformed_FIELD_as_absent_greens_a_rejection(tmp_path): + """The round-3 form restored: type-test, then fall back to the empty string.""" + posted, rm = _run_classify( + tmp_path / "mutant", + _emitting("docs/a.md"), + status_mode="malformed-creator-field", + mutate=( + 'elif (.creator | type) == "null" then "" else $f end\')', + 'else "" end\')', + ), + ) + assert posted is not None and posted["state"] == "success", ( + "the mutant did not green the rejection, so the schema-fault route is not what prevents it " + f"and the test above proves nothing about it: {posted}\n{rm.stdout[-900:]}" + ) + + +def test_MUTATION_restoring_the_SUCCESS_only_no_mark_downgrade_leaves_a_re_derivable_pending(tmp_path): + """The exact predecessor from this branch's own previous commit, restored. + + This is the mutation the round-2 suite did not have: the nearest proof mutated the DESCRIPTION + the downgrade writes, not its SCOPE, and its fixture ran a succeeding enumeration — so + `state=success` there and the `success`-only predicate fired identically. Nothing reached the + downgrade with `state=pending`, and the predecessor survived the whole suite. + """ + posted, r = _run_classify( + tmp_path / "mutant", + "#!/usr/bin/env bash\n" + DOCS_ONLY + "exit 1\n", + history_mode="premark-page1-error", + mutate=( + '[ "$max_id_before" -lt 0 ] && [ "$desc" != "$REPAIR_DESC" ]; then', + '[ "$state" = "success" ] && [ "$max_id_before" -lt 0 ]; then', + ), + ) + assert posted is not None and posted["description"] != UNVERIFIED_DESC, ( + "the `success`-only predecessor still wrote the sentinel, so this fixture does not reach the " + f"downgrade with state=pending and the test above proves nothing about its scope: {posted}" + f"\n{r.stdout[-900:]}" + ) + + # --- Workflow token scope (ersatztv#748) ------------------------------------------------------- # # Both assertions guard a property whose violation is SILENT and, for the first, unrecoverable. -- 2.47.3