fix(849): the verdict gate replaces every unknown state, and proves the clauses that claim to #890

Merged
timothy merged 11 commits from fix/849-verdict-postwrite into main 2026-08-30 07:55:11 +02:00
10 changed files with 3198 additions and 187 deletions
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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 <issue>` 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 <pr> <MERGEABLE|APPROVED|LGTM|BLOCKED|NOT-MERGEABLE> [note]`** — it posts both the `Review-verdict: … @ <head-sha>` 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 <pr> <MERGEABLE|APPROVED|LGTM|BLOCKED|NOT-MERGEABLE> [note]`** — it posts both the `Review-verdict: … @ <head-sha>` 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.
+74 -16
View File
@@ -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
@@ -1689,8 +1690,8 @@ 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
overtaken *declines to write*. The job counts `change_target_branch` events on the PR's issue timeline
**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 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
@@ -1700,9 +1701,22 @@ 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. 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.
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,16 +1745,60 @@ 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.
**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.
**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.
- `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
**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 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.
**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, 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
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, 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
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".
**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`,
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
@@ -214,10 +216,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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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 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)
+2 -2
View File
@@ -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 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 (#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:<short-sha>` 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 `:<short-sha>` — 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. |
File diff suppressed because it is too large Load Diff