Finishes the 1.25.4-dated CI claim sweep #747 deliberately left incomplete (#869), and answers #893 from the Gitea v1.27.1 source instead of inferring it from a header. Docs and comments only - zero non-comment changes in scripts/ and .gitea/. Population derived with `git ls-files`, not from the issue's item list: 17 files, 43 occurrences of `1.25.4`, against the 4 items #869 named. Re-established on 1.27.1: the `creator`-attribution claim the H10 allow-list rests on (4 merged heads, both endpoints); the scope enum (no `status` scope); the `reqRepoWriter(unit.TypeCode)` gate; the `write:package` 403 (live probe with a read control 200 and a write control 201, throwaway repo, artifacts deleted); the absence of any REST cancel route (from source, which a 404 alone cannot establish); and `pull_request`/`pull_request_target` definition resolution. #893: `/statuses/{sha}` does NOT drop rows after pagination. `getCommitStatuses` appends unconditionally and its only filter is a SQL WHERE in the same query as the LIMIT/OFFSET, so an empty page really is the end, `page_statuses` terminating on its first empty page is safe, and the asymmetry with `count_pr_mutations` is correct - recorded with its reason and a date so it is not tidied away. Corrected rather than re-dated: the `--depth=1` no-merge-base claim was filed against the wrong axis (a git property, re-probed on git 2.55.0), and `enable_bypass_allowlist` postdating 1.25.4 had an issue body as its only provenance. Five cold review rounds plus a cross-family Codex pass. They caught a wrong MECHANISM for `creator: null` (it is `CreatorID == -2`, not `== 0`), an evidence count that straddled the upgrade, and a reason for not re-probing MCP `cancel_run` that was invented - all fixed, final verdict CLEAN. fixes #869 fixes #893 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Whe75djeAEuZpdNk6KU7No Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
34 KiB
key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
ci.gate-trigger-base-resolved stopped a PR supplying the gate's own definition. This closes the
layer below: the exemption path still decided from mutable or unattributed PR state, and a
machine-written success was never revalidated. Three routes, one root cause, one fix.
Route 1 was reproduced, not theorised (probe PR #703). A head H and scratch base S chosen so
H vs S is docs-only; opened H → main so the trusted base definition ran; retargeted to S
mid-flight. The job enumerated against the moved base, read docs-only, and posted
review-verdict/h10=success — "Exempt: docs-only change". Retargeted back to main: nothing
reclassified (created_at == updated_at), leaving a PR into main whose diff carried a C# file
behind a green required check. Closed unmerged, branches deleted, no forged h10 left anywhere.
Why a base BINDING and not a pinned diff. Diffing two immutable shas would close it outright;
Gitea cannot serve that: compare/{base}...{head} returns an object whose only keys are commits
and total_commits, no files — measured on 1.25.4 and re-confirmed on 1.27.1, 2026-08-28 (#747),
probed on b16ec15d6c1e...9b4844c1a8a9, 5 commits. Separately, a --depth=1 fetch of the two shas has
no merge base, so three-dot is impossible and two-dot over-reports everything main gained since the
branch point. Re-probed 2026-09-02 (#869), and the re-probe corrected how it is FILED: this is a GIT
property, not a Gitea one, so it was never 1.25.4-dated in the first place. Carrying a Gitea version
stamp on it invited a re-run against the wrong axis — a Gitea upgrade cannot change it, and a git
upgrade can. Measured on git 2.55.0 in a disposable local repo: after `git fetch --depth=1 origin
An earlier draft claimed edited made that residual non-durable. It does not, and cross-family
review was right to call it a Blocker. edited gives DETECTION, not atomicity or ordering: runs are
not serialized, so the stale run can post success AFTER the reclassifying run posts pending, and an
already-scheduled merge can fire in the green window between them. The main → scratch → main ABA
transition is therefore NARROWED and observable, not closed. Tracked as an explicit residual rather
than described as fixed. edited and re-derivation remain one fix — edited alone re-runs and exits
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 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
look unchanged. Measurement is what redirected it: pull_request_target runs were confirmed to
overlap live (probe PR #722, the older run finishing 20s after the newer one started), and a
non-cancelling concurrency group — the fix this record's residual implied and #706 proposed — was
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 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
still "authored by renovate", touches no protected path, and was exempted. Checking the pusher fixes
nothing — a git author is self-asserted text. So the exemption is constrained by what a bump can
legitimately be: across all 11 Renovate PRs this repo has had, the paths touched were
Directory.Packages.props (10) and .config/dotnet-tools.json (1) — and ONLY those. An earlier draft
also exempted web/package.json/web/package-lock.json "so a first SPA bump cannot deadlock"; review
called that a Blocker and was right. renovate.json enables only nuget/github-actions/dockerfile, so
npm is unmanaged here and the entry bought nothing, while package.json scripts are EXECUTED by CI
(npm ci, npm run build) — widening an exemption onto a code-execution path for no benefit. *.csproj is excluded — under Central Package Management
versions live in Directory.Packages.props, so a Renovate .csproj edit is anomalous by
construction. Cost stated: such a PR is not blocked, it needs a real verdict. The two exemptions are
evaluated as INDEPENDENT predicates: written as an elif chain, a Renovate PR touching only docs/
entered the bot branch, failed the manifest test, and never reached the docs-only branch.
A counterfactual, not an incident. Renovate PR #20 touched a .csproj and two C# files but has no
h10 status: it merged 2026-06-27, the gate landed 2026-07-25. The point is what identity-only would
have done. An earlier draft claimed it HAD been exempted — wrong, and the correction is kept because
"was silently exempted" and "would have been" are different claims.
Route 3 — provenance, and the direction of the test. The short-circuit exited on any success, so
an exemption this job wrote was indistinguishable from a human verdict; obtained once, a forgery was
accepted on every later run. It could not simply be deleted — it exists so pending cannot un-approve
a reviewed head. Measured on the combined endpoint: a status POSTed with a user
credential carries .creator.login, one POSTed by an Actions job carries "creator": null. The test is
written in the positive direction — short-circuit only on something identified as human — because
spelled the other way ("skip if it looks machine-written") any unrecognised shape falls through to
trusted. Both halves are required, so if Gitea later populates creator for Actions the description
test still fails: the guard degrades toward re-deriving, never toward trusting.
What this does NOT close. Anyone who can POST statuses directly can write both a creator and a
Review-verdict: description and impersonate a verdict; branch protection binds the context, not its
issuer. A provenance check, not an authentication one — that was #697's registry credential, fixed by
scoping it off write:repository (ci.actions-credential-scoping); GITEA_TOKEN, RENOVATE_TOKEN,
and a collaborator's own token still can. Severity as #672: requires push access, so the threat model
is a compromised contributor.
Verification honesty. Route 1 was reproduced live; the "and now it fails" half cannot be shown from
a PR, because pull_request_target resolves this definition from main — the self-test gap
ci.gate-trigger-base-resolved records. Pre-merge evidence is that reproduction plus the
executed-behaviour tests in scripts/tests/test_pr_changed_files.py, each verified by mutation; the
live re-check happens on main right after merge.
Route 2 was reproduced live 2026-08-26 (#708), and it is REFUSED. Application code was pushed onto
the branch of Renovate PR #761 (guarded first with a WIP: title prefix, confirmed draft: true /
mergeable: false, so the hijacked head could not merge), then the branch was force-restored to its
recorded sha and the PR verified byte-identical across title, head sha, base ref, draft, mergeable,
state, labels, body hash, author and milestone. With AUTHOR: renovate and a changed set of
Directory.Packages.props and ErsatzTV.Core/Probe708.cs, the classifier logged
Decision: state=pending — authored by the 'renovate' bot account, but changes files outside the dependency-manifest set, and posted review-verdict/h10=pending. Before the manifest allow-list the
same head would have been success on identity alone. The classify step's start marker was present,
so this is a real classification and not the #751 silent no-op.
Where the refusal reason surfaces — the log, NOT the status description. The route-2 explanation
lives in reason, which is only echoed as Decision: state=… — <reason>. Of the three description
branches, two are distinctive — Exempt: <reason> and the raced-repair sentinel REPAIR_DESC — and
every other non-exempt head falls to the generic Awaiting review verdict for <sha7>. So a Route-2
bot refusal and an ordinary awaiting-verdict head are indistinguishable from the status alone;
confirming which branch the classifier took means reading the job log. #708's own text expected the
reason in the description; it is not there.
Cost observed on the same PR, on its ORIGINAL head — not a defect in this record's mechanism.
Re-running the gate on 8798a1d2 (the unmutated head, re-triggered by the WIP: title edit via
types: [… edited], and five minutes before the code push) repaired its exemption to pending via
#751's conservative page-2 rule: that head's status history runs past MAX_RESPONSE_ITEMS, so any
page-2 rows are assumed raced. It could not have happened on the probe head — the page-2 check sits on
the exempt=yes path, which a Route-2 refusal never reaches. That is the blunt-instrument case #763
already predicts, now with a live instance of it; see that issue.
Tightened 2026-08-26 (#742): non-null creator -> an allow-list. The route-3 test asked "was this
POSTed with a user credential", and any account's credential answered yes. RENOVATE_TOKEN is a
write:repository PAT of the renovate bot: unlike #697's registry credential it cannot be
scoped off status-write, because Renovate genuinely needs repo write to push branches and open PRs,
and secrets are a per-repo store rather than a per-workflow grant — so a workflow can reference it
and POST a Review-verdict: success that reads back creator: renovate — non-null, and therefore
inheritable. Inferred from a measurement, not an observed event: the user-credential-vs-Actions-token
asymmetry is measured, but renovate has never actually posted a commit status in this repo — every
status row on all six Renovate-authored PR heads carries creator: null. An earlier draft's past tense
("was inherited") described something nobody saw.
Membership in H10_REVIEWERS restores the intended set for an inherited success: every other
account's, recognised or not, re-derives exactly as an Actions-token status already does. (An existing
failure is governed by the weaker attributability test instead — see the state asymmetry below.) The list is a literal in this
base-resolved definition rather than a repo vars: entry, because a vars: entry is settable without
a PR — i.e. without this gate — while .gitea/ sits on the PROTECTED path list, so editing the
allow-list cannot ride a docs-only exemption.
read_existing_verdict answers TWO questions and therefore carries TWO flags. It is called
twice, and the callers want opposite widths. ex_attributable — an attributable Review-verdict: row
for THIS base, whoever wrote it — is what the LAST-MOMENT RE-READ uses, because that site asks "did a
reviewer post a verdict while we were classifying"; narrowing it does not make it stricter, it makes
the job stop abstaining and post its exemption straight over the row, and the post-write repair does
NOT cover that, being skipped whenever the high-water mark could not be established. ex_human is
ex_attributable plus membership, and only the INHERITANCE short-circuit uses it. The first draft
gated the single flag and so narrowed both sites; cold review caught it, and
test_the_MID_RUN_abstain_still_fires_for_a_NON_allowlisted_creator pins it.
The two calls must compute a predicate that "changed" is measurable against. Before the split they
were identical, so anything firing at the second call and not the first had arrived in between — the
mid-run test got that for free. Splitting the flags broke it: a pre-existing row the job declined to
INHERIT is still attributable, so the re-read abstained on the very row the classification had just
decided to overwrite, left the forgery standing and posted nothing at all. The state/creator/
description triple seen at the first read is therefore snapshotted and compared, making "changed"
explicit. A REPLACEMENT counts as a mid-run event, not only an arrival: the combined endpoint returns
one row per context, so a newer POST replaces rather than adds — MEASURED on this instance 2026-08-26, not assumed, because the
comparison made that load-bearing where presence alone had not: two POSTs of context
probe742/combined-newest to one sha (pending, then success) came back as ONE row on
/commits/{sha}/status, the newest, and as BOTH rows on /statuses/{sha}.
The allow-list governs success ONLY. Treating the two states alike is a fail-open. Trace an off-list failure under a symmetric rule: a real reviewer left off
the literal posts BLOCKED; ex_human=no, so the short-circuit declines; the PR is docs-only, so the
classifier exempts it; the re-read triple never changed, so the mid-run abstain declines; the row
predates the high-water mark, so the post-write repair cannot see it — and the job posts success over
an explicit rejection, reached by the very misconfiguration the allow-list's own diagnostic is about.
The asymmetry costs nothing the allow-list bought: inheriting a failure cannot green anything, the
worst a forged one achieves is a stall, and post-review-verdict.sh POSTs unconditionally so any human
clears it in one command. Pinned by test_an_off_list_FAILURE_is_LEFT_ALONE_even_on_an_EXEMPT_PR,
which uses a DOCS-ONLY file list on purpose — with a code file the job posts pending anyway and the
test would pass measuring nothing.
Why the post-write raced check stays broad — and NOT for the reason first written. The first draft
justified it as "narrowing it would leave an exemption success standing over a rejection". That is not a checked reason: a genuine reviewer is on H10_REVIEWERS by construction, so
membership there would still count every real verdict, and an off-list row is one the job deliberately
overwrote a few lines earlier. What is left once failure is handled at the short-circuit is a
NARROWER net than an earlier draft claimed: it covers a misconfigured reviewer's verdict landing inside
the WRITE WINDOW, and nothing else — a verdict already on the head when the job starts sits below the
high-water mark and is structurally invisible to it. Saying it is "the last net before a green stands"
overstated that, and the sentence is corrected rather than kept. It stays broad because within its
window the errors are not symmetric: a repair to pending is recoverable by re-posting, a
wrongly-standing success on an unreviewed head is not. The cost is stated at the site rather than
implied: any verdict-shaped row landing inside that window trips the STICKY REPAIR_DESC sentinel and
costs that head its exemption until a human clears it.
Both directions are mutation-proven against the shipped file — reverting the membership clause reddens
test_a_verdict_shaped_SUCCESS_from_a_NON_allowlisted_account_is_RE_DERIVED and
test_an_account_that_merely_RESEMBLES_an_allowlisted_login_is_RE_DERIVED; pointing the mid-run
abstain back at ex_human, or dropping the changed-row comparison, reddens the mid-run and
inheritance cases respectively. Counts are deliberately not quoted: test_pr_changed_files.py is
graded PROOF | NONE, so nothing re-executes a number written here and one added parametrize case
would falsify it silently.
A guard being byte-identical does not make its REACHABILITY unchanged — the post-write residuals
are #849, and this change makes them reachable from a new class. Before this change, an attributable
success from ANY account short-circuited and the job wrote NOTHING. Re-deriving an off-list success
is the POINT of the change, and it turns "no write" into "a write" — which is what reaches the paths
where post-write verification cannot verify. The guards themselves are untouched here; their
reachability is not, and "pre-existing" was the wrong answer to that.
Two of them were fixed on this branch and then WITHDRAWN, which is the more useful record. The fix withheld the exemption when no high-water mark could be established, and repaired to the sentinel when the post-write history was unreadable. Re-review found both wanting, on grounds that generalise:
- The withheld exemption wrote a GENERIC
pending, and a genericpendingis exactly what a later run re-derives intosuccess. It did not close the burial route, it moved which run greens it. Closing it properly needs a distinct sticky "unverified write" sentinel, which is a new state in a state machine that already has three. - That
pendingalso has NO RETRY PATH. The workflow triggers only onpull_request_targettypes — no schedule, noworkflow_dispatch— so a transient API failure on a PR's LAST event leaves an exempt PR stalled 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, and it was described as a "one-run cost" when it is not.
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
the two values are coupled: the day the writer credential stops being an allow-listed account, every
verdict silently stops being inheritable and each later event re-derives it — to pending on a PR that
needs a verdict, or to an exemption success on a docs-only or Renovate-manifest one, which is the
quieter and worse half. Either way the verdict is gone. It now READS THE STATUS BACK after posting it,
identifies its own write by state and description, and refuses — before the comment, so the surviving
half-state is the ask one — unless .creator.login is on the allow-list. A creator: null
(Actions-token) status is refused with its own message, because there is no account to name and "add
them to the allow-list" would be the wrong advice.
The writer mirrors the state asymmetry above rather than simplifying it: membership is required for a
success only. A failure is written and commented normally from any attributable account, because
that is exactly what this record already says the gate does — requiring membership there would refuse
a verdict the gate honours, and withhold the comment that is the only supported way to record a
rejection. It also reads .status and NOT .state, with no fallback, because the gate reads
.status: tolerating a shape the gate cannot read would make the writer report success on a verdict
that gets re-derived, which is #845 recreated by the check meant to prevent it.
The allow-list is not restated on the writer side. scripts/lib/h10-reviewers.sh DERIVES it from the
H10_REVIEWERS literal in this workflow, so there is one declaration rather than two held together by
a parity test — the shape testing.guard-derives-population-from-source records six failed rounds
against. It is a parse rather than a shared declaration both sides source, because the gate runs
against a checkout of the PR BASE sha: a PR whose base predates such a file would not have it, and a
missing source under set -euo pipefail kills the job, which posts no review-verdict/h10 at all
and blocks every merge including the repair (block_admin_merge_override: true). A fallback list for
that case would reintroduce the second copy. So the derivation runs on the side that can fail safely
and the body of this workflow is untouched. The parse admits exactly one anchored assignment or
refuses; test_pr_changed_files.py::test_the_H10_REVIEWERS_list_is_glob_free_and_non_empty reads the
same literal with the same anchors OVER THE SAME POPULATION — the whole file — so a shape change
reddens CI on the PR that makes it. The population is part of the rule, not an implementation
detail: that guard used to scope itself to the classify step's run: body, and a second anchored
assignment in any other run: block then left it green while making the writer refuse every verdict.
Measured before it was aligned: the guard green, and about half the writer suite red. What was
missing is worth stating precisely, because the obvious phrasing is wrong twice over. Those failures
DO carry the library's refusal, which names the count and the variable. And alignment ADDS a named
guard failure rather than replacing them. What was missing is a failure POINTING AT THE WORKFLOW,
since the reds surface under unrelated test names and the cause had to be inferred from a stack of
them. No figure is given for how many name it, and the trap is worth recording so one is not added
back: a grep -c over pytest output counts LINES rather than failures and --tb=line prints each
matching failure twice, so such a count overstates, while raising the traceback detail moves it the
other way by making more failures show the refusal text. Any figure is therefore a property of the
pytest flags that produced it rather than of the code, and reproducing one means parsing per-failure
sections instead of grepping lines. Such an edit reaching main WOULD be a repo-wide merge outage — no verdict could be
posted and review-verdict/h10 is required — but the guard is not the only thing standing in the
way, and saying so would overstate it: the writer suite reddens too, in the ADVISORY script-tests
job (the required contexts, measured 2026-08-30, are Build & test, EF migration integrity and
review-verdict/h10). What alignment buys is ONE named guard failure pointing at the workflow, ALONGSIDE the half-suite of unrelated
failures. The route by which the outage is actually reachable is the snapshot boundary below — a
verdict posted from a checkout predating the edit — which is why the two paragraphs belong together.
The one false ACCEPT this leaves, named with its direction. The writer reads the workflow in the LOCAL checkout; the gate runs the base-resolved one. On a branch that ADDS a reviewer — the likely edit — the writer accepts that account while the live gate will not yet inherit from it, which is the #845 stall reached through the check meant to catch it. Accepted rather than closed: a live remote read of the base workflow puts a network dependency on the merge-consent write path, where a read failure must resolve either to a refusal (a new way to block every verdict) or to a pass (the hole, reopened). The window is one PR long, the person making the edit is the one who knows the list is in flux, and the residual is a STALL, never a merge of unreviewed code.
The allow-list was timothy and the instance held exactly renovate and timothy when this was
measured on 2026-08-26 (GET /api/v1/user, GET /api/v1/admin/users?limit=50). That measurement is
no longer what keeps the coupling correct, which is the point of the change.
Why the signal had to reach the WRITER. The job already names the creator and the allow-list
whenever it declines to inherit — but in the WORKFLOW RUN, while the mistake is made at the terminal
running post-review-verdict.sh, which reported success either way. An earlier draft tried to fix
that by shouting louder at the reader: a ::warning:: alongside that line, withdrawn after producing
three defects in three rounds (a message that contradicted itself for an allow-listed creator, an
unescaped API value interpolated into a workflow command, and a test asserting its absence on a path
that exits two guards earlier and so could never fail). Recorded so it is not re-attempted: more
volume in the run was never the missing piece.
What #742 does NOT close, restated because the allow-list invites the wrong reading. An
allow-listed collaborator's own write-scoped token can still POST a verdict nobody formed. Branch
protection binds the context NAME, not its issuer, and Gitea offers no way to restrict who may post a
context. h10 remains a process guard against mistakes and drift, not a security boundary against
push access. The advisory hook .claude/hooks/pretooluse-merge-consent.sh checks no identity at all
and is deliberately left that way: it runs as the operator, on the operator's machine, and its
strictest outcome is a human prompt.
Separate defect found reviewing this change: ci.grep-q-pipefail-inversion — a pre-existing
SIGPIPE inversion that let a large PR skip the PROTECTED guard entirely. Fixed in the same PR.