fix(751): review round — the twin null-shape, a governance self-exemption, and four over-claims
Two independent cold reviews (a cross-family GPT-5.6 pass and an isolated Opus pass).
Neither found a path to a green `review-verdict/h10` on an unreviewed head. Both found
real defects BESIDE the fix, which is the failure mode this file keeps producing.
THE TWIN, and the reason not to trust "I fixed the two I could see". `GET
/commits/{sha}/status` returns `statuses: null` — not `[]` — for a head with no
statuses yet: `{"state":"pending","total_count":0,"statuses":null}`, measured on PR
#739's head. `read_existing_verdict` gated on `.statuses | type == "array"` and took
its `exit 1` path, posting NOTHING. Fail-closed, but the user-visible outcome is the
one this issue is about: an exempt PR with no status and, since #743, no bypass. Its
double printed `{"statuses": []}` at all three no-verdict sites, so that branch was
unreachable in the suite — the same unfaithful-double story as the timeline, one
function over. `null` is accepted only when `total_count` is 0, so a body that merely
lost its array is still refused and an existing verdict is still protected. Swept
`scripts/pr-changed-files.sh` too: `pulls/{n}/files` returns `[]`, unaffected. The
generalisable rule is that a nil Go slice serialises to `null`, so every list-shaped
field on this API is suspect and only a per-endpoint measurement settles it.
A GOVERNANCE SELF-EXEMPTION, reachable again precisely because this change works.
`DOCS_ONLY` matched `CLAUDE.md` and `AGENTS.md` — the documents that DEFINE the
completion protocol, the merge-consent convention and the H10 rule. Driving the real
classify body with a lone `CLAUDE.md` change produced `review-verdict/h10=success`.
Protecting `.claude/` while the file specifying what it enforces stayed exemptible is
the same self-exemption the header rules out, one directory over. Both added to
PROTECTED; `README.md` deliberately not (ordinary prose, no enforcement).
FOUR OVER-CLAIMS, corrected rather than defended:
* The repo-wide expression test does NOT catch "any payload that cannot evaluate".
It checks the HEAD TOKEN of each dotted path. `${{ github.ref == }}` and
`${{ …head.sha + }}` pass; so does a renamed output, since tokens after the first
are skipped by design. Claim corrected in the docstring, `docs/ci-cd.md` and the
record. The test is kept permissive on purpose: a red here blocks every merge.
* The strict test's anti-vacuity half banned expressions ANYWHERE outside
`with:`/`env:`, so the standard `if: ${{ always() }}` spelling and even a delimiter
in an inert top-level comment went red — a guard more dangerous than its target.
Replaced with the honest property: the YAML walk saw every `run:` body it declares.
* The `if:` assertion demanded the bare `always()` exactly; now normalised, since the
wrapped form is identical to the runner.
* `exit 1` was matched anywhere in the guard body, so an unreachable
`if false; then exit 1; fi` satisfied it while the real branch said `exit 0`. Now
required INSIDE the missing-marker branch — and the new behavioural test settles it
properly by EXECUTING the guard body both ways.
* The record asserted a repo-wide obligation to guard consequential steps. It is not
repo-wide: `docker-build.yml`'s `test`/`migrations` are also required contexts and a
dropped step there is fail-OPEN (green having done no work), strictly worse than
here. Scoped to this file and tracked as #756 rather than asserted as done.
Also: comments in both files still said it was unestablished whether a later step runs
after a drop — runs 1863/1866 established it, so they now record the measurement; a
cited test name that never existed; `kind` leaked to global scope; a mangled comment
wrap; and an already-false "one event on page 1".
Hardening of my own: `null` now counts as exhaustion only from page 2 ON. Every real
PR's first page carries events (4, 2, 9, 5, 10 across #752/#753/#749/#739/#717), so a
terminator on page 1 means no page was ever read, and certifying "no retarget" from a
response we cannot explain is the one thing the fence exists to refuse. Narrows rather
than closes it: a wrong `null` on page 3 still reads as exhaustion.
Verification: 137 in this file / 452 total green; ELEVEN mutations each red —
reintroducing the defect, deleting the guard, deleting the marker write, removing
`if: always()`, `exit 1`→`exit 0`, a delimiter in the guard body, the fence gate (20
red), the TWIN gate (70 red), dropping the governance paths, accepting a null first
page, and diverging the marker path between the two steps.
Refs: #751
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -199,7 +199,7 @@ jobs:
|
||||
#
|
||||
# Two things now stop a recurrence, and they are deliberately different in kind: the
|
||||
# start-marker below turns a dropped step into a RED job instead of a green one, and
|
||||
# `test_no_workflow_run_body_contains_an_expression_delimiter` in
|
||||
# `test_the_verdict_workflow_has_NO_expression_delimiter_in_any_run_body` in
|
||||
# scripts/tests/test_pr_changed_files.py rejects the delimiter at review time. Note that
|
||||
# the older workflow-shape tests all scan comment-STRIPPED text (`_code_lines`) precisely
|
||||
# so prose cannot redden them — which makes every one of them structurally blind to this
|
||||
@@ -283,7 +283,19 @@ jobs:
|
||||
# containing executable hooks"). A derived rule has to be evaluated against the PR's own
|
||||
# file list, which is the very thing being classified — more moving parts inside a security
|
||||
# predicate, to remove a maintenance burden that is one line per new tooling directory.
|
||||
PROTECTED='^(\.claude/|\.codex/|\.gitea/|\.husky/|scripts/|docker/ci/)'
|
||||
# `CLAUDE.md` and `AGENTS.md` are listed even though they are `.md` files, because they are
|
||||
# not prose about the project — they are the documents that DEFINE the completion protocol,
|
||||
# the merge-consent convention and the H10 rule itself. `.claude/` being protected while the
|
||||
# file that specifies what `.claude/` enforces was docs-only-exempt is the same
|
||||
# self-exemption the header rules out, one directory over. Found by cold review
|
||||
# (ersatztv#751): driving the real classify body with a lone `CLAUDE.md` change produced
|
||||
# `review-verdict/h10=success`, "Exempt: docs-only change (no code, no protected path)".
|
||||
#
|
||||
# It is fixed HERE rather than deferred because this change is what makes it reachable
|
||||
# again: no exemption `success` was writable at all while the classify step was dropped, so
|
||||
# restoring the exemptions restores this hole with them. `README.md` is deliberately NOT
|
||||
# listed — it is ordinary prose and carries no enforcement.
|
||||
PROTECTED='^(\.claude/|\.codex/|\.gitea/|\.husky/|scripts/|docker/ci/|CLAUDE\.md$|AGENTS\.md$)'
|
||||
# Docs-only: prose and decision records. Deliberately narrower than the hook's pattern,
|
||||
# which also lets .claude/.gitea/.husky through — that carve-out is safe there only
|
||||
# because it falls through to a HUMAN PROMPT, whereas here it would post a green status
|
||||
@@ -369,11 +381,31 @@ jobs:
|
||||
read_existing_verdict() {
|
||||
local json row
|
||||
json=$(gh "$BASE_URL/repos/$REPO/commits/$SHA/status?limit=100") || json=""
|
||||
if [ -z "${json//[[:space:]]/}" ] || ! printf '%s' "$json" | jq -e '.statuses | type == "array"' >/dev/null 2>&1; then
|
||||
echo "::error::Could not read existing commit statuses for ${SHA:0:7}. Refusing to post anything rather than risk overwriting an existing verdict."
|
||||
# `.statuses` IS `null`, NOT `[]`, ON A HEAD WITH NO STATUSES YET — the same nil-slice
|
||||
# serialization as the timeline terminator, found by cold review of the fix for that one
|
||||
# (ersatztv#751). Measured on this instance: PR #739's head 5fa672e2 returns
|
||||
# `{"state":"pending","total_count":0,"statuses":null}`. An `array`-only gate read that as
|
||||
# unreadable and took the `exit 1` below, so the job posted NOTHING — fail-closed, but the
|
||||
# user-visible outcome is exactly the one this issue is about: an exempt PR left with no
|
||||
# status and, since ersatztv#743, no bypass. Fixing only the timeline site would have left
|
||||
# the identical bug one function away.
|
||||
#
|
||||
# `total_count` is required to agree, so `null` is accepted only as the empty case and not
|
||||
# as a body that merely lost its array. Anything else is still "cannot tell" and still
|
||||
# refuses to post, which is what protects an existing verdict from a transient API error.
|
||||
st_kind=$(printf '%s' "$json" | jq -r '.statuses | type' 2>/dev/null) || st_kind=""
|
||||
st_total=$(printf '%s' "$json" | jq -r '.total_count // "x"' 2>/dev/null) || st_total="x"
|
||||
st_ok=no
|
||||
case "$st_kind" in
|
||||
array) st_ok=yes ;;
|
||||
null) if [ "$st_total" = "0" ]; then st_ok=yes; fi ;;
|
||||
esac
|
||||
if [ -z "${json//[[:space:]]/}" ] || [ "$st_ok" != yes ]; then
|
||||
echo "::error::Could not read existing commit statuses for ${SHA:0:7} (.statuses was '${st_kind:-unparseable}', total_count '${st_total}'). Refusing to post anything rather than risk overwriting an existing verdict."
|
||||
exit 1
|
||||
fi
|
||||
row=$(printf '%s' "$json" | jq -r --arg c "$CONTEXT" '[.statuses[] | select(.context == $c)] | first // {}')
|
||||
# `// []` so the null case cannot hard-error here under `set -e` once it is accepted above.
|
||||
row=$(printf '%s' "$json" | jq -r --arg c "$CONTEXT" '[(.statuses // [])[] | select(.context == $c)] | first // {}')
|
||||
ex_state=$(printf '%s' "$row" | jq -r '.status // ""')
|
||||
ex_creator=$(printf '%s' "$row" | jq -r '.creator.login // ""')
|
||||
ex_desc=$(printf '%s' "$row" | jq -r '.description // ""')
|
||||
@@ -479,22 +511,22 @@ jobs:
|
||||
# coming — a real stall. The retarget count moves only for the mutation that actually
|
||||
# invalidates a classification, and that mutation always brings its own re-run.
|
||||
#
|
||||
# Completeness is a guard, not an assumption (`ci.verdict-write-retarget-fence`; the key
|
||||
# cited here until 2026-08-06, `ci.paged-endpoint-completeness`, has never existed as a
|
||||
# record — resolve decisions through the catalog, not through a path or key in a comment):
|
||||
# the count is
|
||||
# Completeness is a guard, not an assumption (`ci.verdict-write-retarget-fence`; this
|
||||
# cited `ci.paged-endpoint-completeness` until 2026-08-06, a key that has never existed as
|
||||
# a record — resolve decisions through the catalog, never through a key or path quoted in a
|
||||
# comment). The count is
|
||||
# trusted ONLY when paging reached a validated EMPTY page. A short page, a non-array body, a
|
||||
# non-numeric length or the page cap all leave `rt_ok=no`, and an untrusted count is treated
|
||||
# below as "cannot tell" rather than as zero.
|
||||
count_retargets() {
|
||||
rt_count=0
|
||||
rt_ok=no
|
||||
local page=1 raw n m total=0
|
||||
local page=1 raw n m kind total=0
|
||||
while [ "$page" -le 20 ]; do
|
||||
raw=$(gh "$BASE_URL/repos/$REPO/issues/$PR/timeline?limit=50&page=${page}") || return 0
|
||||
if [ -z "${raw//[[:space:]]/}" ]; then return 0; fi
|
||||
# A PAGE PAST THE END IS JSON `null` HERE, NOT `[]` (ersatztv#751). Measured on this
|
||||
# instance at Gitea 1.27.1: page 2 of PR #752's timeline (one event on page 1) is the
|
||||
# instance at Gitea 1.27.1 (page 1 non-empty, page 2 exhausted): page 2 of PR #752's timeline is the
|
||||
# four bytes `null`, while `/issues/{n}/comments` with no comments returns `[]`. The
|
||||
# instance is NOT consistent between endpoints, so the terminator has to be read from
|
||||
# what this endpoint actually does rather than from the shape a list endpoint "should"
|
||||
@@ -516,8 +548,19 @@ jobs:
|
||||
# workflow once at jq 1.6 (ersatztv#647). A `case` over `jq -r 'type'` cannot be read
|
||||
# two ways. Anything that is neither `null` nor `array` is still "cannot tell".
|
||||
kind=$(printf '%s' "$raw" | jq -r 'type' 2>/dev/null) || kind=""
|
||||
# `null` counts as exhaustion only from page 2 ON. Every real PR's first page carries
|
||||
# events — measured across #752/#753/#749/#739/#717 (4, 2, 9, 5, 10) — so a `null`
|
||||
# FIRST page is anomalous rather than empty, and trusting a zero count from it would
|
||||
# mean trusting that no retarget happened on the strength of a response we cannot
|
||||
# explain. Requiring one real page keeps the property the walk is for: something was
|
||||
# actually read. A PR that somehow has an empty first page falls through to `rt_ok=no`,
|
||||
# which withholds the exemption and asks for a human verdict — the safe direction.
|
||||
# This narrows rather than closes the general concern: a wrong `null` on page 3 is still
|
||||
# read as exhaustion, and no bounded number of round-trips can rule that out.
|
||||
case "$kind" in
|
||||
null) rt_ok=yes; rt_count=$total; return 0 ;;
|
||||
null)
|
||||
if [ "$page" -gt 1 ]; then rt_ok=yes; rt_count=$total; fi
|
||||
return 0 ;;
|
||||
array) ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
@@ -947,13 +990,19 @@ jobs:
|
||||
# illustrative snippets, and `always()` is written bare as `if:` requires — the static test in
|
||||
# scripts/tests/test_pr_changed_files.py holds this to it.
|
||||
#
|
||||
# WHAT IS NOT YET ESTABLISHED, stated plainly rather than assumed: that a LATER step still runs
|
||||
# after an earlier one is dropped. The #751 evidence cannot say — the classifier was the last
|
||||
# step in the job, so there was never a subsequent step to observe. That is what the scratch-
|
||||
# base probe verifies, with a negative control that reintroduces a bad expression and must
|
||||
# produce a RED job. If the runner turns out to drop the remaining steps too, this guard cannot
|
||||
# work and the body has to move into `scripts/` instead, where a one-line `run:` makes the
|
||||
# class unreachable.
|
||||
# THAT A LATER STEP STILL RUNS AFTER AN EARLIER ONE IS DROPPED IS MEASURED, not assumed — it is
|
||||
# the premise this guard stands on, and the #751 report could not settle it because the
|
||||
# classifier was the job's last step, leaving nothing subsequent to observe. Established on this
|
||||
# instance by a scratch-base probe with a negative control (Gitea 1.27.1, 2026-08-06): run 1863
|
||||
# dropped the classifier on a reintroduced bad payload, logged `evaluating expression 'always()'
|
||||
# -> true`, ran THIS step, and the job concluded `failure`. Run 1866 is the positive control —
|
||||
# the classifier ran, posted its exemption, and this step found the marker at
|
||||
# `/tmp/h10-classifier-started-1866-1`, confirming the run-keyed path resolves.
|
||||
#
|
||||
# Had the runner dropped the remaining steps too, this guard could not work and the body would
|
||||
# have had to move into `scripts/`, where a one-line `run:` makes the class unreachable. It does
|
||||
# not, so it stays here. Re-measure if the runner is upgraded: this is the one assumption whose
|
||||
# failure is silent again.
|
||||
- name: Assert the classifier actually executed
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
+37
-4
@@ -1041,10 +1041,15 @@ Three things now hold the line, and they are deliberately different in kind:
|
||||
that absence would be silent as well.
|
||||
- **Two static guards**, in `scripts/tests/test_pr_changed_files.py`: no delimiter in *any* `run:`
|
||||
body of this file (absolute — a dropped step here is a dead merge gate, and its bodies are ~700
|
||||
lines of prose), and repo-wide, every expression payload must name a context or function the runner
|
||||
can resolve (permissive, because the other workflows interpolate into `run:` legitimately — 7
|
||||
occurrences today). The second catches the class: a payload that cannot evaluate, wherever it sits,
|
||||
including in an `if:`.
|
||||
lines of prose), and repo-wide, every expression payload's **head token** must name a context or
|
||||
function the runner can resolve (permissive, because the other workflows interpolate into `run:`
|
||||
legitimately — 7 occurrences today). Be precise about the second one's reach: it catches the
|
||||
historical defect (`pr number`) and a nonexistent context, but **not** a syntactically invalid
|
||||
payload whose tokens are all known (`${{ github.ref == }}` passes), nor a renamed output
|
||||
(`steps.metadata.outputs.shortsha` passes — every token after the first is preceded by `.` and is
|
||||
skipped), nor an unclosed opener. Catching those needs an expression parser. An earlier draft of
|
||||
this section claimed it caught "a payload that cannot evaluate, wherever it sits"; that was false,
|
||||
and the corrected claim is the one to rely on.
|
||||
|
||||
Worth knowing why nothing caught this for three days: every *other* workflow-shape test in that file
|
||||
reads `_code_lines()`, which strips comments. That is correct for what it was for, but it encodes the
|
||||
@@ -1073,6 +1078,34 @@ Two things kept it invisible, and both are worth generalising:
|
||||
The type is now read as a value (`case` over `jq -r 'type'`) rather than through `jq -e`, whose
|
||||
exit-status semantics already bit this workflow once at jq 1.6, and both `null` and `[]` terminate the
|
||||
walk. The regression test is parameterised over both shapes because both are live on this server.
|
||||
`null` is accepted as exhaustion only from **page 2 on** — every real PR's first page carries events
|
||||
(measured: 4, 2, 9, 5, 10 across #752/#753/#749/#739/#717), so a `null` first page is anomalous rather
|
||||
than empty, and the walk should not certify "no retarget happened" from a response it cannot explain.
|
||||
|
||||
**The same nil-slice shape bites `/commits/{sha}/status`** — a third instance, found by cold review of
|
||||
the fix for the second. A head with no statuses yet returns
|
||||
`{"state":"pending","total_count":0,"statuses":null}` (measured on PR #739's head). `read_existing_verdict`
|
||||
gated on `.statuses | type == "array"`, so it hit its `exit 1` and posted nothing at all — fail-closed,
|
||||
same user-visible outcome. `null` is now accepted there only when `total_count` is 0, so a body that
|
||||
merely lost its array is still refused and an existing verdict is still protected from a transient
|
||||
error. `scripts/pr-changed-files.sh` was swept and is unaffected (`pulls/{n}/files` returns `[]`).
|
||||
**The generalisable rule: a nil Go slice serialises to `null`, so every list-shaped field on this API
|
||||
is suspect and only a per-endpoint measurement settles it.**
|
||||
|
||||
**`CLAUDE.md` and `AGENTS.md` are now PROTECTED paths.** `DOCS_ONLY` matched them, so the documents
|
||||
that *define* the completion protocol, the merge-consent convention and the H10 rule were themselves
|
||||
docs-only-exemptible while `.claude/` was protected — the same self-exemption the gate rules out, one
|
||||
directory over. Driving the real classify body with a lone `CLAUDE.md` change produced
|
||||
`review-verdict/h10=success`. It is fixed here rather than deferred because restoring the exemptions is
|
||||
what makes it reachable: no exemption `success` was writable at all while the classify step was
|
||||
dropped. `README.md` is deliberately not listed — ordinary prose, no enforcement. For the same reason,
|
||||
#706's known residual returns with the working fence: while `rt_ok` was never `yes`, route 1 was closed
|
||||
by accident.
|
||||
|
||||
**The guard is scoped to this file, and that is a gap rather than a decision** — `docker-build.yml`'s
|
||||
`test` and `migrations` jobs are also required contexts, and there a dropped step is **fail-OPEN**: the
|
||||
required check goes green having done no work, which is strictly worse than an absent status (compare
|
||||
#684). Tracked as ersatztv#756.
|
||||
|
||||
It lives in its **own workflow file** on purpose: `pr-checks.yml` sets `cancel-in-progress: true`,
|
||||
and a cancelled run there would leave an exempt PR with no status and no further push to
|
||||
|
||||
@@ -44,7 +44,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `ci.decisions-lifecycle-flake` | When `decisions lifecycle` is the **only** red job, do not investigate and do not create a new run to clear it — no rebase, no `--amend`, no no-op push; the operator reruns that single job from the Gitea UI. | 2026-07-21 | [link](records/ci/decisions-lifecycle-flake.md) |
|
||||
| `ci.docs-only-detect-shallow-safe` | The docs-only detect script must diff against `FETCH_HEAD` (always resolves after `git fetch`, even shallow) using a two-dot tree diff — not `origin/<base>` with three-dot — because a `fetch-depth: 1` shallow clone has no remote-tracking ref and no merge-base, which silently fails the original detect into `docs_only=false` (full matrix, no functional error). A CI-behavior change must be verified by measuring the effect (job durations), not just a green check. | 2026-07-17 | [link](records/ci/docs-only-detect-shallow-safe.md) |
|
||||
| `ci.docs-only-skip-steps` | A docs-only change must still run every required job (`test`, `migrations`) so their commit-status contexts always report; each heavy job runs `scripts/ci-detect-docs-only.sh` first and gates its real STEPS on `if: steps.detect.outputs.docs_only != 'true'`, never `if:`-skips the whole job (an `if:`-skipped job reports `skipped`, not `success`, which branch protection may never unblock on). Detection biases toward running more on any doubt. | 2026-07-17 | [link](records/ci/docs-only-skip-steps.md) |
|
||||
| `ci.exemption-provenance` | 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 only for a status POSITIVELY identified as a human verdict for THIS base, meaning a non-null `.creator.login` 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 — 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 only the sub-round-trip window that no API without compare-and-set can close. The PROTECTED path list additionally 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. 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`. | 2026-07-29 | [link](records/ci/exemption-provenance.md) |
|
||||
| `ci.exemption-provenance` | 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 only for a status POSITIVELY identified as a human verdict for THIS base, meaning a non-null `.creator.login` 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 — 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 only the sub-round-trip window that no API without compare-and-set can close. 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`. | 2026-07-29 | [link](records/ci/exemption-provenance.md) |
|
||||
| `ci.format-gate-folder-mode` | The blocking `format` CI job (and matching pre-commit hook) runs `dotnet format whitespace . --folder --include <files>` instead of loading the full MSBuild/Roslyn solution, cutting the gate from ~480s to ~0.5s with unchanged whitespace/charset coverage. | 2026-07-19 | [link](records/ci/format-gate-folder-mode.md) |
|
||||
| `ci.functional-e2e-harness` | The `functional-e2e` CI job boots the PR's own code from source via `dotnet run` (`scripts/e2e-local.sh`) and runs deterministic assertions (`scripts/e2e-functional.sh`) as an advisory (non-blocking) job, not a `build` dependency or required check. Originally curl-only; since #445 the same job carries a second, headless-browser step for the contracts curl cannot express — see `ci.ui-e2e-harness`. | 2026-07-16 | [link](records/ci/functional-e2e-harness.md) |
|
||||
| `ci.gate-trigger-base-resolved` | The workflow that writes the branch-protection-required `review-verdict/h10` status triggers on `pull_request_target` with `branches: [main]`, never on plain `pull_request`. Gitea resolves a `pull_request` workflow DEFINITION from the PR's own head commit, so under that trigger a PR editing `.gitea/workflows/review-verdict.yml` ran its own rewritten copy and could post `h10=success` for itself; `pull_request_target` resolves the definition from the base instead. The `branches: [main]` filter is part of the rule, not a refinement of it: base resolution only relocates the rewrite from the head to the base, so without the filter a PR opened into an attacker-pushed base branch runs that branch's gate. `pull_request_target` is safe HERE only because this job never checks out or executes head-supplied code — it checks out `base.sha` and runs only that tree's scripts (`ci.shared-pr-file-enumeration`); reintroducing a head checkout under this trigger would be worse than the bug it fixed. This closes the rewrite route through THIS workflow and does NOT close the class: Gitea injects a write-capable `GITEA_TOKEN` into EVERY job, so any ref-resolved workflow — and a collaborator's own API token, since branch protection binds the context and not its issuer — can still forge `review-verdict/h10`. The credential half is now RESOLVED in `ci.actions-credential-scoping` (#697): CI's registry secret was the ADMIN account's basic auth and is now a PAT that cannot post a status, which removes the ADMIN escalation and that credential's route (a user credential's forgery carries a real `creator` and is inherited as a human verdict; an Actions job's carries `creator: null` and is re-derived — but do NOT read that asymmetry as protection: re-derivation fires only on the trigger's `types`, and posting a status is not one of them, so a POST timed after the last PR event simply stands). It does not remove EVERY route: `RENOVATE_TOKEN` is a `write:repository` bot PAT in the same secret store, reachable by any PR-added workflow. The injected token stays write-capable until Gitea >=1.26 with a Restricted default (server-management#714), and a collaborator's own token remains unfixable; the exemption path has its own separate defects in #698. | 2026-07-28 | [link](records/ci/gate-trigger-base-resolved.md) |
|
||||
@@ -65,7 +65,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `ci.verdict-write-retarget-fence` | The `review-verdict/h10` job counts `change_target_branch` events on the PR's issue timeline at run start and again immediately before its POST, and writes NOTHING if the count moved. The COUNT is the key because the branch NAME is ABA-vulnerable — `main -> S -> main` reads `main` at both ends, which is how #698 route 1 obtained a forged exemption — while the event count is monotonic and cannot alias. Abstaining is a handoff, not a stall, and that is the property the design rests on: every retarget fires `edited`, which is in this workflow's `types:`, so the event that makes a run abstain has already queued a successor whose window opens after it; the induction terminates when retargeting stops and the last run writes the final answer. `updated_at` was REJECTED as the key because it also moves for comments and labels, which fire none of this workflow's `types:` — a run could abstain with no successor coming, which is a real stall. The count is trusted only when paging reached a validated EMPTY page; an untrusted count (unreadable page, non-array body, non-numeric length, page cap hit) blocks the exemption `success` ONLY and still lets `pending` through, because `pending` cannot turn an unreviewed head green while withholding it would strand ordinary PRs for no safety gain. SEPARATELY, and for the human-verdict race the fence does nothing about: after posting an exemption `success` the job re-reads `/statuses/{sha}` and, if a human `Review-verdict:` row appeared with an id ABOVE a high-water mark taken just before the POST, overwrites its own status with `pending` and logs an error. The repair is `pending`, NEVER a copy of the human's state, since re-posting their `failure` under the machine credential would attribute a human verdict to the job; its description is a SENTINEL that the classification refuses to grant an exemption over AND re-writes verbatim on every later run, so the block is a FIXED POINT rather than decaying — writing the generic `pending` description there instead erases the marker and the exemption simply returns one event later. The mark is captured BEFORE the last-moment re-read, not merely before the POST — a later mark leaves a multi-round-trip blind gap in which a verdict is neither seen by the re-read nor repaired afterwards. The id comparison is load-bearing: a mere presence test would fire forever on a base-mismatched verdict that `read_existing_verdict` deliberately declines to honour, deadlocking that PR's exemption permanently. Finally, a run whose last-moment re-read finds a sentinel it did not see at its FIRST read ABSTAINS instead of posting: that can only mean an overlapping run repaired a raced verdict mid-flight, and this run's `success` — frozen at classification time, with the human row below its own mark, so neither the fence nor the post-write check would catch it — would otherwise bury the rejection. That is the one path in this design that failed toward SUCCESS rather than `pending`. The post-write check counts TWO row shapes above the mark, not one — a human `Review-verdict:` row AND a machine sentinel — because with two overlapping runs the human row can sit BELOW the second run's mark while the first masks it and only then writes the sentinel, leaving the second to post its own `success` on top; counting the sentinel converges both runs on the fixed point instead. | 2026-08-03 | [link](records/ci/verdict-write-retarget-fence.md) |
|
||||
| `ci.verify-locally-ci-confirms` | Treat the local build/verify/review pass as the decision point and CI as confirmation — don't idle waiting on a run you have no reason to doubt. | 2026-07-21 | [link](records/ci/verify-locally-ci-confirms.md) |
|
||||
| `ci.web-test-per-test-timeouts` | Give heavy-render web tests an explicit per-test vitest timeout (e.g. 15s); never raise the global default to fix one slow test. | 2026-07-21 | [link](records/ci/web-test-per-test-timeouts.md) |
|
||||
| `ci.workflow-run-body-no-expressions` | A `run:` body is not shell when the runner reads it: the runner scans the whole scalar for the expression opener and, on finding one, rewrites the ENTIRE body into a single `format(...)` call. That rewrite is all-or-nothing, so a payload that does not evaluate fails the interpolation of the whole scalar — and the runner then DROPS THE STEP AND CONCLUDES THE JOB `success`. A shell comment is therefore NOT inert. In `.gitea/workflows/review-verdict.yml` no expression delimiter may appear in ANY `run:` body, in code or in prose, because a dropped step there is a dead merge gate rather than a failed build; pass values in through the step's `env:` block, which is interpolated per value so a bad payload cannot take the body with it, and describe an expression in prose by NAMING it (`a github.event.pull_request.number expression`) rather than quoting the delimiters. Repo-wide the rule is weaker but covers the real class: every expression payload in every workflow field must name a context or function the runner can actually resolve, since a payload that cannot evaluate fails this same silent way in an `if:` too. Any workflow step whose non-execution is consequential must be paired with a start-marker guard that FAILS the job when the marker is absent, and that guard's own body must be expression-free — a guard the guarded mechanism can silently delete is worse than none. | 2026-08-06 | [link](records/ci/workflow-run-body-no-expressions.md) |
|
||||
| `ci.workflow-run-body-no-expressions` | A `run:` body is not shell when the runner reads it: the runner scans the whole scalar for the expression opener and, on finding one, rewrites the ENTIRE body into a single `format(...)` call. That rewrite is all-or-nothing, so a payload that does not evaluate fails the interpolation of the whole scalar — and the runner then DROPS THE STEP AND CONCLUDES THE JOB `success`. A shell comment is therefore NOT inert. In `.gitea/workflows/review-verdict.yml` no expression delimiter may appear in ANY `run:` body, in code or in prose, because a dropped step there is a dead merge gate rather than a failed build; pass values in through the step's `env:` block, which is interpolated per value so a bad payload cannot take the body with it, and describe an expression in prose by NAMING it (`a github.event.pull_request.number expression`) rather than quoting the delimiters. Repo-wide the rule is weaker and its reach must be stated precisely rather than generously: every expression payload in every workflow field must have a HEAD TOKEN naming a context or function the runner can resolve. That catches the defect above and a nonexistent context; it does NOT catch a syntactically invalid payload whose tokens are all known (`${{ github.ref == }}`), a renamed output (every token after the first is skipped), or an unclosed opener — those need an expression parser, and the guard is kept permissive on purpose because a red here blocks every merge through the combined status. In `review-verdict.yml` specifically, any step whose non-execution is consequential is paired with a start-marker guard that FAILS the job when the marker is absent, and that guard's own body must be expression-free — a guard the guarded mechanism can silently delete is worse than none. That pairing is NOT yet a repo-wide property: `docker-build.yml`'s `test` and `migrations` jobs are also required contexts and a dropped step there is fail-OPEN (the required check goes green having done no work), which is strictly worse than here; extending the guard to them is ersatztv#756, not something this record claims is done. | 2026-08-06 | [link](records/ci/workflow-run-body-no-expressions.md) |
|
||||
| `concurrency.diff-scalar-fanout` | The frozen Block optimistic-concurrency recipe (api-conventions §7a) fans out to Collection/Playout×2/MultiCollection/RerunCollection, keeping a guard-returned `PreconditionFailedError` out of any handler's generic `catch(Exception)`→422 mapping, and preserving each aggregate's existing `SaveChangesAsync() > 0` gate semantics under the new unconditional `Version++`. | 2026-07-11 | [link](records/concurrency/diff-scalar-fanout.md) |
|
||||
| `concurrency.etag-rotation-completion` | Every handler that mutates a versioned root's editor-visible config state must bump `Version` (rotating the ETag) with no per-aggregate carve-outs, short-circuiting on a genuine no-op before the bump so idempotent re-submits don't fire spurious rebuild fan-out; `SaveChangesForcingVersion` rebases the retry (stored + pending delta), never adopts the stored token verbatim. | 2026-07-12 | [link](records/concurrency/etag-rotation-completion.md) |
|
||||
| `concurrency.force-write-non-ifmatch` | Any handler that leaves a versioned root `Modified` or `Deleted` but takes no `If-Match` (deletes, item add/remove bumpers, scalar-config writers) must save through `ConcurrencyExtensions.SaveChangesForcingVersion` — force-write past a concurrent `Version` bump rather than throw an unhandled `DbUpdateConcurrencyException` (500). | 2026-07-12 | [link](records/concurrency/force-write-non-ifmatch.md) |
|
||||
|
||||
@@ -5,7 +5,7 @@ status: active
|
||||
since: '2026-07-29'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: '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 only for a status POSITIVELY identified as a human verdict for THIS base, meaning a non-null `.creator.login` 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 — 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 only the sub-round-trip window that no API without compare-and-set can close. The PROTECTED path list additionally 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. 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`.'
|
||||
rule: '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 only for a status POSITIVELY identified as a human verdict for THIS base, meaning a non-null `.creator.login` 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 — 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 only the sub-round-trip window that no API without compare-and-set can close. 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`.'
|
||||
signals: '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` · issues: #698, #697, #672, #663, #649, #632'
|
||||
mechanics: '`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; short-circuit requires `.creator.login` non-null AND description matching `^Review-verdict:`; `types: [opened, reopened, synchronize, ready_for_review, edited]`'
|
||||
---
|
||||
|
||||
@@ -5,7 +5,7 @@ status: active
|
||||
since: '2026-08-06'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'A `run:` body is not shell when the runner reads it: the runner scans the whole scalar for the expression opener and, on finding one, rewrites the ENTIRE body into a single `format(...)` call. That rewrite is all-or-nothing, so a payload that does not evaluate fails the interpolation of the whole scalar — and the runner then DROPS THE STEP AND CONCLUDES THE JOB `success`. A shell comment is therefore NOT inert. In `.gitea/workflows/review-verdict.yml` no expression delimiter may appear in ANY `run:` body, in code or in prose, because a dropped step there is a dead merge gate rather than a failed build; pass values in through the step''s `env:` block, which is interpolated per value so a bad payload cannot take the body with it, and describe an expression in prose by NAMING it (`a github.event.pull_request.number expression`) rather than quoting the delimiters. Repo-wide the rule is weaker but covers the real class: every expression payload in every workflow field must name a context or function the runner can actually resolve, since a payload that cannot evaluate fails this same silent way in an `if:` too. Any workflow step whose non-execution is consequential must be paired with a start-marker guard that FAILS the job when the marker is absent, and that guard''s own body must be expression-free — a guard the guarded mechanism can silently delete is worse than none.'
|
||||
rule: 'A `run:` body is not shell when the runner reads it: the runner scans the whole scalar for the expression opener and, on finding one, rewrites the ENTIRE body into a single `format(...)` call. That rewrite is all-or-nothing, so a payload that does not evaluate fails the interpolation of the whole scalar — and the runner then DROPS THE STEP AND CONCLUDES THE JOB `success`. A shell comment is therefore NOT inert. In `.gitea/workflows/review-verdict.yml` no expression delimiter may appear in ANY `run:` body, in code or in prose, because a dropped step there is a dead merge gate rather than a failed build; pass values in through the step''s `env:` block, which is interpolated per value so a bad payload cannot take the body with it, and describe an expression in prose by NAMING it (`a github.event.pull_request.number expression`) rather than quoting the delimiters. Repo-wide the rule is weaker and its reach must be stated precisely rather than generously: every expression payload in every workflow field must have a HEAD TOKEN naming a context or function the runner can resolve. That catches the defect above and a nonexistent context; it does NOT catch a syntactically invalid payload whose tokens are all known (`${{ github.ref == }}`), a renamed output (every token after the first is skipped), or an unclosed opener — those need an expression parser, and the guard is kept permissive on purpose because a red here blocks every merge through the combined status. In `review-verdict.yml` specifically, any step whose non-execution is consequential is paired with a start-marker guard that FAILS the job when the marker is absent, and that guard''s own body must be expression-free — a guard the guarded mechanism can silently delete is worse than none. That pairing is NOT yet a repo-wide property: `docker-build.yml`''s `test` and `migrations` jobs are also required contexts and a dropped step there is fail-OPEN (the required check goes green having done no work), which is strictly worse than here; extending the guard to them is ersatztv#756, not something this record claims is done.'
|
||||
signals: 'Unable to interpolate expression format(, step never ran but job green, missing Run Main step marker, review-verdict/h10 absent after a green run, docs-only PR unmergeable, Renovate PR unmergeable, exemption stopped working, expression in a shell comment, workflow comment changed behaviour · paths: `.gitea/workflows/review-verdict.yml`, `scripts/tests/test_pr_changed_files.py` · issues: #751, #706, #748'
|
||||
mechanics: '`RAN_MARKER` written at the top of the classify step and asserted by the `Assert the classifier actually executed` step (`if: always()`, expression-free body, `exit 1` on a missing marker); static guards `test_the_verdict_workflow_has_NO_expression_delimiter_in_any_run_body` (raw scalar, absolute, gate file only) and `test_every_workflow_expression_names_a_REAL_context_or_function` (repo-wide, allow-list of contexts/functions) plus `test_a_dropped_classify_step_FAILS_the_job_instead_of_going_green` (pins marker path agreement and guard ordering across ONE yaml parse)'
|
||||
---
|
||||
@@ -48,6 +48,26 @@ residual had described that universal behaviour as a narrow over-cap edge case.
|
||||
the same commit, which is the general lesson: a guard that has never executed has told you nothing, and
|
||||
merging it is not executing it.
|
||||
|
||||
**A THIRD instance of the same server behaviour was found by cold review of this fix**, and it is
|
||||
the reason to distrust "I fixed the two I could see". `GET /commits/{sha}/status` also returns
|
||||
`statuses: null` — not `[]` — for a head with no statuses yet (measured on PR #739's head 5fa672e2:
|
||||
`{"state":"pending","total_count":0,"statuses":null}`). `read_existing_verdict` gated on
|
||||
`.statuses | type == "array"` and took its `exit 1` path, posting nothing: fail-closed, but the same
|
||||
user-visible outcome again. Its double printed `{"statuses": []}` at all three no-verdict sites, so
|
||||
that branch was unreachable in the suite; correcting the double and restoring the old gate turns 40+
|
||||
tests red. `scripts/pr-changed-files.sh` was swept too and is unaffected — `pulls/{n}/files` returns
|
||||
`[]`. The generalisable rule is that a nil Go slice serialises to `null`, so EVERY list-shaped field
|
||||
on this API is suspect, and a per-endpoint measurement is the only way to know.
|
||||
|
||||
**Restoring the exemptions restores a hole that had been dead**, and this is worth saying rather than
|
||||
presenting the change as pure repair. `DOCS_ONLY` matched `CLAUDE.md` and `AGENTS.md`, the documents
|
||||
that define the completion protocol and the H10 rule itself — so those were auto-exemptible while
|
||||
`.claude/` was protected, which is the same self-exemption the workflow header rules out, one
|
||||
directory over. Reachable only because exemptions work again, hence fixed here (both added to
|
||||
`PROTECTED`; see `ci.exemption-provenance`). For the same reason, #706's known residual — the
|
||||
sub-round-trip ABA window, "narrowed and observable, not closed" — comes back with the working fence:
|
||||
while `rt_ok` was never `yes`, route 1 was closed by accident.
|
||||
|
||||
**Verified by mutation, not by a green suite.** All six mutations produce a red and the restored tree
|
||||
is green: reintroducing the exact defect (caught by both the strict and the general test), deleting
|
||||
the guard step, deleting only the marker write, weakening `if: always()`, turning the guard's
|
||||
|
||||
@@ -923,6 +923,11 @@ if "/timeline" in url:
|
||||
for part in url.split("?", 1)[-1].split("&"):
|
||||
if part.startswith("page="):
|
||||
page = int(part.split("=", 1)[1])
|
||||
if mode == "empty-first-page":
|
||||
# A terminator on PAGE 1 — no real page ever read (ersatztv#751). The job must NOT certify a
|
||||
# zero retarget count from this, so the exemption is withheld.
|
||||
print(os.environ.get("STUB_TIMELINE_TERMINATOR", "null"))
|
||||
sys.exit(0)
|
||||
if mode == "unreadable":
|
||||
print("<html>502 Bad Gateway</html>")
|
||||
sys.exit(0)
|
||||
@@ -1001,6 +1006,19 @@ if "/status" in url:
|
||||
# never-overwrite short-circuit unreachable: neither could be made to fire, so mutations
|
||||
# deleting them survived the whole suite.
|
||||
mode = os.environ.get("STUB_STATUS_MODE", "none")
|
||||
|
||||
# THE EMPTY SHAPE IS `statuses: null`, NOT `[]` (ersatztv#751). Measured on this instance: a head
|
||||
# with no statuses returns `{"state":"pending","total_count":0,"statuses":null}` — PR #739's head
|
||||
# 5fa672e2. This stub printed `{"statuses": []}` at all three no-verdict sites, which is a shape
|
||||
# the server does not produce for that case, so the job's `.statuses | type == "array"` gate was
|
||||
# never exercised against reality and its `exit 1` branch — posting nothing at all — was
|
||||
# unreachable in the suite. Same class as the timeline terminator, one function over.
|
||||
# Parameterised, not merely corrected: a head that HAS statuses really does return an array, so
|
||||
# both shapes are live and the job must read either.
|
||||
def empty_statuses():
|
||||
if os.environ.get("STUB_STATUS_EMPTY_SHAPE", "null") == "array":
|
||||
return json.dumps({"state": "pending", "total_count": 0, "statuses": []})
|
||||
return json.dumps({"state": "pending", "total_count": 0, "statuses": None})
|
||||
if mode.startswith("appears-on-read:"):
|
||||
# A human verdict that does NOT exist at the first read and DOES exist at the re-read made
|
||||
# immediately before the POST (ersatztv#706). Models a reviewer posting BLOCKED while the job
|
||||
@@ -1010,7 +1028,7 @@ if "/status" in url:
|
||||
n = int(ctr.read_text()) if ctr.exists() else 0
|
||||
ctr.write_text(str(n + 1))
|
||||
if n + 1 < nth:
|
||||
print(json.dumps({"statuses": []}))
|
||||
print(empty_statuses())
|
||||
sys.exit(0)
|
||||
print(json.dumps({"statuses": [
|
||||
{"context": "review-verdict/h10", "status": "failure",
|
||||
@@ -1025,7 +1043,7 @@ if "/status" in url:
|
||||
n = int(ctr.read_text()) if ctr.exists() else 0
|
||||
ctr.write_text(str(n + 1))
|
||||
if n + 1 < nth:
|
||||
print(json.dumps({"statuses": []})); sys.exit(0)
|
||||
print(empty_statuses()); sys.exit(0)
|
||||
print(json.dumps({"statuses": [
|
||||
{"context": "review-verdict/h10", "status": "pending", "creator": None,
|
||||
"description": "Human verdict raced this exemption write — re-post the verdict"}]}))
|
||||
@@ -1056,7 +1074,7 @@ if "/status" in url:
|
||||
"creator": ({"login": creator} if creator else None), "description": desc},
|
||||
{"context": "Functional E2E", "status": "pending"}]}))
|
||||
sys.exit(0)
|
||||
print(json.dumps({"statuses": []}))
|
||||
print(empty_statuses())
|
||||
sys.exit(0)
|
||||
|
||||
print("{}")
|
||||
@@ -1068,7 +1086,8 @@ def _run_classify(tmp_path, enum_stub: str | None, author: str = "timothy",
|
||||
status_creator: str | None = "timothy",
|
||||
status_desc: str = "Review-verdict: MERGEABLE @ a9e3e23 (base: main)",
|
||||
timeline_mode: str = "none", history_mode: str = "none",
|
||||
timeline_terminator: str = "null"):
|
||||
timeline_terminator: str = "null",
|
||||
status_empty_shape: str = "null"):
|
||||
"""Execute the workflow's classify `run:` block with a stubbed enumeration script.
|
||||
|
||||
Returns the status payload the job POSTed, or None if it posted nothing.
|
||||
@@ -1095,6 +1114,7 @@ def _run_classify(tmp_path, enum_stub: str | None, author: str = "timothy",
|
||||
env["STUB_STATUS_DESC"] = status_desc
|
||||
env["STUB_TIMELINE_MODE"] = timeline_mode
|
||||
env["STUB_TIMELINE_TERMINATOR"] = timeline_terminator
|
||||
env["STUB_STATUS_EMPTY_SHAPE"] = status_empty_shape
|
||||
env["STUB_HISTORY_MODE"] = history_mode
|
||||
env.update({
|
||||
"GITEA_TOKEN": "stub",
|
||||
@@ -2202,15 +2222,25 @@ def test_the_verdict_workflow_has_NO_expression_delimiter_in_any_run_body():
|
||||
"delimiters. Pass values in through the step's `env:` block, which is interpolated per "
|
||||
"value, so a bad payload there cannot take the body with it."
|
||||
)
|
||||
# Belt-and-braces on the parse above: a `run:` body the YAML walk failed to reach would make
|
||||
# this test vacuously green, which is the failure mode `eight ways a test passes for the wrong
|
||||
# reason` warns about. The gate file's expressions all live in `with:`/`env:` blocks, so the raw
|
||||
# count and the per-field count must agree exactly.
|
||||
in_fields = sum(1 for _ in _EXPR.finditer(_workflow_expression_fields_text(WORKFLOW)))
|
||||
assert len(list(_EXPR.finditer(raw))) == in_fields, (
|
||||
"review-verdict.yml contains expression delimiters that are not in a `with:`/`env:` value. "
|
||||
"The per-step scan above may not have reached them, so it cannot be trusted — locate them "
|
||||
"before relying on a green here")
|
||||
# ANTI-VACUITY, and it has to be the right property. A `run:` body the YAML walk never reached
|
||||
# would make the assertion above vacuously green — the failure mode to guard against. An earlier
|
||||
# version of this guard compared the file's TOTAL delimiter count against the count inside
|
||||
# `with:`/`env:` values, which is a different and wrong claim: it banned expressions everywhere
|
||||
# else in the file too. Both reviewers reproduced the false red — writing `if: ${{ always() }}`,
|
||||
# the standard and equivalent spelling of the `if:` two steps below, turned this test red, as did
|
||||
# a delimiter in an inert top-level YAML comment. Neither is unsafe, and a red here blocks every
|
||||
# merge through the combined status, so the guard was strictly more dangerous than the thing it
|
||||
# was protecting against.
|
||||
#
|
||||
# The honest anti-vacuity check is that the walk SAW every `run:` body: compare the number of
|
||||
# bodies the parse produced against the number of `run:` keys in the raw text. That fails if the
|
||||
# walk misses a step and says nothing about delimiters anywhere else.
|
||||
walked = sum(1 for _, s in _iter_workflow_steps(WORKFLOW) if s.get("run"))
|
||||
declared = len(re.findall(r"^\s{4,}run:\s*[|>]", raw, re.M))
|
||||
assert walked == declared, (
|
||||
f"the YAML walk reached {walked} run: bodies but the file declares {declared}. The assertion "
|
||||
"above may not have examined every body, so a green here proves nothing — find the step the "
|
||||
"walk missed before trusting this test")
|
||||
|
||||
|
||||
def _iter_workflow_steps(path: Path):
|
||||
@@ -2242,10 +2272,22 @@ def test_every_workflow_expression_names_a_REAL_context_or_function():
|
||||
context that does not exist. All of them fail the same silent way, and in an `if:` the
|
||||
consequence is the same shape as #751: the step does not run and nothing is red.
|
||||
|
||||
A full expression parser is not needed to catch it. Every payload the runner accepts begins its
|
||||
dotted paths at a known context and calls only known functions, so checking the head token of
|
||||
each path is enough. `pr number` — the real defect — fails here on `pr`. Verified against all
|
||||
31 distinct payloads in this repo today, which pass.
|
||||
BE PRECISE ABOUT WHAT THIS ENFORCES, because the first version of this docstring was not and
|
||||
both reviewers caught it: it checks that THE HEAD TOKEN of each dotted path is a known context or
|
||||
function. Nothing more. That catches the historical defect — `pr number` fails on `pr` — and a
|
||||
payload naming a context that does not exist. It does NOT catch:
|
||||
|
||||
* syntactically invalid expressions whose tokens are all known: `${{ github.ref == }}` and
|
||||
`${{ github.event.pull_request.head.sha + }}` both pass, verified;
|
||||
* a renamed or misspelled output or property, because every token after the first is preceded
|
||||
by `.` and is deliberately skipped: `steps.metadata.outputs.shortsha` passes;
|
||||
* an unclosed opener, since `_EXPR` requires the closing braces to match at all.
|
||||
|
||||
A real fix for those is an expression parser, which is a different and much larger change. This is
|
||||
a cheap net under the specific class that has bitten us, deliberately kept permissive so it cannot
|
||||
false-red the repo (a red here blocks every merge through the combined status). Verified against
|
||||
all 31 distinct payloads in this repo today, which pass. Do not restate this test as "catches any
|
||||
payload that cannot evaluate" — that claim was in the docs and the decision record and was false.
|
||||
"""
|
||||
offenders = []
|
||||
for wf in _workflow_files():
|
||||
@@ -2282,12 +2324,14 @@ def test_a_dropped_classify_step_FAILS_the_job_instead_of_going_green():
|
||||
(fail-closed, but it would look like this guard is broken rather than like a real drop), and a
|
||||
test carrying its own third copy of the literal could not see the divergence at all.
|
||||
|
||||
What this test does NOT establish, said plainly: that the runner still executes a LATER step
|
||||
after dropping an earlier one. Nothing in the #751 evidence answers that — the classifier was
|
||||
the last step in the job, so there was never a subsequent step to observe. Only the scratch-base
|
||||
probe can answer it, with a negative control that reintroduces a bad payload and must produce a
|
||||
RED job. If the runner drops the remaining steps too, this guard is inert and the body has to
|
||||
move into `scripts/`, where a one-line `run:` makes the class unreachable.
|
||||
The premise — that the runner still executes a LATER step after dropping an earlier one — is
|
||||
MEASURED, not assumed, and could not be settled from the #751 report because the classifier was
|
||||
the job's last step. Gitea 1.27.1, 2026-08-06: probe run 1863 dropped the classifier on a
|
||||
reintroduced bad payload, ran the guard anyway (`always()` evaluated true), and the job concluded
|
||||
`failure`; run 1866 is the positive control. So the guard is load-bearing in production and the
|
||||
body does not need to move into `scripts/`. This test pins the SHAPE; the probe pinned the
|
||||
BEHAVIOUR, and neither substitutes for the other — which is why the behavioural test below
|
||||
EXECUTES the guard body rather than only reading it.
|
||||
"""
|
||||
# ONE parse, and both steps located inside it. Using `_classify_step()` here instead was wrong
|
||||
# in a way worth recording, because it went green-adjacent rather than loud: that helper does its
|
||||
@@ -2322,12 +2366,26 @@ def test_a_dropped_classify_step_FAILS_the_job_instead_of_going_green():
|
||||
f"the dropped-step guard is step {guard_idx[0]} but the classifier is step "
|
||||
f"{classifiers[0]} — a guard that runs first always fails")
|
||||
guard = steps[guard_idx[0]]
|
||||
assert str(guard.get("if", "")).strip() == "always()", (
|
||||
f"the dropped-step guard's `if:` is {guard.get('if')!r}; it must be `always()`, or it will "
|
||||
"be skipped on exactly the runs where the classifier failed")
|
||||
assert re.search(r"exit\s+1", guard["run"]), (
|
||||
"the dropped-step guard never exits non-zero, so it observes the failure and still lets the "
|
||||
"job go green — which is the whole defect")
|
||||
# `always()` and `${{ always() }}` are the same condition; the runner accepts both and the second
|
||||
# is the more common spelling. Pinning the bare form EXACTLY would red the repo over a
|
||||
# semantically identical edit, so normalise instead. What must not change is that the guard runs
|
||||
# when the classifier failed.
|
||||
guard_if = re.sub(r"\s+", "", str(guard.get("if", "")))
|
||||
assert guard_if in ("always()", "${{always()}}"), (
|
||||
f"the dropped-step guard's `if:` is {guard.get('if')!r}; it must be `always()` (bare or "
|
||||
"wrapped), or it will be skipped on exactly the runs where the classifier failed")
|
||||
# INSIDE the missing-marker branch, not merely somewhere in the body. Cold review pointed out
|
||||
# that a bare `exit 1` substring is satisfied by an unreachable `if false; then exit 1; fi` while
|
||||
# the real branch says `exit 0` — the test passes and a dropped classifier goes green again. The
|
||||
# behavioural test below is the real proof; this keeps the structural one from being satisfiable
|
||||
# by dead code.
|
||||
missing_branch = re.search(r'if \[ ! -f "\$marker" \]; then(.*?)\bfi\b', guard["run"], re.S)
|
||||
assert missing_branch, (
|
||||
"the dropped-step guard no longer tests for a MISSING marker with `if [ ! -f \"$marker\" ]`, "
|
||||
"so the assertion below cannot locate the branch that must fail the job")
|
||||
assert re.search(r"exit\s+1", missing_branch.group(1)), (
|
||||
"the dropped-step guard detects the missing marker but does not `exit 1` inside that branch, "
|
||||
"so it observes the failure and still lets the job go green — which is the whole defect")
|
||||
assert not _EXPR.search(guard["run"]), (
|
||||
"the dropped-step guard's own run body contains an expression delimiter, so the mechanism "
|
||||
"it guards against can drop the guard too — and that absence would be silent as well")
|
||||
@@ -2367,3 +2425,129 @@ def test_the_fence_TRUSTS_the_count_and_POSTS_when_the_timeline_terminates(tmp_p
|
||||
assert "trusted=yes" in r.stdout, (
|
||||
"the exemption was posted but the fence did not report a trusted count — the two must agree, "
|
||||
f"or this test is passing for a different reason than it claims.\n{r.stdout[-800:]}")
|
||||
|
||||
|
||||
def _marker_steps():
|
||||
"""(classify prologue that CREATES the marker, guard body that CHECKS it) — from one parse."""
|
||||
steps = [s for _, s in _iter_workflow_steps(WORKFLOW)]
|
||||
idx = [i for i, s in enumerate(steps) if "review-verdict/h10" in (s.get("run") or "")]
|
||||
classify = steps[idx[0]]
|
||||
prologue = [ln for ln in classify["run"].splitlines()
|
||||
if re.match(r'\s*RAN_MARKER=', ln) or re.match(r'\s*:\s*>\s*"\$RAN_MARKER"', ln)]
|
||||
guard = next(s for i, s in enumerate(steps)
|
||||
if i != idx[0] and "h10-classifier-started" in (s.get("run") or ""))
|
||||
return "\n".join(ln.strip() for ln in prologue), guard["run"]
|
||||
|
||||
|
||||
def test_the_dropped_step_guard_BEHAVIOURALLY_fails_without_the_marker_and_passes_with_it(tmp_path):
|
||||
"""Executes the guard, instead of reading it — the structural test above cannot prove the exit
|
||||
code, and cold review was right that a bare `exit 1` substring is satisfiable by dead code.
|
||||
|
||||
The marker is created by running THE CLASSIFY STEP'S OWN two prologue lines under the same
|
||||
environment, never by rebuilding the path in Python. That is the point: it proves the two steps
|
||||
AGREE on the path by construction. A test that computed the name itself would keep passing after
|
||||
the two steps drifted apart, which is the one divergence that makes the guard fail on every run
|
||||
and get deleted as broken.
|
||||
|
||||
`GITHUB_RUN_ID`/`GITHUB_RUN_ATTEMPT` are set to fixed values, so this also covers the run-keying
|
||||
added after the live probes: if either step stopped interpolating them, the paths would differ and
|
||||
case B would fail.
|
||||
"""
|
||||
prologue, guard = _marker_steps()
|
||||
assert prologue.count("RAN_MARKER=") == 1 and ': > "$RAN_MARKER"' in prologue, (
|
||||
f"could not extract the classify step's marker prologue; got {prologue!r}")
|
||||
env = {"PATH": os.environ["PATH"], "RUNNER_TEMP": str(tmp_path),
|
||||
"GITHUB_RUN_ID": "424242", "GITHUB_RUN_ATTEMPT": "7"}
|
||||
|
||||
# A — the step was DROPPED: no marker exists. The job must fail.
|
||||
a = subprocess.run(["bash", "-c", guard], env=env, capture_output=True, text=True)
|
||||
assert a.returncode != 0, (
|
||||
"the guard exited 0 with NO start marker present — a dropped classify step would go green "
|
||||
f"again, which is the whole defect (ersatztv#751).\nstdout: {a.stdout}\nstderr: {a.stderr}")
|
||||
assert "did not execute" in (a.stdout + a.stderr), (
|
||||
f"the guard failed but without an actionable message: {a.stdout!r} {a.stderr!r}")
|
||||
assert not list(tmp_path.glob("h10-classifier-started*")), (
|
||||
"the guard itself created the marker it is supposed to be checking for")
|
||||
|
||||
# B — the classifier RAN: its own prologue created the marker. The guard must pass.
|
||||
b = subprocess.run(["bash", "-c", prologue + "\n" + guard], env=env,
|
||||
capture_output=True, text=True)
|
||||
assert b.returncode == 0, (
|
||||
"the guard rejected a marker written by the classify step's OWN prologue — the two steps "
|
||||
f"disagree on the path, so this guard would fail on every run.\nstdout: {b.stdout}\n"
|
||||
f"stderr: {b.stderr}")
|
||||
written = [q.name for q in tmp_path.glob("h10-classifier-started*")]
|
||||
assert written == ["h10-classifier-started-424242-7"], (
|
||||
f"the marker is not keyed on the run id/attempt as intended; found {written}. A fixed name in "
|
||||
"a shared RUNNER_TEMP lets a stale marker satisfy this guard on a run whose step was dropped")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", ["null", "array"],
|
||||
ids=["statuses-null", "statuses-empty-array"])
|
||||
def test_a_head_with_NO_statuses_YET_is_readable_and_still_gets_its_exemption(tmp_path, shape):
|
||||
"""The twin of the timeline terminator, found by cold review of the fix for that one (#751).
|
||||
|
||||
`GET /commits/{sha}/status` returns `{"state":"pending","total_count":0,"statuses":null}` for a
|
||||
head that has no statuses yet — measured on PR #739's head 5fa672e2. `read_existing_verdict`
|
||||
gated on `.statuses | type == "array"`, so it read that as unreadable and took its `exit 1`
|
||||
path: the job posted NOTHING. Fail-closed, but the user-visible result is exactly the outcome
|
||||
this issue is about — an exempt PR with no status and, since #743, no bypass.
|
||||
|
||||
Both shapes are asserted because a head that HAS statuses really does return an array, so the job
|
||||
must read either. Restoring the `array`-only gate turns 40+ tests red with the corrected double,
|
||||
which is the measure of how thoroughly the unfaithful stub was hiding this.
|
||||
"""
|
||||
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), status_empty_shape=shape)
|
||||
assert r.returncode == 0, (
|
||||
f"the classifier died reading a `statuses: {shape}` body instead of treating it as "
|
||||
f"'no verdict yet', so nothing was posted at all.\n{r.stdout[-1000:]}\n{r.stderr[-600:]}")
|
||||
assert posted is not None, (
|
||||
f"a docs-only PR whose head has no statuses yet (shape: {shape}) got NO status at "
|
||||
f"all.\n{r.stdout[-1000:]}")
|
||||
assert posted["state"] == "success", f"expected the docs-only exemption, got {posted}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["CLAUDE.md", "AGENTS.md"])
|
||||
def test_the_GOVERNANCE_docs_are_protected_and_get_no_docs_only_exemption(tmp_path, path):
|
||||
"""`CLAUDE.md`/`AGENTS.md` are not prose about the project — they define the completion protocol,
|
||||
the merge-consent convention and the H10 rule itself. `DOCS_ONLY` (`^(docs/|[^/]*\\.md$)`) matched
|
||||
them, so a PR editing the document that specifies what `.claude/` enforces was auto-exemptible
|
||||
while `.claude/` itself was protected: the same self-exemption the workflow header rules out, one
|
||||
directory over. Found by cold review of #751 by driving this exact case through the real body.
|
||||
|
||||
Latent until #751, because no exemption `success` was writable at all while the classify step was
|
||||
dropped — restoring the exemptions is what makes it reachable, which is why it is fixed there.
|
||||
|
||||
Asserted through the PROTECTED branch specifically, not merely "not exempt": `pending` is reached
|
||||
by several routes and a test that accepted any of them could not tell a working guard from a dead
|
||||
one.
|
||||
"""
|
||||
posted, r = _run_classify(tmp_path, _emitting(path))
|
||||
assert posted is not None, f"the job posted nothing: {r.stderr[-1500:]}"
|
||||
assert posted["state"] == "pending", (
|
||||
f"{path} was granted an exemption ({posted}) — the document that DEFINES the merge gate must "
|
||||
f"not be able to exempt itself from it.\n{r.stdout[-800:]}")
|
||||
assert "protected" in r.stdout.lower(), (
|
||||
f"{path} was not exempted, but not via the protected-path branch either, so that guard may be "
|
||||
f"dead for it.\n{r.stdout[-800:]}")
|
||||
|
||||
|
||||
def test_a_terminator_on_PAGE_ONE_does_not_certify_a_zero_retarget_count(tmp_path):
|
||||
"""`null` means "exhausted" only after a real page has been read.
|
||||
|
||||
Every real PR's first page carries events — measured 4, 2, 9, 5, 10 on #752/#753/#749/#739/#717 —
|
||||
so a terminator on page 1 is anomalous, not empty. Trusting a zero count from it would mean
|
||||
certifying that no retarget happened on the strength of a response we cannot explain, which is the
|
||||
one thing the fence exists to refuse. Withholding the exemption is the safe direction: the PR asks
|
||||
for a human verdict instead.
|
||||
|
||||
This narrows rather than closes the concern — a wrong `null` on page 3 is still read as exhaustion,
|
||||
and no bounded number of round-trips can rule that out.
|
||||
"""
|
||||
posted, r = _run_classify(tmp_path, _emitting("docs/a.md"), timeline_mode="empty-first-page")
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert posted is None, (
|
||||
"an exemption `success` was posted from a timeline whose FIRST page was already the "
|
||||
f"terminator, so no page of events was ever actually read: {posted}\n{r.stdout[-800:]}")
|
||||
assert "trusted=no" in r.stdout, (
|
||||
f"the exemption was withheld, but not because the count was untrusted:\n{r.stdout[-800:]}")
|
||||
|
||||
Reference in New Issue
Block a user