Files
ersatztv/docs/decisions/records/ci/exemption-provenance.md
T
fb55929711
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m24s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m17s
fix(869,893): re-establish the 1.25.4-dated CI claims on 1.27.1, and settle the page_statuses asymmetry from source (#905)
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>
2026-09-02 20:20:43 +00:00

34 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
ci.exemption-provenance 2026-07-29 — the `review-verdict/h10` exemption path binds the base ref, constrains the bot exemption by CONTENT, and re-derives any success it cannot attribute to a human (#698) active 2026-07-29 none none The three inputs the exemption decision rests on must each be bound to something the judged PR cannot mutate. (1) BASE — `scripts/pr-changed-files.sh` takes the expected base BRANCH as a REQUIRED 5th argument and re-reads it before and after paging, because `/pulls/{n}/files` diffs against the PR's live base and retargeting moves the answer without moving the head sha; the workflow passes `github.event.pull_request.base.ref` from the `pull_request_target` payload, which a retarget cannot rewrite. (2) BOT EXEMPTION — an author match is necessary but never sufficient: `pull_request.user.login` is the PR's immutable CREATOR while its head is not, so the exemption additionally requires EVERY changed path to be a dependency manifest (`Directory.Packages.props` or `.config/dotnet-tools.json`, and ONLY those — the npm manifests are excluded because `package.json` `scripts` are executed by CI). (3) INHERITED SUCCESS — the never-overwrite short-circuit fires for a `success` only when it is POSITIVELY identified as an APPROVED REVIEWER's verdict for THIS base, meaning a `.creator.login` that is a member of the workflow's `H10_REVIEWERS` allow-list (an existing `failure` takes the weaker ATTRIBUTABLE test and needs no membership, because inheriting a rejection can only withhold an exemption while re-deriving one can turn it green on an exempt PR) — non-null was too weak, since any account's credential satisfies it and `RENOVATE_TOKEN` is one that cannot be scoped down (#742) — AND a `Review-verdict:` description AND, when that description records a base (`(base: …)`, `release.verdict-status-check`), a base matching the PR's — tested by requiring the description to END with the exact literal `(base: <base>)` and to contain exactly ONE such marker, never by extracting a value (see below); a present-but-different base is rejected, an absent one is not, since verdicts predating that convention carry none; every other shape, including any unrecognised one, is re-derived rather than trusted. The bot and docs-only exemptions are evaluated as INDEPENDENT predicates and the decision made afterwards, never as an `elif` chain. `edited` is in the workflow's `types:` so a retarget reclassifies — which gives DETECTION, not atomicity: status writes are not serialized, so a stale run can still post over a fresher one. That residual is now FENCED rather than merely tracked (NARROWED, not resolved — a retarget between the fence's final count and the POST still leaves a PERMANENT forged green, #849) — the job refuses to write at all if the PR's timeline retarget COUNT moved while it was classifying (`ci.verdict-write-retarget-fence`, #706) — leaving TWO windows, not one: the sub-round-trip window that no API without compare-and-set can close, AND the post-final-count/pre-POST window, which is not sub-round-trip and yields a PERMANENT green because the successor can consume the `edited` event and exit before the stale run posts last (#849). The PROTECTED path list additionally covers `CLAUDE.md` and `AGENTS.md` (#751) — they are not prose but the documents DEFINING the completion protocol, the merge-consent convention and the H10 rule, so protecting `.claude/` while the file specifying what it enforces stayed docs-only-exempt was the same self-exemption one directory over; driving the real classify body with a lone `CLAUDE.md` change produced an exemption `success`. `README.md` is deliberately not listed. It also covers `.codex/` (#711), which mirrors `.claude/hooks/` byte for byte including the merge-consent hook — latent while that directory is untracked, live the moment it is tracked; the list stays ENUMERATIVE rather than derived, because a derived rule would have to be evaluated against the very file list being classified. Reading the CURRENT status for input (3) must tolerate `statuses: null`: `GET /commits/{sha}/status` serialises a nil slice as `null`, not `[]`, on a head with no statuses yet, and an `array`-only gate made `read_existing_verdict` `exit 1` and post nothing at all (#751, `ci.workflow-run-body-no-expressions`) — `null` is accepted only when `total_count` is 0, so a body that merely lost its array is still refused. Path predicates are evaluated by COUNTING with `grep -c`, never `| grep -q` (SIGPIPE inversion) and never a here-string (temp-space failure) — see `ci.grep-q-pipefail-inversion`. The WRITER is bound to the same allow-list rather than trusted to match it (#845): `scripts/post-review-verdict.sh` reads its own status back, identifies that write by state and description, and refuses before writing the comment unless `.creator.login` is a member — so a writer credential that is not a reviewer fails at the terminal that made the mistake instead of stalling the PR one event later. The list is DERIVED from the literal in this workflow by `scripts/lib/h10-reviewers.sh`, never restated: one declaration, not two plus a parity test. verdict inherited from a bot token, RENOVATE_TOKEN posts a review verdict, who may post an h10 verdict, H10_REVIEWERS allow-list, forged review-verdict exemption, retarget race against the docs-only classifier, PR base changed mid-run, hijacked Renovate branch, bot exemption on a code change, machine-written success inherited as a verdict, status creator null vs user, never overwrite a human verdict, exemption chain skips docs-only for bots, why is my Renovate PR asking for a verdict, base ref binding on pr-changed-files.sh · paths: `.gitea/workflows/review-verdict.yml`, `scripts/pr-changed-files.sh`, `.claude/hooks/pretooluse-merge-consent.sh`, `scripts/tests/test_pr_changed_files.py`, `scripts/post-review-verdict.sh`, `scripts/lib/h10-reviewers.sh` · issues: #849, #845, #742, #698, #697, #672, #663, #649, #632 `scripts/pr-changed-files.sh <owner> <repo> <pr> <expected-head-sha> <expected-base-ref>` (5 args; a 4-arg call exits 2); workflow env `BASE_REF: ${{ github.event.pull_request.base.ref }}`; `BOT_MANIFESTS` anchored allow-list; `H10_REVIEWERS="timothy"` declared as a literal in the classify body (a repo `vars:` entry or secret is settable without a PR); `scripts/lib/h10-reviewers.sh` derives that same literal for the write side by an anchored single-assignment parse and refuses on 0 or >1 matches, on a non-plain-login entry, or on an empty list; membership by exact POSIX `[ = ]` comparison over word-split entries, never an UNQUOTED right-hand side — `` makes it a pattern, while a quoted RHS under ` ` is literal (probed, bash 5.3); POSIX `[ = ]` cannot glob at all, which is why it is used and never `grep -xF` (an API-supplied creator containing a newline reads as several patterns); `read_existing_verdict` sets `ex_attributable` (creator non-null AND `^Review-verdict:` AND base OK) and derives `ex_human` from it by membership; the INHERITANCE short-circuit reads `ex_human`, the LAST-MOMENT RE-READ reads `ex_attributable` plus a change against the state/creator/description triple snapshotted at the first read, and the post-write raced `jq` stays broad on `creator != null`; `types: [opened, reopened, synchronize, ready_for_review, edited]`

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

<head>`, `.git/shallow` is present, `git merge-base` exits **1**, and `git diff ...<head>` exits **128** with `fatal: ...: no merge base`. Re-probe it against a GIT version. So the base is read before the first page and after the last, and **the gap is stated plainly**: a retarget opening *and* closing strictly between the files call and the re-read stays invisible from inside the enumeration.

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 generic pending is exactly what a later run re-derives into success. 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 pending also has NO RETRY PATH. The workflow triggers only on pull_request_target types — no schedule, no workflow_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.