Compare commits

...
Author SHA1 Message Date
timothy 4c44a13e3d docs(release): record the v26.15.0 release notes
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 14s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 15s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 23s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 12s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 9s
review-verdict/h10 Exempt: docs-only change (no code, no protected path)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Review verdict / Set review-verdict status (pull_request_target) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m1s
Adds the `v26.15.0` row to the release table in `docs/ci-cd.md`.

The tag goes on `736649b3b`, NOT on this commit and not on `main`'s head. Every
`Build & push image (amd64)` since `e8f80c42c` fails: that commit added
`web/src/api/completeAnnotations.guard.test.ts`, a third importer of
`virtual:etv-tracked-source-files`, without adding it to the hand-maintained
`--exclude` list in the Dockerfile's `web-build` stage — and that stage has no
git index, by construction (#887, claimed and in progress elsewhere). The guard
is behaving correctly; it refuses to fall back to a filesystem walk. Measured:
run 2459 on `736649b3b` ran the image job for 6m45s and published; run 2515 on
`cf5f42edf` died in web-build after 86s. `736649b3b` is therefore the newest
commit on `main` that can produce a release image.

Consequence recorded in the row itself: #880 (scheduling recurrence) slips to
the next release, since it merged after the break.

Release-boundary sweep (docs/ci-cd.md -> "Before cutting a release"):
- `decisions_validate.py` -> OK; 0 legacy-unmigrated records remain
- `build_decisions_catalog.py` -> no drift
- record ceiling: 45/218 over 60 lines (fraction 0.21, inside the blocking
  0.02-0.25 band). The validator notes the 60 has drifted below the tail
  boundary (p90=104, p95=142) and asks for re-derivation when convenient —
  a maintenance signal about the constant, not a blocker for this cut.
2026-08-30 16:56:17 +02:00
timothy cf5f42edf9 fix(858,859): a rule the classifier cannot read is not a rule that matches nothing (#897)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 10m49s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m41s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 7m18s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m26s
#859 was filed as a wrong STATED CAUSE. It was masking a live false-open in the merge gate.

Gitea reports a GLOB branch-protection rule with an EMPTY `branch_name` — the canonical
name lives only in `rule_name`. Measured 2026-08-30 on a scratch repo against 1.27.1.
jq's `//` fires on null and false but NOT on `""`, so `(.branch_name // .rule_name // "")`
resolved every glob rule to the empty string — a name with no metacharacters — and the
glob test, the entire basis of the classifier's undecidable-first ordering, never saw it.

Measured on the predecessor: glob `m*` (not requiring review-verdict/h10) beside plain
`main` (requiring it) resolved to `exact` on `main` and AUTO-GRANTED a scheduled merge,
while Gitea — ordering by Priority then plain-name-ness — may be applying `m*`. That is
#622's hole, reached through the ordering written to close it. Mirror case: a glob alone
resolved to `none` and DENIED about a rule that provably governs the base.

A name is now a non-empty string. Each field resolves to a NAME, a SKIP (absent/null/
empty — fall through), or POISON (present, wrong type — poisons whichever field carries
it). A rule with no usable name is a distinct `unreadable` verdict with its own operator
cause, instead of feeding `none`, whose whole authority is "the full rule list was read
and none matches". The short-circuit is STRUCTURAL: jq binds `as` eagerly, so the flat
form still evaluated `offs`/`nonascii` on the bad name and died before reaching the arm
meant to prevent that.

Also #859: `branch_protections` is fetched ONCE per run, not twice. The round trip is the
smaller half — it is mutable config, so two reads can disagree and the two arms then
decide about different repo states with neither able to notice.

#858: `verdict_script` resolves from `$repo_root`, not `$CLAUDE_PROJECT_DIR`. And the
finding that mattered more — `ETV_HOOK_FIRE_LIB` is `. `-SOURCED, so it is CODE running
before stdin is read and before `decide` exists. A first draft exempted it as "telemetry,
not a predicate"; cold review refuted that by execution: a decoy hook-fire-log.sh in an
env-var-named tree printing an allow and exiting 0 GRANTS THE MERGE, bypassing every
check. Classify a path by how it is CONSUMED, never by what it is called. This hook's copy
is self-located; the other twelve are #891 (high/security), which records the reachable
case — husky launches the prepush hooks by RELATIVE path, so the two roots diverge there.

check-required-contexts.sh gains an array-type gate (a JSON object previously printed
`nomatch`, a positive claim about server config from a body it cannot consume).

Verification: 1377 passed / 2 skipped; 11 declared mutants, 11 detected, disjoint
reddened sets; classifier executed across jq 1.8.2 and 1.6 with identical results; both
env-var tests ship a negative control, because the passing outcome is also what an inert
decoy produces.

Four cold review rounds plus a bounded prose check. Every round found defects the
previous round's fixes introduced — a type conflation that re-opened the auto-grant, a
comment asserting the opposite of the line its own commit changed, and a corrected
sentence whose identical twin survived in the same diff.

Docs: new record `process.hook-resolves-inputs-from-repo-root`; both inline sites cite it
rather than arguing it twice. docs/remote-state-inventory.md's row for the second read
updated. Follow-ups filed: #891 (the other 12 hooks), #895 ("all N tests green" claims).

fixes #858
fixes #859

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-30 11:33:50 +00:00
timothyandClaude Opus 5 (1M context) &lt;noreply@anthropic.com&gt; 0e40ac283b fix(870): an empty timeline page is not exhaustion — the walk reads to its cap (#896)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 5s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 10m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m56s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 7m2s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m35s
`count_pr_mutations` treated an empty page past page 1 as proof it had reached the end of the PR
timeline. Gitea does not mean that: `ListIssueCommentsAndTimeline` applies the LIMIT/OFFSET in
`FindComments` at the DATABASE level and filters AFTERWARDS, dropping `CommentTypeCode` rows and
inaccessible cross-references into a nil slice that serializes as bare `null`. A page of 50 inline
review comments is byte-identical to a page past the end while later pages still hold events, and
rows are ASCENDING, so the events a fence looks for are the furthest from page 1. Fifty comments,
which a PR author can create on their own PR, truncated both walks at the same place: both counts
agreed, the sha comparison agreed, and an ABA force-push yielded an exemption `success` over a diff
no single head justified.

The walk no longer infers the end from an empty page BEFORE its cap. Such a page is skipped; the
loop reads every page to its 20-page cap and trusts the counts only when the LAST page came back
empty. An empty FIRST page and any unreadable shape still end the walk untrusted.

NARROWED, NOT CLOSED, and the docs say so in one unit: the page-20 terminator is still trusted for
the same unprovable reason, so the defeat now costs a timeline of over 1000 rows rather than ~100,
with the same 50-row filtered block pinned to offsets 950..999.

Measured at Gitea 1.27.1, ruling out the cheaper fixes: `X-Total-Count` on this endpoint is the
post-filter length of the PAGE, not a total (`?limit=1` returns 1 on a 14-row timeline), while
`/activities/feeds` returns a true total; `limit` clamps to 50; the only query params are `since`,
`before`, `page`, `limit`, so the paged and serialized sets cannot be made to agree.

Also: each page bounded `--connect-timeout 5 --max-time 15` and retried once, mirroring
`page_statuses`, because the walk went from ~2 requests to a fixed 20 and the third call site runs
after the exemption `success` is posted. Costs stated rather than hidden — worst case 40 requests
and 20 sleeps, wall-clock pessimum 620s per walk, and the suite roughly doubled (202s -> 474s).

Seven tests, each mutation-witnessed red; three reproduce the defeat against the shipped predecessor.
Two independent cold reviews plus a re-review of the fix: no Blocker or High in the code. Their real
finding was prose claiming the hole was closed, and cost arithmetic wrong twice. One reviewer claim
was refuted by execution.

Fixes #870

Refs: #803, #706, #664, #751, #893
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) &lt;noreply@anthropic.com&gt;
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-30 11:29:38 +00:00
timothyandClaude Opus 5 528383cf3a fix(880): an absent recurrence array means unrestricted, an explicit [] is rejected (#892)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 10m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 7m34s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m59s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m27s
The three recurrence arrays are read CONJUNCTIVELY by
AlternateScheduleSelector.GetScheduleForDate, so an empty set matches no date.
`?? []` on an omitted array therefore returned HTTP 200 while storing an
alternate-schedule or template item that could never apply, silently -- while the
read side (#823) already read a NULL column as the All*() sets.

Absent and explicitly-empty are two different requests and get two answers:
ABSENT (missing, or explicit null) normalizes to AlternateScheduleSelector.All*(),
the same symbols the read side substitutes; EXPLICIT [] is rejected with a 422
naming the consequence, via RecurrenceSetBounds called from both replace handlers.

The rejection lives in the handlers, not the controller, because
api.ffmpeg-profile-numeric-bounds' "accept an UNCHANGED bad value" rule binds
hardest here: both PUT paths are whole-list replaces, so rejecting a pre-existing
empty set would make every OTHER item in the list uneditable. That comparison
needs the stored row. The validated set is derived from `incoming`, so the
highest-Index catch-all -- whose recurrence the handler discards -- is excluded by
construction.

Verified: full ErsatzTV.Tests suite green; three mutation proofs with disjoint
reddened sets; live-E2E against a real instance confirmed an OMITTED property
round-trips as unrestricted (the Newtonsoft missing-property chain unit tests
cannot reach), an explicit [] returns the 422, and [] on the catch-all is accepted.
Cross-family cold review BLOCKED the first implementation with 3 findings, all real
and all fixed; re-review returned MERGEABLE.

Follow-up #894 filed: the SPA can still build the empty state the server rejects.

fixes #880

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 09:29:02 +00:00
timothy 58681b3a79 fix(849): the verdict gate replaces every unknown state, and proves the clauses that claim to (#890)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 13s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 11s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m16s
2026-08-30 05:55:09 +00:00
timothyandClaude Opus 5 d4b36ac232 fix(849): restore 13 proofs round 9 deleted by accident, and one comment that argued both sides
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 22s
Review verdict / Set review-verdict status (pull_request_target) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m45s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 11m40s
review-verdict/h10 Review-verdict: MERGEABLE @ d4b36ac (base: main)
The round-9 cross-family review found no Blockers and no Highs, and independently confirmed
the clause deletion it was asked to check. What it did find is that round 9 removed FIFTEEN
test definitions and added four — a net loss of eleven — where the commit message claimed
two. Verified against the parent: 227 definitions before, 216 after.

The cause is mechanical and worth naming, because it produces a green suite: the round-9
edits replaced whole source RANGES (`s[:start] + new + s[end:]`) whose end anchor was the
next test rather than the end of the one being rewritten, so everything in between went with
it. The suite then passed because the tests were GONE, not because the code was right — the
exact shape this issue exists to prevent, reproduced in its own test file.

Among the casualties were round 4's proofs for two earlier BLOCKERS:

- `test_a_generic_PENDING_with_no_mark_also_becomes_the_sentinel` and its mutation, which
  pin the no-mark downgrade covering every re-derivable write rather than only `success`;
- `test_a_MALFORMED_creator_FIELD_...` and its mutation, which pin a wrong-typed field
  taking the fault route rather than reading as absent and licensing a re-derive.

Also lost: both `$own`-exclusion proofs, the no-op-repair skip proof, the id-asymmetry pair
(the reviewer's named example), and two write-failure propagation proofs.

All 13 unintended deletions are restored verbatim from the parent commit and ALL PASS against
round 9's code, so nothing had regressed — the harm was the missing evidence, not the
behaviour. The two deletions that WERE intended stay deleted: a test superseded by
`..._still_refuses`, and the positive control round 9 inverted.

Prose: the comment above the unreadable-element guard still argued a malformed neighbour is
safe noise once the target row was found, eleven lines above code that now refuses
unconditionally — two adjacent blocks giving opposite accounts of one rule, and the stale one
licenses reinstating the Blocker.

Refs: #849
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 07:10:21 +02:00
timothyandClaude Opus 5 c2c70e50ad fix(849): round 9 — sentinel TEXT is not sentinel STATE, and an unreadable neighbour is not noise
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 16s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 19s
PR Gates / Docs update reminder (pull_request) Successful in 22s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
review-verdict/h10 Awaiting review verdict for c2c70e5
Review verdict / Set review-verdict status (pull_request_target) Successful in 13s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 14m56s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 15m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m8s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 9m36s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 9s
The round-8 cross-family review found two more Blockers. Both are cases where a principle
this branch had already established was applied in one place and not the adjacent one.

## Sentinel text is not sentinel state

`ex_repair` and `ex_unverified` were set from the DESCRIPTION alone. A `success` carrying
`$REPAIR_DESC` verbatim — from a machine or an off-list account — therefore read as a
sentinel: the mid-run guard exited on it, and the mark's already-there test matched it and
returned without POSTing. A green stood on an unreviewed head, on a first-push event with no
successor guaranteed.

This is the same reasoning that removed the "this job's own output" exclusion one round
earlier: a description is not provenance. It is not state either. Both sentinels this job
writes are `pending` by construction, so requiring it costs nothing.

## An unreadable neighbour cannot be shown to be unrelated

Round 8 refused only when NO readable target row was found, reasoning that a malformed row
beside a good one is noise. An element whose `.context` cannot be read cannot be shown to be
a DIFFERENT context — so it may be a mangled rendering of this head's own rejection, and the
one-row-per-context invariant that would rule that out is exactly what a schema-corrupt
response has already broken. The branch's own POSITIVE CONTROL encoded the failing case: a
scalar beside an off-list `success`, which this branch re-derived and greened where
`origin/main` errored on the scalar and posted nothing. That test is inverted, not adjusted.

The cost is a stall on any head carrying a malformed element — the correct direction for a
required check, since it withholds a green rather than granting one.

## Two clauses deleted rather than proved

Chasing a proof for the mark's repair promotion showed its three clauses were MUTUALLY
REDUNDANT: each alone produces the outcome, so no single-clause mutation could show harm.
Tracing why revealed that two are unreachable as a sole cause — a repair sentinel at the
first read sets `ex_repair`, which forces `desc="$REPAIR_DESC"`, and one arriving mid-run is
caught by the sentinel guard unless this run is itself writing that string. So they are
redundant rather than unprovable, and they are gone. One clause, one mechanism, one proof.

refs #849

Refs: #849
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 06:05:02 +02:00
timothyandClaude Opus 5 879208d554 docs(849): re-apply the CLAUDE.md correction the rebase resolution dropped
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 10s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 17s
PR Gates / Docs update reminder (pull_request) Successful in 21s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 27s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 13s
review-verdict/h10 Awaiting review verdict for 879208d
Review verdict / Set review-verdict status (pull_request_target) Successful in 13s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 11m43s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 12m49s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 8m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Failing after 15m0s
The rebase onto #889 resolved a CLAUDE.md hunk in favour of upstream, which kept #845's new
clause and discarded #849's — leaving the file asserting that a rejection landing inside a
run's own write window is "a separate and still-open route". Both edits belong: they touch
one sentence for different reasons.

This message also repairs the TRAILER BLOCK for the whole branch, which CI caught and local
runs did not. Every commit here ended:

    refs #849
    Decisions-Edit: yes

    Co-Authored-By: ...

Git parses only the LAST paragraph as trailers, so the blank line put `Decisions-Edit: yes`
in the second-to-last one and it was never a trailer at all — `git log --format=%(trailers)`
showed only the Co-Authored-By pair. `refs #849` without a colon disqualifies that paragraph
independently. `decisions_validate.py` arms its rationale-prose exemption from ANY non-merge
commit in the range, so one correctly-formed block repairs all nine.

Refs: #849
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 05:27:30 +02:00
timothyandClaude Opus 5 691a14d039 fix(849): round 8 — a green main does not post, and two round-7 fixes that overshot
The first cross-family review in five rounds (Codex/GPT-5.6, once its quota reset). It found
a Blocker four same-family rounds had missed, and REVERSED two of round 7's fixes — which is
the more useful result, because both were made in response to a review and both overshot in
the direction the finding pointed.

## The Blocker: dropping unreadable elements became "no verdict exists"

Round 3 added `select(type == "object")` so a malformed NEIGHBOUR could not kill the step.
When it drops EVERY element, `first // {}` yields `{}`, all `ex_*` read empty, and the job
concludes no verdict exists — so a docs-only PR walks straight to the exemption. Measured:
`{"total_count":1,"statuses":[7]}` posts `Exempt: docs-only change` here and posted NOTHING
on `origin/main`, which raised jq error 5 and aborted under `set -e` before any write. An
input on which this branch greens a head that `main` fails closed on, and if that scalar is a
mangled rendering of the head's human `failure`, the rejection is what gets greened.

The asymmetry is now the rule: a malformed row BESIDE one we did read is noise; a malformed
row where we found NOTHING is the only evidence there was. The absence conclusion has to be
earned over a list with no unreadable elements in it.

## Two round-7 fixes that overshot

- **The arms judged both snapshots.** Round 6's review said they judged `$pre_*` while the
  POST replaces `$ex_*`; I made both veto, which is the mirror defect — an opening row since
  REPLACED by a machine `success` still vetoed, so the arm left that success gating the head.
  They judge the current row alone now. The opening snapshot keeps exactly one job: it can
  make the write STRONGER, never suppress it.
- **The "this job's own output" exclusion keyed on the DESCRIPTION.** A description is not
  provenance. Any workflow with `code: write` can POST a `creator: null` row and any
  repository writer can POST one with a creator, either wearing this job's text — so masking
  a human `failure` with a lookalike `pending` bought an abstention, and the successor
  re-derived it as ordinary machine output with the rejection below its own mark. Removed;
  the attempt is recorded because it is the tempting one, and there is no issuer field that
  could make it safe.

## A guard that could not be reached, folded into the one that can

The repair veto turned out unreachable: an `$ex_desc` of `$REPAIR_DESC` with a different
`$desc` is caught by the mid-run sentinel guard long before an arm runs, and when `$desc` IS
`$REPAIR_DESC` the promotion writes the same string. Rather than keep a guard no fixture can
reach — or delete it on the strength of a check three hundred lines away — the invariant is
enforced where it is local and provable: the mark carries the strongest description any
snapshot shows, then declines to write what is already there.

`ci.exemption-provenance` still called the post-final-count window a PERMANENT forged green
in its `rule:` frontmatter and body; the post-POST re-count made it transient two rounds ago.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 05:13:29 +02:00
timothyandClaude Opus 5 72cca9a280 fix(849): round 7 — the arms judged the wrong snapshot, and two comments that invited a bug
A fourth cold review of the tip. No Blockers, no High: it enumerated every POST site and
every exit and could not construct an input where this branch writes a `success` that
`origin/main` would not.

## The arms judged the wrong snapshot

`mark_declined_row_if_any`'s three refusals all read `$pre_*` — the FIRST read — while the
POST replaces whatever row is CURRENT. So a reviewer's verdict arriving between the two
reads slipped past every refusal written to protect it: the base mismatch clears
`ex_attributable` so the mid-run abstain declines, `pre_creator` is empty so the allow-list
loop declines, and the arm marks a row nobody evaluated. Executed trace, control and case.
Both snapshots are consulted now, and either one vetoes.

Recovery was not free, which is why it mattered: the next run's reconciliation counts that
`Review-verdict:` row as buried and upgrades to the human-only sentinel — exactly the cost
the refusal exists to avoid.

The arm also marked this job's OWN ordinary machine `pending`. Every PR past its first run
carries one, so "kept off the commonest path in this job" was true only of a head with no
status at all. Scoped on the DESCRIPTION rather than on `creator: null`, which would also
exclude a machine `success` from another workflow — the row this marking exists for.

## Two comments that invited a bug

- One still described the round-4 REGRESSION as the intended behaviour ("a malformed row
  reads as no creator, hence re-derived"), two lines below the block recording that it was
  fixed. Adjacent comments giving contradictory accounts of one line, and the stale one
  licenses reinstating it.
- The fault token's justification said "no Gitea status field contains a NUL". The token is
  SOH (0x01). That is not pedantry: `$'\000…'` is the EMPTY STRING in bash, so an editor
  correcting the code to match the comment would make every legitimately-absent field
  compare equal to the token and send every clean head down the fail-closed route — the gate
  would stall every PR.

## Docs

The record quoted a predicate that no longer exists (`[ "$ex_desc" != "$pre_desc" ]`, now
`$row_replaced`); `docs/ci-cd.md` stated the reconciliation witness unconditionally when the
code degrades to a description match where the server omits `id`; one of the six unproven
clauses carried a wrong `because` (the conclusion holds via `(.id | numbers) // -1` over a
validated array, not via the schema-fault route, which governs a different endpoint's row);
and the record's own counts read as a contradiction cold — 20 surviving MUTANTS collapse
onto 6 distinct CLAUSES, several clauses admitting more than one disarming edit. The
run-by-run provenance moved to the issue, where `docs.no-session-narrative` says it belongs.

Two existing mutation proofs lost their binding to the reworded clauses and failed loudly
rather than measuring the unmutated body, which is what that count assertion is for. Rebound.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 05:13:29 +02:00
timothyandClaude Opus 5 6fdf48de2d docs(849): drop a duplicated clause from the record, and record the sweep's yield
The `mechanics:` field ended with the same sentence twice — the enumeration of the
unreachable clauses was appended without removing the tail it replaced. Found by the
mutation-sweep agent while reading the record it was checking its own results against.

In its place, the number that makes the technique worth its cost: 60 mutants, 40 red, 20
survivors, on a tree that had already been through three per-finding review rounds by two
model families.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 05:13:29 +02:00
timothyandClaude Opus 5 bbaf1d76f8 fix(849): round 5+6 — the survivors a mutation SWEEP found, and the six that cannot be reached
Codex was unavailable for this round (usage quota), so the cross-family reviewer was
replaced by a same-family agent doing one mechanical job: enumerate every security-bearing
clause the diff adds, disarm each, and run the WHOLE suite per mutant. 60 mutants, 40 red,
20 survivors — a yield no per-finding review in this series came close to, because a review
looks at what the diff says it does and a sweep looks at what the tests actually pin.

## Proved (nine)

- the description type test in the RECONCILIATION `buried` filter — exact twin of the
  post-write one, which had a proof; without it a numeric description hard-errors
  `startswith`, the count comes back unusable, and the genuine verdict on the next row is
  lost with it;
- the `.status` / `.description` / `.id` type tests, parametrised over all four consumed
  fields so a fifth cannot be added without a case (`.creator`'s was the only one proved);
- both retry loops — the combined read and `repair_status_to`'s second POST. Against a stub
  that fails EVERY attempt a retrying reader and a one-shot reader are indistinguishable,
  which is how a retry ships unexercised; the fixtures now fail only the first attempt;
- the mid-run guard's self-exemption, which is what stops a sentinel-writing run abstaining
  on the row it was about to replace with an equivalent one;
- both repair-write failure paths (the repair and the post-POST replacement), reachable only
  with a stub that lets the FIRST post through and fails the rest — with every post failing
  the job dies on its own classification write and never reaches them;
- the two `state=pending` updates after a repair. The first is load-bearing beyond tidiness:
  without it a repaired head re-enters the post-POST check and, on a retarget it then
  observes, replaces `$REPAIR_DESC` with the weaker reconcilable sentinel — the same ordering
  inversion the floor beside it exists to prevent, reached by another route.

## Declared unreachable (six), enumerated rather than counted

The path-predicate failure branch; the empty-`row` refusal; page 2's non-numeric length; the
`$witness` normalisation; and the two unusable-count arms. Each is defence in depth behind a
filter that makes its input well-formed for every case a fixture can pose — the same standing
exception the post-write unusable-count arm already carried.

That set has gone two -> five -> six across three rounds as the sweep widened. Naming them is
the point: an inventory that undercounts reads as a checked claim and talks the next reader
out of verifying, which is the same defect as inventing coverage — and this branch has
already had to correct that twice.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 05:13:29 +02:00
timothyandClaude Opus 5 28e82fcb59 fix(849): round 4 — a regression round 3 introduced, and the clauses it left unproven
A third cold review, which ran the mutants itself, found one measured direction regression
against `origin/main`, one ordering inversion, and four clauses this branch claims as
fixes that survived mutation of their own text.

## The regression

Round 3 type-tested the four consumed fields of the existing `h10` row and resolved a
failure to `""`. For `.creator` that means "no creator" — unattributable — which is a
LICENCE TO RE-DERIVE. Measured, same fixture, both bodies: a head carrying
`h10=failure` with `"creator": 7` posts `Exempt: docs-only change` here and posted NOTHING
on `main`, which died on `.creator.login` before any write. Fail-closed became fail-open.

The rationale that produced it came from #763, whose site is the POST-WRITE filter: there,
dying leaves a green already published, so dropping the row is the safe direction. Here the
alternative is dying BEFORE any write. The deferral rationale did not transfer — which is
the shape this repo has a record for.

A wrong TYPE is now distinguished from a legitimately ABSENT value: `null` is the machine
creator, an unset description and every field of the `{}` no-verdict row; anything else is
unknown state and takes the route an unreadable ELEMENT already took.

## The ordering inversion

`mark_declined_row_if_any` was scoped to "the head carries any row", so it fired on a head
carrying `$REPAIR_DESC` and replaced the human-only marker with the machine-clearable one —
inverting the ordering the SAME commit added a floor to protect at the repair site. One
mechanism, three writers, and only two had the rule.

It also buried a verdict an ALLOW-LISTED reviewer wrote for another base. "Declined" is
decided against this event's `$BASE_REF`, so such a row is still the right answer for the
base it names and the successor run for that base short-circuits on it; burying it costs a
manual re-post on an ordinary retarget-onto-the-reviewed-base flow. Membership is tested on
the raw creator, not on `ex_human`, which the base check has already cleared — the question
is who wrote the row, not whether it governs this diff.

## The unproven clauses

Four claims survived mutation, including the headline one. The witness fixture had been
designed AROUND its own discriminator — its comment said a seed with an unrelated id "would
make this run carry the sentinel forward … and the guard under test would never be reached",
which is a description of the test not reaching it. Eleven proofs added, covering the
witness-by-id, the head arm's own call site (two callers of one helper, one fixture), the
mark helper's result propagation, and the round-4 behaviour above.

`raced_why`'s human value is a named constant now: it is the one such value that is also a
PREDICATE, compared twice, and a drift in either copy silently downgrades the human
`::error::` — the only message that tells a reviewer their verdict was buried.

## Docs

The renamed sentinel literal in two places; three documents still asserting the fence
"writes NOTHING"; the record's `mechanics:` still describing round 2's witness; the
replacement-site list, which had grown by four; a residual pointing "below" at something
above it; and `CLAUDE.md`'s "closed", which is stronger than the record it points at — that
record lists six residuals including both endpoints failing at once. The proof inventory is
stated as an invariant (every clause with a predecessor is mutated back to it) rather than a
count that rots.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 05:13:28 +02:00
timothyandClaude Opus 5 168fe21088 fix(849): round 3 — replace every unknown state, and prove the clauses that claim to
Two more cold reviews — cross-family (Codex/GPT-5.6) and a cold Claude reviewer that ran
the mutants itself — converged on two separate things: a remaining class of paths that
still left an unknown state standing, and, more importantly, that several clauses this
branch claimed as fixes SURVIVED mutation of the exact text they name.

## Behaviour

1. The reconciliation witness matches the CURRENT row's `id`, not merely a row with the
   sentinel's description. Description alone is satisfied by an OLDER identical sentinel —
   which is what a fixed point produces — so a read carrying only the earlier row cleared
   the sentinel while the verdict buried under the current one ended up below the fresh
   mark. Falls back to the description where the server omits `id`.
2. The two OBSERVED-mutation arms mark a head that carries a row this run declined, instead
   of only abstaining. They are still right not to post their CLASSIFICATION — computed
   against a base or head the PR may no longer have — but a declined row must not stay
   authoritative for the whole window until a successor finishes, and for a PR's FIRST push
   no successor is queued at all. Scoped to `pre_state` being non-empty, so the common path
   stays quiet.
3. `replace_unknown_state` RETURNS a status. Its first version ended the failure arm with a
   successful `echo`, so it reported 0 after both POSTs failed and the fence caller's
   `exit 0` reported an abstention that had not happened.
4. An `id` difference counts only when BOTH reads supplied one. A response that omits `id`
   beside one that includes it otherwise reads as a replacement, and this guard's reaction
   is to abstain — over a row the classification had already declined.
5. Every element and every consumed field of the combined response is type-checked before
   extraction, and a schema failure routes to the replacement. `.statuses` being an array
   was checked; its ELEMENTS were not, so one scalar made `select(.context == $c)`
   hard-error and `set -e` took the step down before any path could mark the head.
6. The path-predicate failure replaces rather than merely exiting, for the same reason.
7. `$UNVERIFIED_DESC` says "Status write", not "Exemption write". It is now written on paths
   that grant no exemption at all, and it is the operator-facing text of a required check.
8. The no-op-repair skip keeps the human `::error::`. Skipping the WRITE is right — the head
   already carries the strongest marker — but that message is the only place a reviewer is
   told their verdict was buried. `raced_why` is a sentence now, not the token `human`.

## Proof

The cold reviewer measured three of the six round-2 claims surviving mutation of their own
clause, one against the verbatim predecessor from the previous commit. Nine proofs added:
the no-mark downgrade's SCOPE (not just the description it writes), the page-2 refusals, the
untrusted-fence write, the row-`id` comparison, the repair floor, the no-op skip, both `$own`
exclusions, the write-result return, and the both-ids-present rule.

Two of those needed the test double to grow: the combined-status stub emitted no `id` at
all, so the `ex_id` clause had never once run with a non-empty value; and POSTs always
succeeded, so both write helpers' failure arms were unreachable.

The `$own` exclusions and the no-op skip are OUTCOME-redundant — mutating either alone leaves
the post sequence unchanged, which is how duplicate guards hide each other. Their proofs
assert the LOG, because what the exclusions alone decide is whether the job reports a race
against its own row. One clause is left deliberately unproven and named as such in the record
and the guard inventory rather than counted: the path-predicate failure branch has no fixture
that can reach it.

## Also

Round 2 left two comment paragraphs duplicated verbatim and a block header narrower than its
block; both fixed. Stale prose corrected in the workflow ("dies WITHOUT posting", "post-write
verification never runs for it", "this block only runs after a `success`"), `docs/ci-cd.md`
("the fence never re-counts", "the history is read twice" — it is three now),
`ci.exemption-provenance` and `docs/guard-inventory.md`.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 05:13:28 +02:00
timothyandClaude Opus 5 957a328f33 fix(849): round 2 — the uncertainty paths that still resolved toward success
Two independent cold reviews (Codex/GPT-5.6 cross-family, and a cold Claude reviewer in
its own worktree) converged on the same class: paths where "this job cannot establish
what is on the head" still resolved by leaving the head alone, which protects a real
verdict and leaves a forged one.

Behaviour:

1. The four page-2 completeness refusals now replace the unknown state too. They were
   excluded on the reasoning that the probe fires when NO row for this context was on page
   1, so there is no green of any provenance to leave standing — self-contradictory, since
   the only reason page 2 is read is that the row may be beyond page 1, which the probe's
   own message says. Accepted cost, stated in the record: a head with more CONTEXTS than
   the 50-row cap stalls every run; measured 2026-08-29, this repo puts 8 on a `main` head,
   and that case already stalled with an ABSENT check.
2. The no-mark downgrade covers every re-derivable write, not only `success`. Restricting
   it analysed the wrong PR: the damaging case is one that IS exemptible and got the
   generic `pending` only from a transient enumeration failure. That description carries no
   marker, nothing verifies it without a mark, and the next run re-derives it into the
   exemption with the human row below its own mark — route 2's damage through route 1's
   condition. `$REPAIR_DESC` stays exempt, being stronger and not re-derivable.
3. The fence branch that cannot trust its retarget count while holding a derived `success`
   writes the sentinel instead of abstaining. It is reached only after the classification
   DECLINED to inherit the row the head carries, so posting nothing left that row current;
   the message said the context "stays absent", true only of a head that had none.
4. Reconciliation needs a WITNESS: it may clear only over a complete history containing the
   sentinel's own row. `ex_unverified` means the combined endpoint just returned that row
   and `/statuses/{sha}` keeps one per POST, so a complete-but-empty history contradicts a
   write that demonstrably happened — and `page_statuses` accepts an empty page 1 as
   complete, which is what made it reachable. Both reviewers reproduced the clear-then-exempt
   outcome. The shipped positive test used exactly that impossible fixture, so it was
   pinning the defect; it now seeds the sentinel row, and an impossible-empty negative plus
   a witness mutation proof were added.
5. The mid-run "did this row change" comparison now includes the row ID. The two sentinels
   are byte-identical by design, so a mid-run replacement of one by another was invisible to
   a state/creator/description triple. Measured 2026-08-29 (Gitea 1.27.1, head 736649b3):
   the COMBINED endpoint carries `id` on every row, ids 14..30 ascending — the job had only
   ever read ids from `/statuses/{sha}`. Where a server omits it both sides are empty and
   the comparison degrades to the pre-existing text test.
6. The repair has a FLOOR — it may never write a description weaker than the one this run
   decided — and is skipped when it would rewrite what is already there. Widening the gate
   to every write meant a transient post-write read could rewrite a correct `$REPAIR_DESC`
   carry-forward with the machine-clearable sentinel, reversing the ordering rule the
   classification chain states.

Writing the sentinel and failing the job are separate decisions, which is why
`replace_unknown_state` and `replace_unknown_and_die` are two functions: the read refusals
were already non-zero exits on `main` and stay red; the fence branch exited 0 there and
still does, because an unreadable timeline is an ordinary hiccup and reddening every one is
noise this file elsewhere refuses to add.

Prose corrected where it now overclaimed: "the green never stands" after the post-POST
re-check is wrong — it is live between the POST and the repair, so the check makes a
permanent green TRANSIENT; "a later run reconciles this automatically" is wrong in the one
case where the replacement costs anything, since finding a masked verdict UPGRADES to the
human-only sentinel; and the mutation-proof framing claimed every mutant restores the exact
predecessor, when two do, one restores the shape #742 withdrew, and the rest disarm clauses
that have no predecessor. The quiet-timeline positive control now counts timeline walks,
because a single POST is also what a skipped re-check produces.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 05:13:28 +02:00
timothyandClaude Opus 5 e30702111f fix(849): verify every write, and mark a head nothing could verify
The gate's post-write verification had five routes that all ended the same way — an
exemption `success`, or a generic `pending` a later run turns into one, standing over a
human `failure`.

Two of these were attempted inside #742 and withdrawn, and the withdrawal is what shaped
this change. That attempt withheld the exemption by writing a GENERIC `pending`, which is
exactly what a later run re-derives into `success` — it moved which run posted the forged
green rather than stopping it — and it had no retry path, because this workflow triggers
only on `pull_request_target` types, so a transient failure on a PR's last event stalled an
exempt PR until a human nudged it. The fix therefore needs two properties at once: sticky,
so a later run cannot re-derive it, and reconcilable, so a blip does not cost a head its
exemption permanently. Neither the repair sentinel nor a generic `pending` has both, which
is why there is now a second sentinel rather than a reuse of the first.

What changed:

1. No high-water mark => the exemption is WITHHELD before the POST and the head is marked
   with the new `UNVERIFIED_DESC` sentinel. Withholding before the write rather than
   posting and repairing matters because the defect is known in advance: publishing a green
   to take it back opens a window branch protection, and an already-scheduled auto-merge,
   can see.
2. Post-write verification runs after EVERY write, not only `success`. A generic `pending`
   masks a rejection landing in its own write window just as well, and carries no marker,
   so the next run re-derives it with the human's row now below THAT run's mark.
3. `.description` is type-tested before `startswith`. `(.description // "")` does not
   replace a NUMBER, so `startswith` hard-errors on one, killing the whole count — the
   genuine verdict beside the malformed row is lost with it.
4. The retarget count is re-taken AFTER the POST on the exemption path, closing the
   PERMANENT forged green `ci.verdict-write-retarget-fence` listed as its residual 1. The
   retarget axis only: a push after the POST moves the head, so the status no longer gates
   that PR, while a retarget changes the effective diff with the sha unchanged.
5. An unreadable combined-status read retries once and then REPLACES the unknown state
   instead of declining to write. Declining protects a real verdict and leaves a FORGED one
   — an off-list `success` is the row #742 exists to revoke, revocation happens by
   re-deriving it, and the job then went red on a status branch protection does not read.

One defect this introduced and fixed on the way: widening the post-write gate to every
write made the job match its OWN row, because the machine-sentinel arm selects on a null
creator. A run taking the carry-forward path POSTed `$REPAIR_DESC`, then found "a sentinel
above the mark", then repaired to the identical description. `--arg own "$desc"` excludes
it, by description rather than by id — the id of the row just written is not knowable
there.

Reconciliation is what bounds the stall: a later run pages `/statuses/{sha}` in full and
either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it
upgrades to the repair sentinel, clearable only by a human — or finds none and clears it.
It is sound because the two endpoints disagree: a masked verdict is invisible on the
combined endpoint (latest row per context, which is the sentinel) and still present in the
per-POST history.

Tests: each fix is paired with a `test_MUTATION_…` proof that restores the exact
predecessor text through a new `_run_classify(mutate=…)` knob, whose count assertion is the
binding — a clause that has since moved substitutes zero times and fails loudly rather than
measuring the unmutated body. Two CHAINED tests feed run N's real output into run N+1,
because both sentinels are fixed points and a single hop cannot assert a fixed point: the
raced-`pending` repair must survive the run that would otherwise grant the exemption, and
the unverified sentinel must not decay while it cannot be reconciled.

Docs: new record `ci.verdict-unverified-write-sentinel`; the now-false guarantee prose in
`ci.verdict-write-retarget-fence` (its `rule:` frontmatter, the "resolves it" opener, "the
fence above closes", the truncating-block claim and residual 1), `ci.exemption-provenance`,
`docs/ci-cd.md`, `docs/remote-state-inventory.md` and `CLAUDE.md` corrected by concept
rather than by phrase, per the scope boundary recorded on the issue.

fixes #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 05:13:28 +02:00
timothyandClaude Opus 5 5d955000f3 fix(845): the verdict writer checks that the gate will honour what it just posted (#889)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 31s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 15m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m51s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 8m27s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m46s
`review-verdict.yml` inherits an existing `review-verdict/h10=success` only from a status whose
`.creator.login` is on its `H10_REVIEWERS` allow-list (#742). `post-review-verdict.sh` wrote those
verdicts with whatever account owned the credential in the environment and never asked whose it was.
Two coupled values, nothing asserting the coupling, and the failure was the silent kind: the status
is written, the tool reports success, and the next `pull_request_target` event re-derives it and
posts over it. The PR stalls with no visible cause.

The writer now READS ITS OWN STATUS BACK, identifies that write by state and description, and
refuses — before the verdict comment, so the surviving half-state is the documented `ask` one —
unless the recorded creator is allow-listed. Measured after the write rather than probed before it:
that tests what Gitea recorded as the author, which is the value the gate reads, and needs no scope
beyond the repo access the POST already required.

Membership is required for a `success` ONLY, mirroring the gate's own asymmetry: a `failure` is
inherited from any attributable account, so requiring it there would refuse a verdict the gate
honours and leave an off-list reviewer no supported way to record a rejection.

The allow-list is DERIVED from the gate's own literal by the new `scripts/lib/h10-reviewers.sh` —
one declaration, not two plus a parity test. It is a parse rather than a shared declaration both
sides source because the gate runs against a checkout of the PR's BASE sha: a PR whose base predates
such a file would not have it, and a missing `source` under `set -euo pipefail` kills the job, which
posts no `review-verdict/h10` at all and blocks every merge including its own repair (#743).

`scripts/post-review-verdict.sh` moves BEHAVIOUR-ONLY -> MUTATION in the guard inventory, which the
manifest's own note called "the most valuable upgrade on this list". The declared clause lives in the
GATE: rewriting `H10_REVIEWERS` while the posting account stays fixed reddens the accept path only if
the writer reads the list live AND the comparison gates the outcome.

Two defects were caught by probing the live instance rather than re-reading the code. Reading `.state`
instead of `.status` per row would have refused EVERY verdict — a repo-wide deadlock, shipped green,
because the test shim replayed the POST payload as the read-back body and so agreed with the parser
by construction. Then a `(.status // .state)` fallback added as defensiveness recreated #845 exactly:
the writer would accept a shape the gate cannot read and report success.

Nine independent cold review rounds, all worktree-isolated, one cross-family (GPT-5.6 via Codex).
Round 8 caught the most important one: a `set -u` "correction" made mid-branch had inverted a TRUE
statement in live merge-gate code, because the probe used a plain `$UNSET` while the validator uses
`${#arr[@]}` — different shapes, different behaviour. Withdrawn wholesale; both libraries are
byte-identical to `main` again.

Verification: full `scripts/tests` suite green (1278 passed, 2 skipped); the declared mutation
executes every run and reddens its named proof with the manifest's `expect` string; every clause
disarmed individually and confirmed to redden its own named test; live probes against Gitea 1.27.1
for the row shape, the description round-trip, the paging order and the required-check list.

Docs: `ci.exemption-provenance` records the coupling as asserted rather than as a tracked residual,
plus `docs/ci-cd.md`, `CLAUDE.md`, `docs/guard-inventory.md`, `docs/remote-state-inventory.md`,
`ci.script-tests-job` and the `script-tests` population comment in `pr-checks.yml`.

Deferred: the refused-verdict residual (a non-inheritable status left standing with no comment) is
the `ask` half-state `release.verdict-writes-status-before-comment` designates as safe; a second
corrective write is the sticky-sentinel mechanism #849 is separately designing.

fixes #845

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-30 02:43:08 +00:00
timothyandClaude Opus 5 1d50241833 docs(853): workflow_dispatch can't be ref-restricted at 1.27.1 — and restricting it would close nothing (#888)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 12m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 8m19s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 8m10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 21s
Probed every reachable surface rather than stopping at the endpoint #853 already knew 404s: the dispatch API takes `ref` as a required free-form string with no allow-list; protected environments do not exist at 1.27.1 (0 of 308 documented paths mention "environment", secrets are org/repo/user-scoped only); the loaded `app.ini` sets two `[actions]` keys; and the CLI's sole Actions subcommand is `generate-runner-token`. So option 3 is unavailable.

Accepted on a different ground than the issue proposed. "Anyone with repository write can already do worse" is unfalsifiable and hides the cheaper route. The operative reason is that dispatch is not the cheapest path: `docker-build.yml`'s head-resolved `pull_request:` runs attacker-authored YAML, which reaches every secret in the store — six of its jobs hold `REGISTRY_PASSWORD` on that route and two are branch-protection required contexts. "Push a branch, open a PR" costs no act outside the ordinary contribution flow, where a dispatch costs one.

Corrections to #853's own table, verified against the tree: `dependency-scan.yml` references no secrets at all; the "four workflows" count is right.

Deliberately not applied: a `v*` tag protection (`tag_protections` is empty and 1.27.1 supports it) — protection-class config whose failure mode is a broken release cut, so it needs its own change and verification. Tracked with the `pull_request:` residual in #885.

The web UI was not swept, and the record says so explicitly rather than claiming exhaustiveness — a Gitea Actions control can exist with no API surface at all.

fixes #853

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-30 01:18:03 +00:00
timothyandClaude Opus 5 (1M context) &lt;noreply@anthropic.com&gt; 94a3d13495 fix(836): never pass --depth to a checkout that may already be complete (#884)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 11s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 32s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 16m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m7s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 8m7s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 2m0s
`git fetch --depth=N` grafts a complete clone shallow. `scripts/ci-detect-docs-only.sh` applied a depth chosen for its three `fetch-depth: 2` consumers to `build`'s `fetch-depth: 0` checkout, so the `git describe --tags` in the next step found no reachable tag and a `|| echo v0.0.0` fallback turned that into a version: every `:latest` image shipped `InformationalVersion 0.0.0-<sha>` from 2026-07-17 (#416) until now.

Both fetch sites now go through `fetch_ref`, which passes `--depth` only when the checkout is already shallow. `Compute version and tags` fails the job instead of defaulting, so no `:latest` is published rather than a mislabelled one; releases are unaffected because the tag path never calls `describe`.

Ships a guard that drives the real script over real `file://` clones with a negative control, a declared clause mutation, and a decision record `ci.fetch-depth-never-grafts-a-complete-clone`.

fixes #836

Co-Authored-By: Claude Opus 5 (1M context) &lt;noreply@anthropic.com&gt;
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-29 23:55:29 +00:00
timothy e8f80c42ce fix(820): derive where Complete&lt;T&gt; is APPLIED, not just what it means (#883)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 28s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 14m22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 11m1s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 9m58s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m58s
`Complete<T>` (#807) makes SPA full-replace bodies fail typecheck when a builder omits a schema member. Nothing checked it was APPLIED: `completeRequest.guard.test.ts` proves the type's semantics and would stay green with every annotation deleted, and `test_optional_request_members.py`'s COVERED disposition — "the builder is annotated `Complete<T>`" — was a claim about another language's source that nothing verified.

Adds `completeAnnotationScan.ts` (compiler-API scanners) + `completeAnnotations.guard.test.ts`, with a synthetic-source fixture suite. Two derived populations: the `Complete<…>` annotations (SPA AST ∩ git index) and the droppable schemas (parsed from the generated `v1.d.ts`, a pass-through of the OpenAPI `required` array). It asserts a production annotation per schema dispositioned as needing one, NO annotation on the server-computed and load-bearing-omission schemas, that every `Complete<X>` resolves to a generated schema rather than a hand-written mirror, and set equality between droppable schemas and the reviewed dispositions. `test_complete_annotation_dispositions.py` cross-checks that table against the authoritative Python one and ships a declared, harness-executed mutation.

Found one live defect: `playouts.ts` declared two request types as hand-written mirrors SHADOWING generated schemas of the same name, so their `Complete<>` was checking a local copy rather than the contract — the #754 mechanism wearing the annotation meant to prevent it.

Eight review rounds, seven BLOCKED, two independent cold reviewers. A wrapper-signature scanner was built and REMOVED: every blocker traced to that one mechanism (obligation on the wrong population; reachability mistaken for protection, since `Complete<T>` is shallow; body discovery keyed on a parameter name, then parameter-vs-local; and finally `export function` → `export const` blinding the scanner and its cross-check together). Five defects from one mechanism, so the mechanism went rather than a sixth patch.

Residuals stated in §4b, the guard-inventory row and the record: per-SCHEMA not per-site or per-wrapper; token presence not liveness; the phantom direction unchecked (#777); a second `setupFiles` entry could discharge; and plugin-level population integrity borrowed from the sibling guard.

fixes #820

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-29 22:06:38 +00:00
53 changed files with 8653 additions and 506 deletions
+137 -33
View File
@@ -45,9 +45,26 @@
# ETV_GITEA_URL overrides the base (default: the LAN instance; a LAN address, not a secret).
set -euo pipefail
# THE FIRE-LOG PATH BELOW IS SELF-LOCATED, not `${CLAUDE_PROJECT_DIR:-...}` (ersatztv#858, #891).
# Written here rather than beside the assignment because the instrumentation preamble that follows is
# machine-compared: `test_hook_fire_log.py::test_the_stripper_removes_EXACTLY_the_preamble_and_nothing_else`
# permits only its own recognised lines in that block, so a comment inside it fails the suite.
#
# That line is `. `-SOURCED, so whatever it names runs AS CODE inside this hook, before stdin is read
# and before `decide` exists. It is therefore not "telemetry" in any sense a gate can rely on.
# MEASURED 2026-08-30: with the env-var-first form, a `hook-fire-log.sh` in an env-var-named tree
# that prints an `allow` decision and exits 0 GRANTS THE MERGE outright, having bypassed every check
# below. Self-locating binds it to the tree this hook was loaded from and closes that.
#
# The other twelve tracked hooks still carry the env-var-first form and are deliberately NOT changed
# here; that sweep needs its own population and review (ersatztv#891, where the reachable case is
# measured — husky launches the prepush hooks as `./.claude/hooks/...`, a RELATIVE path independent
# of `$CLAUDE_PROJECT_DIR`, so the two roots genuinely diverge there). This copy is fixed because
# leaving a total gate bypass 500 lines above the gate this PR hardens would make the rest decorative.
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin pretooluse-merge-consent "" capture || true
@@ -321,6 +338,50 @@ for n in $issues; do
fi
done
# ONE branch-protection READ per run (ersatztv#859). Two arms consume this endpoint — the scheduled
# path's `review-verdict/h10` required-check test, and the guard-scope freshness check at the bottom
# — and they used to issue independent GETs, so a scheduled auto-merge hit it twice (measured: the
# test stub recorded 2 URLs).
#
# THE ROUND TRIP IS THE SMALLER HALF. What matters is that branch protection is MUTABLE config: two
# reads can return two different answers, and the gap between them is a gap in which the two arms
# decide about different repo states — one concluding `review-verdict/h10` is required on the base
# while the other classifies a rule list that no longer says so. Neither arm can detect that; both
# would report confidently. Caching makes a single run internally consistent BY CONSTRUCTION, which
# is a property no retry or ordering change can supply.
#
# WHY #787 DID NOT ALREADY SHARE IT, since the obvious question is why two reads existed at all: the
# arms ask genuinely different QUESTIONS — one about `$base_ref` and its required contexts, one about
# `main` and snapshot freshness — so their classifications must stay separate. But they ask those
# questions of the same URL with the same credentials, so the RESPONSE is shareable even though the
# verdicts are not. Cache the bytes; never cache a verdict.
#
# This does NOT pin anything: protection can still change after the read, and the honest ceiling is
# unchanged (`process.check-and-use-pins-a-version`). It removes a second window, it does not remove
# the first.
bp_fetched=no
bp_cache=""
bp_cache_code=""
fetch_branch_protections() {
# Idempotent by design: every caller invokes it unconditionally and the FIRST one pays. A caller
# that had to know whether it was first would be a second place for the two arms to disagree.
if [ "$bp_fetched" = yes ]; then return 0; fi
bp_fetched=yes
local f
# A temp-file failure gets its own sentinel rather than an HTTP-shaped one, so each caller can
# keep the distinct message it had before this was shared. Reporting a mktemp failure as HTTP
# '000 — Gitea unreachable' would state a cause that did not happen, which is the defect class
# this whole file is organised around.
f=$(mktemp) || { bp_cache=""; bp_cache_code=mktemp-failed; return 0; }
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
bp_cache_code=$(curl -s -o "$f" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
else
bp_cache_code=$(curl -s -o "$f" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
fi
bp_cache=$(cat "$f" 2>/dev/null || true)
rm -f "$f"
}
# --- (a) CI combined status must be green (unless deferring to Gitea's own check-gate). ---
if [ "$mwcs" != "true" ]; then
[ -n "$sha" ] || decide ask "H6 merge gate: could not resolve PR #$pr head sha to check CI. Verify CI is green before merging."
@@ -453,13 +514,12 @@ else
# the twin rather than documenting it is the point: one fetch, one classifier, one argument, and
# no second path to keep in step. The ref no longer reaches a URL segment, so it needs no
# encoding either.
bp_file=$(mktemp) || decide ask "H6/H10 merge gate: could not allocate a temp file to read branch protection for '$base_ref'. Confirm the 'review-verdict/h10' required check manually before scheduling an auto-merge."
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
bp_code=$(curl -s -o "$bp_file" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
else
bp_code=$(curl -s -o "$bp_file" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
fetch_branch_protections
if [ "$bp_cache_code" = "mktemp-failed" ]; then
decide ask "H6/H10 merge gate: could not allocate a temp file to read branch protection for '$base_ref'. Confirm the 'review-verdict/h10' required check manually before scheduling an auto-merge."
fi
bp_list=$(cat "$bp_file" 2>/dev/null || true)
bp_code=$bp_cache_code
bp_list=$bp_cache
bp=""
if [ "$bp_code" = "200" ] && printf '%s' "$bp_list" | jq -e 'type == "array"' >/dev/null 2>&1; then
# DO NOT claim parity with Gitea's matcher — this code cannot have it, and asserting it would
@@ -522,15 +582,26 @@ else
# the two comments beside that arm were written to fix, so it is checked here rather than inherited.
classifier="$repo_root/scripts/lib/branch-rule-classifier.jq"
if [ ! -r "$classifier" ]; then
rm -f "$bp_file"
decide ask "H6/H10 merge gate: the shared branch-protection rule classifier is missing or unreadable at $classifier, so which rule governs '$base_ref' — and therefore whether 'review-verdict/h10' is required on it — could not be derived (ersatztv#787). Restore the file, or confirm the required checks manually."
fi
bp_verdict=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c -f "$classifier" 2>/dev/null || true)
case $(printf '%s' "$bp_verdict" | jq -r '.verdict // ""' 2>/dev/null || true) in
exact) bp=$(printf '%s' "$bp_verdict" | jq -c '.rule' 2>/dev/null || true); bp_code=200 ;;
undecidable) rm -f "$bp_file"
decide ask "H6/H10 merge gate: no branch-protection rule on this repo governs '$base_ref' decidably — a GLOB rule could govern it, or two rule names fold-equal, or a name is non-ASCII. This hook deliberately does not reimplement Gitea's glob matcher, so whether 'review-verdict/h10' is required on this base cannot be derived here (ersatztv#778). Confirm it in the repo's branch-protection settings, or merge immediately instead of scheduling." ;;
undecidable) decide ask "H6/H10 merge gate: no branch-protection rule on this repo governs '$base_ref' decidably — a GLOB rule could govern it, or two rule names fold-equal, or a name is non-ASCII. This hook deliberately does not reimplement Gitea's glob matcher, so whether 'review-verdict/h10' is required on this base cannot be derived here (ersatztv#778). Confirm it in the repo's branch-protection settings, or merge immediately instead of scheduling." ;;
none) bp_code=nomatch; bp="" ;;
# A DECLARED class of the classifier's contract (ersatztv#859), with its OWN sentinel — not
# merely its own arm. The first draft gave it an arm that set `unreadable-rules`, the same value
# the catch-all sets, and that arm was measured to be a no-op: deleting it left the WHOLE suite
# green, because nothing downstream could tell the two apart. An arm no observation can
# distinguish is not a fix, it is a comment with syntax. (The invariant is "no test reddens",
# not a test count — a count goes stale the next time anyone adds one.)
#
# They are different findings and now say so. `unnamed-rule` means the list was READ and a rule
# in it carries no usable name; `unreadable-rules` means jq died or answered a word this hook
# does not know. Same decision (ask), different cause — and naming the cause accurately is the
# entire subject of this issue, so collapsing them here would have reproduced the defect being
# fixed, one arm over.
unreadable) bp_code=unnamed-rule; bp="" ;;
*) bp_code=unreadable-rules; bp="" ;;
esac
else
@@ -546,7 +617,6 @@ else
fi
bp=""
fi
rm -f "$bp_file"
# `nomatch` is the CLASSIFIER's verdict, deliberately not an HTTP code. Reusing 404 for it made
# this deny reachable from an HTTP 404 on the list read too — repo not found, or invisible to the
# credential, which Gitea also answers 404 — and then the reason claimed "the full rule list was
@@ -555,11 +625,26 @@ else
if [ "$bp_code" = "nomatch" ]; then
decide deny "H6/H10 merge gate: BLOCKED — no branch-protection rule on this repo can govern '$base_ref' (the full rule list was read and none matches), so 'review-verdict/h10' is not a required check on it. A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622). Restore branch protection on '$base_ref', or merge immediately (without merge_when_checks_succeed) once CI is green."
fi
# `unreadable-rules` is the CLASSIFIER failing on a 200 it could not parse — a numeric
# `branch_name` makes jq throw, and `//` does not catch it because it fires only on null/false.
# It gets its own sentinel for the same reason `nomatch` does: reporting "HTTP '000' — Gitea
# unreachable" about a successful 200 read states a cause that did not happen, which is the defect
# fixed one arm over for the deny.
# `unnamed-rule` is the classifier reporting a rule whose NAME it could not use. Two distinct
# shapes, and the reason string must cover both or it states a cause that did not happen: EITHER
# both fields supply no name (absent, null, or empty), OR one of them is present holding a
# non-string, which poisons the rule however good its sibling is. It is deliberately NOT reported as
# "no rule matches": a rule that cannot be read might be the rule Gitea is applying, so a list
# containing one supports no finding about which rule governs the base. That was the #859 defect —
# `""` is a valid name that matches nothing, so an unreadable rule DENIED with a stated cause that
# had not happened.
if [ "$bp_code" = "unnamed-rule" ]; then
decide ask "H6/H10 merge gate: a branch-protection rule on this repo carries no name this hook can use — either both 'branch_name' and 'rule_name' are absent/null/empty, or one of them is present holding something that is not a string. Which rule governs '$base_ref', and whether 'review-verdict/h10' is required on it, therefore could not be derived. A rule that cannot be read might be the one Gitea applies, so this is deliberately NOT reported as 'no rule matches' (ersatztv#859). Inspect the branch-protection rules, or merge immediately instead of scheduling."
fi
# `unreadable-rules` is the CLASSIFIER failing on a 200 this hook could not turn into a verdict —
# jq died, or answered a word this contract does not define. It gets its own sentinel for the same
# reason `nomatch` does: reporting "HTTP '000' — Gitea unreachable" about a successful 200 read
# states a cause that did not happen, which is the defect fixed one arm over for the deny.
#
# A numeric `branch_name` was the worked example here until ersatztv#859 and no longer reaches this
# arm: it is not a usable NAME, so the classifier now classifies it rather than throwing on it, and
# it lands on `unnamed-rule` above with the cause that actually applies. The example is corrected
# rather than dropped, because it is the one shape a reader is likely to reach for when testing.
if [ "$bp_code" = "unreadable-rules" ]; then
decide ask "H6/H10 merge gate: this repo's branch-protection rules came back in a shape this hook could not parse, so whether 'review-verdict/h10' is required on '$base_ref' is unknown. Check the rules manually, or merge immediately instead of scheduling."
fi
@@ -620,7 +705,19 @@ fi
# inside a fenced code block (documentation showing the convention counted as a real verdict), and a
# sha taken from the first `@<hex>` anywhere on the line (a markdown link could supply it). Every
# decision the classifier makes is documented there; this file only maps a class onto a hook decision.
verdict_script="${CLAUDE_PROJECT_DIR:-.}/scripts/check-review-verdict.sh"
# RESOLVED FROM `$repo_root`, never `$CLAUDE_PROJECT_DIR` — the rule, the threat model and the
# boundary are in `process.hook-resolves-inputs-from-repo-root` (ersatztv#858). Written once there
# rather than twice here: this file carried two resolutions of the same question, and the guard-scope
# arm below is the other one. Two answers in one file is the state most likely to be "tidied" toward
# the weaker side, so neither site restates the argument now.
#
# Site-specific consequence only: a `$CLAUDE_PROJECT_DIR` naming a sibling worktree — routine here —
# would classify THIS PR's comments with THAT tree's copy of the H10 grammar.
#
# `ETV_HOOK_FIRE_LIB` at the top of this file is bound the same way, and for a STRONGER reason — it
# is sourced, so it is code. See the block above it. The twelve other hooks still resolve it from
# the env var and are ersatztv#891.
verdict_script="$repo_root/scripts/check-review-verdict.sh"
if [ ! -x "$verdict_script" ]; then
decide ask "H10 merge gate: verdict classifier not found at $verdict_script, so the review state can't be derived. Confirm the review covered the latest commit before merging."
fi
@@ -691,14 +788,12 @@ esac
# stale is a guard's scope — a different artifact, on a different clock. Denying would state
# something false about the change in front of the reader. Every non-`match` class asks, so a
# comparison that could not be made is surfaced rather than skipped (`unknown` is not `fine`).
# ONE base for both the checker and the snapshot, and it is `$repo_root` — derived from this file's
# own location — rather than `$CLAUDE_PROJECT_DIR`. Two reasons, and the second is the load-bearing
# one. Resolving them from different roots would let the hook classify one checkout's snapshot with
# another checkout's script, mismatched halves of a comparison whose whole job is to detect a
# mismatch. And an ENV VAR is not a sound input to a security decision: a wrong value pointing at a
# tree that happens to contain an executable checker returns `match` about a different checkout
# entirely, which silently authorizes the grant. A missing path only asks, so the failure is quiet
# exactly where it is worst.
# ONE base for both the checker and the snapshot, and it is `$repo_root` — see
# `process.hook-resolves-inputs-from-repo-root` for why an env var may not select either
# (ersatztv#787, #858). The reason specific to THIS arm is that both halves of a comparison are
# resolved here: from two different roots the hook would classify one checkout's snapshot with
# another checkout's script — mismatched halves of a comparison whose entire job is to detect a
# mismatch — and answer `match` about a tree nobody asked about.
ctx_base="$repo_root"
ctx_snapshot="$ctx_base/.gitea/required-status-contexts.json"
ctx_script="$ctx_base/scripts/check-required-contexts.sh"
@@ -725,24 +820,33 @@ if [ "$ctx_repo_fold" = "$target_repo_fold" ]; then
if [ ! -x "$ctx_script" ]; then
decide ask "H6 merge gate: the required-contexts checker is missing or not executable at $ctx_script, so whether the dropped-step guard's scope still matches branch protection on 'main' could not be derived (ersatztv#787). Check it manually, or restore the script."
fi
bpf=$(mktemp) || decide ask "H6 merge gate: could not allocate a temp file to read branch protection for the guard-scope freshness check (ersatztv#787)."
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
ctx_code=$(curl -s -o "$bpf" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
else
ctx_code=$(curl -s -o "$bpf" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
# THE SHARED READ (ersatztv#859). On a scheduled merge the arm above already fetched this; here that
# call is a cache hit, so the endpoint is read once per run instead of twice. On the IMMEDIATE path
# this is the only consumer and it performs the fetch itself, which is why the call sits AFTER the
# `[ ! -x "$ctx_script" ]` check above: a missing checker must ask without having touched the
# network, and a test pins exactly that by asserting no branch-protection URL was recorded.
fetch_branch_protections
if [ "$bp_cache_code" = "mktemp-failed" ]; then
decide ask "H6 merge gate: could not allocate a temp file to read branch protection for the guard-scope freshness check (ersatztv#787)."
fi
ctx_code=$bp_cache_code
# ONE temp file, and it holds the checker's STDERR. Until ersatztv#859 this was `mktemp` for the
# payload plus an unmanaged `$bpf.err` beside it — a second path mktemp never created and therefore
# never made unpredictable. The payload now comes from the shared cache over a pipe, so the only
# thing still needing a file is the diagnostic, and it gets the mktemp'd one.
ctx_err=$(mktemp) || decide ask "H6 merge gate: could not allocate a temp file for the guard-scope freshness check's diagnostics (ersatztv#787)."
if [ "$ctx_code" = "200" ]; then
# stderr is KEPT, not sent to /dev/null. The checker exits 2 with a diagnostic on a usage error —
# an unreadable snapshot, a branch mismatch, a missing classifier — and discarding it made all of
# those arrive at the operator as the catch-all's "returned 'nothing'", which names no cause. That
# is the same states-a-cause-that-did-not-happen shape this arm was careful about elsewhere.
ctx_class=$("$ctx_script" --branch main --snapshot "$ctx_snapshot" < "$bpf" 2>"$bpf.err" || true)
ctx_diag=$(tr '\n' ' ' < "$bpf.err" 2>/dev/null | cut -c1-300 || true)
ctx_class=$(printf '%s' "$bp_cache" | "$ctx_script" --branch main --snapshot "$ctx_snapshot" 2>"$ctx_err" || true)
ctx_diag=$(tr '\n' ' ' < "$ctx_err" 2>/dev/null | cut -c1-300 || true)
else
ctx_class=readfail
ctx_diag=""
fi
rm -f "$bpf" "$bpf.err"
rm -f "$ctx_err"
case "$ctx_class" in
match) : ;;
drift)
+9 -3
View File
@@ -36,9 +36,15 @@ on:
# from the pushed ref like everything else in this file, so a branch that DELETES it re-enables
# the route. What closes is the DRIVE-BY case — an ordinary push of a legitimate `docker/ci`
# change publishing an image nobody asked for, with no deliberate act anywhere. This is NOT a
# boundary against a malicious or compromised writer and must not be cited as one; that class is
# ersatztv#853. `workflow_dispatch` is self-supplied from the selected branch in the same way, and
# is the deliberate publish path (docs/ci-cd.md -> "CI toolchain image").
# boundary against a malicious or compromised writer and must not be cited as one. That class was
# probed and ACCEPTED in ersatztv#853 (`ci.workflow-dispatch-ref-unrestricted`): Gitea 1.27.1 cannot
# restrict `workflow_dispatch` by ref, and restricting it would close nothing anyway:
# docker-build.yml's head-resolved `pull_request:` runs attacker-authored YAML, which reaches every
# secret in the store — so it covers renovate.yml's RENOVATE_TOKEN too, without dispatching
# renovate.yml at all. Only the DISPATCH third is settled; the `v*` tag push and the PR route
# itself remain open in ersatztv#885. `workflow_dispatch` is loaded from the ref it is dispatched
# on, exactly as the `branches:` filter below is loaded from the pushed ref, and is the deliberate
# publish path (docs/ci-cd.md -> "CI toolchain image").
#
# A `v*` tag push does not match this trigger either: there is no `tags:` key, and a `branches:`
# filter is compared against a branch ref. The exact matcher semantics are not probed here; the
+21 -1
View File
@@ -865,7 +865,27 @@ jobs:
INFO_VERSION="${VERSION}"
TAGS=("${IMAGE}:prod" "${IMAGE}:${VERSION}" "${IMAGE}:${SHORT}")
else
DESC=$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)
# `git describe` MUST resolve here, and a failure is fatal rather than defaulted
# (ersatztv#836). This job checks out `fetch-depth: 0`, so the tags are present; the
# only thing that ever stopped `describe` from seeing them was the detector step above
# grafting this complete clone shallow. The old `|| echo v0.0.0` was a fallback that
# cannot fail, so from 2026-07-17 (when #416 introduced the depth) until #836 every
# `:latest` image was published carrying
# `InformationalVersion 0.0.0-<sha>` and nothing anywhere went red — the defect was
# found by reading the string out of a running container, which is not a detector.
# Failing the job instead means no `:latest` is published at all: visible, recoverable,
# and never a mislabelled image promoted downstream. The tag path above never calls
# `describe`, so a release cut is unaffected by this.
# stderr is discarded on the CAPTURE and re-run for the diagnostic, rather than folded
# in with `2>&1`: a git warning on the SUCCESS path would otherwise land inside DESC and
# become part of the version string — the same shape of silent corruption this whole
# step is being hardened against.
if ! DESC=$(git describe --tags --abbrev=0 2>/dev/null); then
echo "is-shallow-repository=$(git rev-parse --is-shallow-repository)"
git describe --tags --abbrev=0 || true
echo "::error::git describe --tags --abbrev=0 failed, so this image would ship InformationalVersion 0.0.0-${SHORT} instead of a real version (ersatztv#836). The usual cause is a --depth fetch grafting this complete clone shallow; the two lines above say which."
exit 1
fi
INFO_VERSION="${DESC#v}-${SHORT}"
TAGS=("${IMAGE}:latest" "${IMAGE}:${SHORT}")
fi
+10 -5
View File
@@ -301,11 +301,16 @@ jobs:
# close. A distinct job name keeps a real failure unambiguous.
#
# Runs UNCONDITIONALLY on every PR rather than behind a `scripts/**` path filter. The suite's
# corpus tests are fixture/tmp-repo based, but test_post_review_verdict.py and
# test_merge_consent_exemption.py execute the REAL `scripts/post-review-verdict.sh` and
# `.claude/hooks/pretooluse-merge-consent.sh`, so its true input set spans at least two top-level
# directories. A `scripts/**` filter would silently miss a `.claude/hooks/**` edit — and at ~10s a
# filter buys nothing but drift.
# corpus tests are fixture/tmp-repo based, but several execute REAL artifacts from other top-level
# directories: test_post_review_verdict.py runs `scripts/post-review-verdict.sh`,
# test_merge_consent_exemption.py runs `.claude/hooks/pretooluse-merge-consent.sh`, and since
# ersatztv#845 test_post_review_verdict.py ALSO reads `.gitea/workflows/review-verdict.yml` —
# the writer derives the H10 allow-list from it, so editing that literal changes the suite's
# outcome. Its true input set therefore spans at least three top-level directories, and this
# enumeration is the kind that goes stale: a `scripts/**` filter would silently miss a
# `.claude/hooks/**` or `.gitea/workflows/**` edit. The reason is the INPUT SET, not the cost —
# the suite was ~10s when that was decided and is minutes now, and filtering on `scripts/**`
# would still be wrong.
prove-fix:
name: "Fix proofs (Proves trailers)"
runs-on: small
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), so a verdict posted with any other account is written, reported as success by the script, and then silently re-derived on the next PR event (ersatztv#845).
- `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.
@@ -1,4 +1,5 @@
using System.Threading.Channels;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
@@ -86,6 +87,29 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
var incoming = request.Items.Except([highest]).ToList();
// Reject an EXPLICITLY empty recurrence set before any mutation (#880). The checked set is
// `incoming` -- the exact list whose DaysOfWeek/DaysOfMonth/MonthsOfYear the loops below
// write -- so the check and its subject cannot drift apart. That EXCLUDES the highest-Index
// catch-all by construction: its recurrence is discarded along with its date range (only its
// ProgramScheduleId is read, further down), so an empty set there cannot make anything "never
// apply" and rejecting it would state a reason that is false for that item.
foreach (ReplacePlayoutAlternateSchedule item in incoming)
{
ProgramScheduleAlternate stored = existing.FirstOrDefault(e => e.Id == item.Id);
Option<BaseError> recurrenceError = RecurrenceSetBounds.Validate(
item.DaysOfWeek,
item.DaysOfMonth,
item.MonthsOfYear,
stored?.DaysOfWeek,
stored?.DaysOfMonth,
stored?.MonthsOfYear);
foreach (BaseError error in recurrenceError)
{
return error;
}
}
var toAdd = incoming.Filter(x => existing.All(e => e.Id != x.Id)).ToList();
var toRemove = existing.Filter(e => incoming.All(m => m.Id != e.Id)).ToList();
var toUpdate = incoming.Except(toAdd).ToList();
@@ -44,6 +44,25 @@ public class ReplacePlayoutTemplateItemsHandler(
List<ReplacePlayoutTemplate> incoming = request.Items;
// Same rule as the alternate-schedule path (#880), over ALL items: unlike that one, every
// template item's recurrence IS stored, so there is no catch-all to exclude here.
foreach (ReplacePlayoutTemplate item in incoming)
{
PlayoutTemplate stored = existing.FirstOrDefault(e => e.Id == item.Id);
Option<BaseError> recurrenceError = RecurrenceSetBounds.Validate(
item.DaysOfWeek,
item.DaysOfMonth,
item.MonthsOfYear,
stored?.DaysOfWeek,
stored?.DaysOfMonth,
stored?.MonthsOfYear);
if (recurrenceError.IsSome)
{
return recurrenceError;
}
}
var toAdd = incoming.Filter(x => existing.All(e => e.Id != x.Id)).ToList();
var toRemove = existing.Filter(e => incoming.All(m => m.Id != e.Id)).ToList();
var toUpdate = incoming.Except(toAdd).ToList();
@@ -0,0 +1,74 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Scheduling;
/// <summary>
/// Validates the three recurrence sets shared by <c>ProgramScheduleAlternate</c> and
/// <c>PlayoutTemplate</c> (ersatztv#880). One validator called from BOTH replace handlers, mirroring
/// <c>FFmpegProfileBounds</c> — the exemplar for `api.ffmpeg-profile-numeric-bounds`, whose shape this
/// follows deliberately.
/// </summary>
/// <remarks>
/// <para>
/// An EMPTY set is rejected because the three are read CONJUNCTIVELY by
/// <c>AlternateScheduleSelector.GetScheduleForDate</c> — a miss on any one continues to the next
/// item — so an empty one matches NO date and stores an item that can never apply. Rejecting
/// rather than substituting is the point: accept-then-rewrite would make an explicit `[]`
/// indistinguishable from an omitted field, which is the very collapse this issue removed.
/// </para>
/// <para>
/// An UNCHANGED empty set that the row ALREADY holds is let through. Both PUT paths are
/// whole-list replaces, so a hard rejection would make every OTHER item in the playout
/// uneditable over a row the operator never touched — the same reason
/// `api.ffmpeg-profile-numeric-bounds` rejects only a NEWLY submitted out-of-range value. A row
/// whose stored set is NULL is NOT exempt: null means unrestricted, so submitting `[]` for it is
/// a new emptying, not an unchanged legacy value.
/// </para>
/// <para>
/// This runs on the COMMAND, after the request records have normalized an ABSENT array to the
/// All*() sets, so an empty set reaching here is one a caller sent EXPLICITLY. That also means a
/// direct (non-HTTP) caller is held to the same rule rather than being able to write a dead row.
/// </para>
/// </remarks>
public static class RecurrenceSetBounds
{
public static Option<BaseError> Validate(
ICollection<DayOfWeek> daysOfWeek,
ICollection<int> daysOfMonth,
ICollection<int> monthsOfYear,
ICollection<DayOfWeek> storedDaysOfWeek,
ICollection<int> storedDaysOfMonth,
ICollection<int> storedMonthsOfYear)
{
if (IsNewlyEmpty(daysOfWeek, storedDaysOfWeek))
{
return Some(BaseError.New(Message("DaysOfWeek", "no day of the week")));
}
if (IsNewlyEmpty(daysOfMonth, storedDaysOfMonth))
{
return Some(BaseError.New(Message("DaysOfMonth", "no day of the month")));
}
if (IsNewlyEmpty(monthsOfYear, storedMonthsOfYear))
{
return Some(BaseError.New(Message("MonthsOfYear", "no month")));
}
return Option<BaseError>.None;
}
// "send null" rather than "omit the property": all three are listed in the schema's `required` array
// in v1.json (they are nullable, not optional), so a client generated from the published contract
// cannot omit them. Omitting also works at runtime -- Newtonsoft maps a missing property and an
// explicit null to the same thing -- but naming only that would tell a conforming client to send
// something its own schema forbids.
private static string Message(string field, string consequence) =>
$"[{field}] must not be empty; an empty set matches {consequence}, so the item would never apply. " +
"Send null to leave it unrestricted";
// A new item (no stored row) has `stored` null, so an empty set is newly empty and is rejected.
// Only a stored set that is ITSELF already empty exempts an empty submission.
private static bool IsNewlyEmpty<T>(ICollection<T> submitted, ICollection<T> stored) =>
submitted is { Count: 0 } && stored is not { Count: 0 };
}
@@ -4,6 +4,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
@@ -121,13 +122,200 @@ public class PlayoutHandlerTests
LeftOf(result).Value.ShouldContain("must not be empty");
}
// ---- #880 empty recurrence sets ----
[Test]
public async Task ReplaceAlternateSchedules_Should_Reject_A_Newly_Empty_Recurrence_On_A_Stored_Item()
{
await SeedPlayout(1, version: 1);
var handler = new ReplacePlayoutAlternateScheduleItemsHandler(
_db.Factory,
_worker,
NullLogger<ReplacePlayoutAlternateScheduleItemsHandler>.Instance);
// TWO items: index 1 is the catch-all (highest index), so index 0 is a real alternate whose
// recurrence IS stored. The empty set goes on THAT one.
ReplacePlayoutAlternateSchedule empty = AltItem(index: 0) with { DaysOfWeek = [] };
Either<BaseError, Unit> result = await handler.Handle(
new ReplacePlayoutAlternateScheduleItems(1, [empty, AltItem(index: 1)]),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("[DaysOfWeek]");
LeftOf(result).Value.ShouldContain("no day of the week");
// rejected BEFORE any mutation -- the version bump is the observable proof nothing was written
(await ReadPlayoutVersion(1)).ShouldBe(1);
}
// The catch-all's recurrence is discarded by the handler (only its ProgramScheduleId is used), so an
// empty set there cannot make anything "never apply". Rejecting it would state a reason that is FALSE
// for that item, which is why the check walks `incoming` rather than every submitted item.
[Test]
public async Task ReplaceAlternateSchedules_Should_Allow_An_Empty_Recurrence_On_The_CatchAll_Item()
{
await SeedPlayout(1, version: 1);
var handler = new ReplacePlayoutAlternateScheduleItemsHandler(
_db.Factory,
_worker,
NullLogger<ReplacePlayoutAlternateScheduleItemsHandler>.Instance);
// a single item IS the catch-all
ReplacePlayoutAlternateSchedule catchAll = AltItem(index: 0) with { DaysOfWeek = [], MonthsOfYear = [] };
Either<BaseError, Unit> result = await handler.Handle(
new ReplacePlayoutAlternateScheduleItems(1, [catchAll]),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await ReadPlayoutVersion(1)).ShouldBe(2);
}
[Test]
public async Task ReplaceTemplates_Should_Reject_A_Newly_Empty_Recurrence()
{
await SeedPlayout(1, version: 1);
var handler = new ReplacePlayoutTemplateItemsHandler(
_db.Factory,
NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);
ReplacePlayoutTemplate empty = TemplateItem() with { DaysOfMonth = [] };
Option<BaseError> result = await handler.Handle(
new ReplacePlayoutTemplateItems(1, [empty]),
CancellationToken.None);
result.IfNone(() => throw new AssertionException("Expected a Some(error)"))
.Value.ShouldContain("[DaysOfMonth]");
(await ReadPlayoutVersion(1)).ShouldBe(1);
}
// `api.ffmpeg-profile-numeric-bounds`: reject a NEWLY submitted bad value, not an UNCHANGED one the row
// already holds. Both PUT paths are whole-list replaces, so without this a single pre-existing empty row
// would make every OTHER item in the playout uneditable.
[Test]
public async Task ReplaceTemplates_Should_Allow_An_UNCHANGED_Empty_Recurrence_That_Is_Already_Stored()
{
int templateItemId = await SeedPlayoutWithEmptyTemplateRecurrence();
var handler = new ReplacePlayoutTemplateItemsHandler(
_db.Factory,
NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);
// same row, same empty DaysOfWeek -- an edit to some OTHER field on the same list
ReplacePlayoutTemplate unchanged = TemplateItem() with { Id = templateItemId, DaysOfWeek = [] };
Option<BaseError> result = await handler.Handle(
new ReplacePlayoutTemplateItems(1, [unchanged]),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
(await ReadPlayoutVersion(1)).ShouldBe(2);
}
// ... and the complement: the SAME stored row rejects a DIFFERENT field being newly emptied, so the
// exemption is per-field rather than "this row is grandfathered".
[Test]
public async Task ReplaceTemplates_Should_Still_Reject_A_Different_Field_Newly_Emptied_On_A_Stored_Row()
{
int templateItemId = await SeedPlayoutWithEmptyTemplateRecurrence();
var handler = new ReplacePlayoutTemplateItemsHandler(
_db.Factory,
NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);
// DaysOfWeek is the stored-empty one; MonthsOfYear is stored FULL, so emptying it is new
ReplacePlayoutTemplate item = TemplateItem() with
{
Id = templateItemId,
DaysOfWeek = [],
MonthsOfYear = []
};
Option<BaseError> result = await handler.Handle(
new ReplacePlayoutTemplateItems(1, [item]),
CancellationToken.None);
result.IfNone(() => throw new AssertionException("Expected a Some(error)"))
.Value.ShouldContain("[MonthsOfYear]");
(await ReadPlayoutVersion(1)).ShouldBe(1);
}
private async Task<int> SeedPlayoutWithEmptyTemplateRecurrence()
{
await using TvContext context = _db.CreateContext();
// The handler loads templates with `.Include(p => p.Templates).ThenInclude(t => t.Template)`, and
// that navigation is required -- so a PlayoutTemplate whose Template row does not exist is joined
// OUT and never reaches `existing`. Without seeding this, the stored row is invisible, the
// exemption cannot match, and the test fails for a reason that has nothing to do with the rule.
context.TemplateGroups.Add(new TemplateGroup { Id = 5, Name = "Group", Templates = [] });
context.Templates.Add(new Template { Id = 20, TemplateGroupId = 5, Name = "Template 20", Items = [] });
await context.SaveChangesAsync();
var template = new PlayoutTemplate
{
Index = 0,
TemplateId = 20,
DaysOfWeek = [],
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = false,
StartMonth = 1,
StartDay = 1,
EndMonth = 12,
EndDay = 31
};
context.Playouts.Add(
new Playout
{
Id = 1,
ChannelId = 1,
ProgramScheduleId = 10,
Version = 1,
Items = [],
ProgramScheduleAlternates = [],
Templates = [template]
});
await context.SaveChangesAsync();
return template.Id;
}
// ---- #253 optimistic concurrency (alternate schedules #7 + templates #8, shared Playout.Version) ----
private static ReplacePlayoutAlternateSchedule AltItem(int programScheduleId = 10) =>
new(0, 0, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
// Recurrence sets are UNRESTRICTED here, not empty (#880): an empty set now means "matches no date"
// and is rejected on any item whose recurrence is stored, so an empty fixture would make these
// concurrency tests measure the recurrence guard instead of the version check.
private static ReplacePlayoutAlternateSchedule AltItem(int programScheduleId = 10, int index = 0) =>
new(
0,
index,
programScheduleId,
AlternateScheduleSelector.AllDaysOfWeek(),
AlternateScheduleSelector.AllDaysOfMonth(),
AlternateScheduleSelector.AllMonthsOfYear(),
false,
1,
1,
null,
12,
31,
null);
private static ReplacePlayoutTemplate TemplateItem(int templateId = 20) =>
new(0, 0, templateId, null, [], [], [], false, 1, 1, null, 12, 31, null);
new(
0,
0,
templateId,
null,
AlternateScheduleSelector.AllDaysOfWeek(),
AlternateScheduleSelector.AllDaysOfMonth(),
AlternateScheduleSelector.AllMonthsOfYear(),
false,
1,
1,
null,
12,
31,
null);
private async Task SeedPlayout(int id, int version, int? programScheduleId = 10)
{
@@ -978,7 +978,7 @@ public class PlayoutControllerTests
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
var request = new ReplacePlayoutAlternateSchedulesRequest(
[new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]);
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, true, startMonth, startDay, null, endMonth, endDay, null)]);
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
@@ -1002,7 +1002,7 @@ public class PlayoutControllerTests
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
var request = new ReplacePlayoutAlternateSchedulesRequest(
[new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]);
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, true, startMonth, startDay, null, endMonth, endDay, null)]);
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
@@ -1025,7 +1025,7 @@ public class PlayoutControllerTests
// LimitToDateRange is false, so the out-of-range month/day here must not block the save.
var request = new ReplacePlayoutAlternateSchedulesRequest(
[new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], false, 0, 0, null, 13, 32, null)]);
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, false, 0, 0, null, 13, 32, null)]);
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
@@ -1079,6 +1079,110 @@ public class PlayoutControllerTests
Arg.Any<CancellationToken>());
}
// ----- #880: absent recurrence means UNRESTRICTED, an explicit [] is rejected -----
// Reddens if ToReplaceItem's `?? All*()` is reverted to `?? []`: the counts drop to 0. That is the
// point of the test -- the normalization is the fix, so it is what must be pinned.
[Test]
public async Task ReplaceAlternateSchedules_Should_Normalize_Absent_Recurrence_To_Unrestricted()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
_mediator.Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>())
.Returns([MakeScheduleVm(7)]);
_mediator.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>())
.Returns([MakeAltVm(1, 0, 7)]);
// All three recurrence arrays omitted -- the shape an API client sends and the SPA never does.
var request = new ReplacePlayoutAlternateSchedulesRequest(
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, false, 1, 1, null, 12, 31, null)]);
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
await _mediator.Received(1).Send(
Arg.Is<ReplacePlayoutAlternateScheduleItems>(c =>
c.Items.Count == 1 &&
c.Items[0].DaysOfWeek.Count == 7 &&
c.Items[0].DaysOfMonth.Count == 31 &&
c.Items[0].MonthsOfYear.Count == 12),
Arg.Any<CancellationToken>());
}
// The complement of the test above: normalization must fill in ONLY what was absent. Without this a
// fix that substituted All*() unconditionally would still pass the normalization test.
[Test]
public async Task ReplaceAlternateSchedules_Should_Preserve_An_Explicit_Recurrence_Selection()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
_mediator.Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>())
.Returns([MakeScheduleVm(7)]);
_mediator.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>())
.Returns([MakeAltVm(1, 0, 7)]);
var request = new ReplacePlayoutAlternateSchedulesRequest(
[
new PlayoutAlternateScheduleItemRequest(
0,
7,
[DayOfWeek.Monday, DayOfWeek.Tuesday],
null,
[6],
false,
1,
1,
null,
12,
31,
null)
]);
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
await _mediator.Received(1).Send(
Arg.Is<ReplacePlayoutAlternateScheduleItems>(c =>
c.Items[0].DaysOfWeek.Count == 2 &&
c.Items[0].DaysOfWeek.Contains(DayOfWeek.Monday) &&
c.Items[0].DaysOfMonth.Count == 31 &&
c.Items[0].MonthsOfYear.Count == 1 &&
c.Items[0].MonthsOfYear.Contains(6)),
Arg.Any<CancellationToken>());
}
[Test]
public async Task ReplaceTemplates_Should_Normalize_Absent_Recurrence_To_Unrestricted()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(
Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
_mediator.Send(Arg.Any<GetAllTemplates>(), Arg.Any<CancellationToken>())
.Returns([MakeTemplateViewModel(7)]);
_mediator.Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.None);
_mediator.Send(Arg.Any<GetPlayoutTemplates>(), Arg.Any<CancellationToken>())
.Returns([MakeTemplateVm(1, 0, 7, null)]);
var request = new ReplacePlayoutTemplatesRequest(
[new PlayoutTemplateItemRequest(0, 7, null, null, null, null, false, 1, 1, null, 12, 31, null)]);
IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
await _mediator.Received(1).Send(
Arg.Is<ReplacePlayoutTemplateItems>(c =>
c.Items.Count == 1 &&
c.Items[0].DaysOfWeek.Count == 7 &&
c.Items[0].DaysOfMonth.Count == 31 &&
c.Items[0].MonthsOfYear.Count == 12),
Arg.Any<CancellationToken>());
}
// ----- Playout templates -----
[Test]
@@ -1187,7 +1291,7 @@ public class PlayoutControllerTests
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
var request = new ReplacePlayoutTemplatesRequest(
[new PlayoutTemplateItemRequest(0, 7, null, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]);
[new PlayoutTemplateItemRequest(0, 7, null, null, null, null, true, startMonth, startDay, null, endMonth, endDay, null)]);
IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None);
@@ -1212,7 +1316,7 @@ public class PlayoutControllerTests
// LimitToDateRange is false, so the out-of-range month/day here must not block the save.
var request = new ReplacePlayoutTemplatesRequest(
[new PlayoutTemplateItemRequest(0, 7, null, [], [], [], false, 0, 0, null, 13, 32, null)]);
[new PlayoutTemplateItemRequest(0, 7, null, null, null, null, false, 0, 0, null, 13, 32, null)]);
IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None);
@@ -1400,7 +1504,7 @@ public class PlayoutControllerTests
new(id, index, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
private static PlayoutAlternateScheduleItemRequest MakeAltRequest(int programScheduleId) =>
new(0, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
new(0, programScheduleId, null, null, null, false, 1, 1, null, 12, 31, null);
private static ProgramScheduleViewModel MakeScheduleVm(int id) =>
new(id, $"Schedule {id}", false, false, false, false, FixedStartTimeBehavior.Strict, null, 0);
@@ -1426,7 +1530,7 @@ public class PlayoutControllerTests
null);
private static PlayoutTemplateItemRequest MakeTemplateRequest(int templateId, int? decoTemplateId) =>
new(0, templateId, decoTemplateId, [], [], [], false, 1, 1, null, 12, 31, null);
new(0, templateId, decoTemplateId, null, null, null, false, 1, 1, null, 12, 31, null);
private static PlayoutNameViewModel MakePlayout(int id) =>
new(
@@ -1,13 +1,15 @@
#nullable enable
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Controllers.Api.Requests;
public record PlayoutAlternateScheduleItemRequest(
int Id,
int ProgramScheduleId,
List<DayOfWeek> DaysOfWeek,
List<int> DaysOfMonth,
List<int> MonthsOfYear,
List<DayOfWeek>? DaysOfWeek,
List<int>? DaysOfMonth,
List<int>? MonthsOfYear,
bool LimitToDateRange,
int StartMonth,
int StartDay,
@@ -16,14 +18,26 @@ public record PlayoutAlternateScheduleItemRequest(
int EndDay,
int? EndYear)
{
// An ABSENT recurrence array means UNRESTRICTED, not "matches nothing" (#880). The three sets are
// conjunctive in AlternateScheduleSelector.GetScheduleForDate -- a miss on any one `continue`s -- so
// the previous `?? []` stored an item that could never apply on any date, and returned 200 while
// doing it. Absence now normalizes to the same All*() sets the READ side substitutes for a NULL
// column (AlternateScheduleSelector's read guard and both Mapper.ProjectToViewModel overloads), so
// the two halves of "this field is absent" finally agree -- residual (3) of
// `media.nullable-primitive-collection-mutation`.
//
// These are nullable so that ABSENT is distinguishable from an explicitly-sent `[]`, which is a
// different request and is REJECTED with a 422 in PlayoutController rather than normalized here.
// Newtonsoft maps both a missing property and an explicit `null` to null, so both read as absent;
// only a literal `[]` survives as empty.
public ReplacePlayoutAlternateSchedule ToReplaceItem(int index) =>
new(
Id,
index,
ProgramScheduleId,
DaysOfWeek ?? [],
DaysOfMonth ?? [],
MonthsOfYear ?? [],
DaysOfWeek ?? AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth ?? AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear ?? AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange,
StartMonth,
StartDay,
@@ -1,4 +1,6 @@
#nullable enable
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Controllers.Api.Requests;
@@ -6,9 +8,9 @@ public record PlayoutTemplateItemRequest(
int Id,
int TemplateId,
int? DecoTemplateId,
List<DayOfWeek> DaysOfWeek,
List<int> DaysOfMonth,
List<int> MonthsOfYear,
List<DayOfWeek>? DaysOfWeek,
List<int>? DaysOfMonth,
List<int>? MonthsOfYear,
bool LimitToDateRange,
int StartMonth,
int StartDay,
@@ -17,15 +19,18 @@ public record PlayoutTemplateItemRequest(
int EndDay,
int? EndYear)
{
// Same contract as PlayoutAlternateScheduleItemRequest: ABSENT means UNRESTRICTED (the All*() sets
// the read side substitutes for a NULL column), an explicit `[]` is rejected with a 422 in
// PlayoutController. See that record for the full rationale (#880).
public ReplacePlayoutTemplate ToReplaceItem(int index) =>
new(
Id,
index,
TemplateId,
DecoTemplateId,
DaysOfWeek ?? [],
DaysOfMonth ?? [],
MonthsOfYear ?? [],
DaysOfWeek ?? AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth ?? AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear ?? AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange,
StartMonth,
StartDay,
+40
View File
@@ -383,6 +383,46 @@ configurable — never materialized into a stored number on save, so an untouche
identically. Document the range in the schema with `[property: Description("…")]` on the request
record's positional parameter (`System.ComponentModel`); it renders into `v1.json`.
### 3e. An ABSENT collection means unrestricted; an explicitly EMPTY one is rejected
A nullable collection on a write path carries two different requests that are easy to collapse into
one, and collapsing them is how ersatztv#880 shipped a 200 that stored a row which could never apply.
Decide both, separately:
- **Absent** (the property is missing, or explicitly `null` — Newtonsoft maps both to `null`) means
*the client is not expressing a restriction*. Normalize it to the **permissive** value, which is
whatever the READ side already substitutes for the same absence. It must not become the empty set:
for a filter, empty is the maximally *restrictive* value, so `?? []` silently inverts the request.
- **Explicitly empty** (`[]`) is a different, well-formed request. If the empty set has no meaningful
outcome — a conjunctive filter that then matches nothing — **reject it with a 422 naming the
consequence**, per §3d. Do not accept-then-rewrite it into the permissive value: that would make
`[]` and absence indistinguishable again, in the other direction.
Distinguishing them requires the request record's property to be **nullable** (`List<T>?`, with a
per-file `#nullable enable` where the project has annotations off), because `?? …` on a
non-nullable-annotated `List<T>` cannot tell absence from empty. Normalization belongs in the request
record's `ToReplaceItem`; **rejection does not belong beside it.** Three rules that come with it:
- **Say "send `null`", not "omit the property".** Making a C# property nullable does NOT make it
optional in the generated schema: all three recurrence properties are still listed in `required` in
`v1.json`, with type `["null","array"]`. A client generated from the published contract therefore
*cannot* omit them, and an error message telling it to would be instructing a schema violation.
Omission still works at runtime; `null` is the form that is also contract-legal.
- **Validate where the STORED value is in hand — the handler, not the controller.** §3d's rule that an
*unchanged* bad value must still be accepted applies here in its sharpest form: these are whole-list
replace PUTs, so rejecting a pre-existing empty set would make every *other* item in the list
uneditable over a row the operator never touched. That comparison needs the stored row, which the
controller does not have and the handler already loaded. Exemplar: `RecurrenceSetBounds`
(`ErsatzTV.Application/Scheduling`), one validator called from both replace handlers — the same shape
as `FFmpegProfileBounds`. The exemption is **per field**, not per row: a row grandfathered on
`DaysOfWeek` still cannot newly empty `MonthsOfYear`.
- **Derive the validated set from the list the handler actually writes.** In the alternate-schedule
path that is `incoming`, which *excludes* the highest-`Index` catch-all — the handler discards that
item's recurrence along with its date range, so an empty set there cannot make anything "never
apply" and rejecting it would state a reason that is false for that item. Walking the same list the
writes iterate is what keeps the check and its subject from drifting; do not re-derive "which item is
the catch-all" in a second place (`api.put-replace-index-order`).
## 4. Artwork contract
API response DTOs return **rooted, directly-usable artwork URLs** — e.g. `/artwork/posters/...`,
+204 -47
View File
@@ -41,6 +41,7 @@ Upstream's final release was **`v26.3.0`** (archived). Our line continues from t
| `v26.12.0` | **`ErsatzTV.Mcp` MCP server** — read + cautious-write over `/api/v1`, `ERSATZTV_ALLOW_WRITES`-gated (#58). **External channel-logo URLs download + cache at save time** (#525), with the on-screen bug now rendered for external-URL logos (#502). HLS cold-start hardening: burst-read the first segments so start isn't `-readrate`-bound (#350) and floor QSV extra hardware frames so an unthrottled read can't exhaust the pool (#529); remote graphics-engine image fetches bounded — timeout, size cap, decode cap, redirects, pooling (#511). Decision-lifecycle tooling + parallel-orientation startup rewrite (#520/#521); CI `docker build` lane rebalance (#508). |
| `v26.13.0` | **RuleBuilder maturation** — arbitrary-depth group nesting (#436), inline smart-query authoring in Channel Builder (#437), DB-sourced facet typeahead + relative-date operators + validation (#434/#435/#438), and an artist typeahead covering music-video/song credits with `album_artist` no longer 404ing (#578). **Per-channel On Now/Next transient overlay** (#74/#570) and **per-schedule clock-boundary padding** (#392); in-browser channel preview (#60); Auto-Tune per-source weight steppers + exclude/add-untagged (#440). Library-browse pickers now resolve by search instead of a 100-row window, closing several silent at-cap truncations (#644/#650/#651/#634). Correctness: one watermark resolver for all four attachment points, incl. `MiddleCenter` (#503/#510); QSV HDR tonemaps through OpenCL because `vpp_qsv=tonemap` is a silent no-op (#505); `LibraryFolder` unique index + concurrent-insert tolerance (#491); per-library music-video identity with soft trash (#496); Jellyfin Album/Track music-video projection (#177); metadata-collection dedup (#500); accented facet values via a registered Unicode fold on SQLite (#668); `WorkAheadSlots` atomic slot claim, never a negative count (#536/#539); on-demand guide rebuild on thaw (#68). Process/CI: the H10 review-verdict gate became a sha-bound **required** commit status and was hardened through its false-open chain (#622/#629/#632/#648/#649/#672/#698), the decision corpus split to one YAML-frontmatter record per file (#610/#620), and headless Playwright UI-E2E flows landed (#445/#533). Five dual-provider migrations. |
| `v26.14.0` | **Live TV no longer starves on embedded bitmap subtitles** — `-readrate` paces an input off its *furthest-behind* stream, and a PGS/DVD subtitle read through the video's own `-i` is sparse enough to drag the whole process to **0.53x realtime** against the 1.0x a client consumes, draining the buffer until the channel stalls. Fixed with a capability-gated `-readrate_catchup` (ffmpeg 8.0+) on realtime inputs, keeping `-readrate` on the frame-producing path so the `ffmpeg.qsv-extra-hw-frames-floor` bound is untouched; measured 0.533x → 1.067x on QSV and software, with a 240s QSV soak clean of allocation errors (#726). Affects items carrying an embedded bitmap subtitle matching the channel's subtitle mode — 3,182 of 24,646 media versions on prod, and a property of the *item*, not the channel, which is why the stall presented as random. Process/CI: the H10 review-verdict gate's repair sentinel became a fixed point and its write is now fenced on the timeline retarget count, closing a raced-sentinel false-open (#706/#707/#711). **The decisions validator now cross-checks its dependency-free frontmatter parse against PyYAML** and reports both the truncating unquoted `` #`` and the scalar-closing bare apostrophe as errors, so a record whose `rule:` silently halves under PyYAML fails the local gate instead of CI (#674/#688) — the ceiling-calibration claim was also split so the suite pins what the derivation MEANS rather than live-corpus order statistics. Dependencies: CliWrap 3.10.4, JetBrains.ReSharper.GlobalTools 2025.3.5. |
| `v26.15.0` | **`main` became PR-only, which is what makes `review-verdict/h10` load-bearing** — branch protection now carries `enable_push: false` *and* `block_admin_merge_override: true`, so a direct `git push origin HEAD:main` is refused at pre-receive for every account including a site admin, and an admin cannot force-merge past a missing or red required context (#743/#749). Gitea only evaluates `status_check_contexts` on the PR merge path, so before this the whole gate was skippable with no forgery needed. The gate was then hardened through the rest of its false-open chain: CI's registry credential scoped so a head-resolved workflow cannot forge the status (#697), a stray expression delimiter **in a comment** that killed the gate outright and is now banned on the release path itself by a `scan` job `build` depends on (#751/#767), a positive verdict inherited only from an allow-listed reviewer (#742), the PR-timeline `HEAD` alias fenced on the `pull_push` count (#803/#664), and both `/statuses/{sha}` reads paged to a validated terminator (#763). App-facing: **FFmpeg profile numeric fields are bounded with a 422 that names the bound and its consequence** instead of being silently rewritten, and readrate pacing is exposed as a profile field (#735 — one dual-provider migration, `Add_FFmpegProfile_ReadRatePacing`). **On Now / Next gets a boxed background and is on by default** (#732). Field-level progressive disclosure lands as a shared `FieldHelp` trigger + portalled panel, adopted on FFmpeg Profiles and documented as `docs/spa-conventions.md` §15 (#734). Auto-Tune proposal rows align on a grid (#721); `AddItemsDialog` resolves by search instead of windowing a whole media-library type, with stale-response and is-mounted guards (#685/#740). Correctness: a paged handler now counts **the same query it pages**, so a filtered page stops reporting the unfiltered total across playouts, rerun collections and six sibling handlers (#690/#758); nullable primitive collections are guarded at the read site rather than mutated on a tracked entity — `SongMetadata.Artists` in both search indexers (#701/#824) and the scheduling `DaysOfMonth`/`MonthsOfYear`/`DaysOfWeek` columns, where a runtime NULL reads as **unrestricted** (#823); SPA full-replace bodies are built as `Complete<T>` so an omitted optional DTO field is a type error (#807); and every `ErsatzTV.Mcp` tool is pinned to its OpenAPI contract, with `graphicsElementIds` and `padToNearestMinute` declared so an MCP channel update stops silently stripping the overlay (#754/#757). Process/CI: one rule for how a guard derives its population and one for the proof it ships with, both enforced (#774/#775) — populations come from the git index, never a filesystem walk (#806/#819), every `MUTATION` grade is executed each suite rather than asserted in prose (#790), and verification code is itself code under test (#796). Workflows declare their own per-job metadata and guard populations derive from it (#786/#789); all six declare `permissions:` (#748); `ci-image.yml` publishes from `main` only and the persisted checkout credential is gone (#744/#746/#835). Hooks report their own execution and the report is measured (#776), and `docs.no-session-narrative` was generalized to every durable artifact and the sites its sweep IDENTIFIED were remediated as judgements rather than by regex — no closure is claimed over a phrasing space (#784/#812). A ruff config is committed and enforced in CI (#780). Dependencies: Scriban.Signed 7.2.6. **Cut at `736649b3b`, not at `main`'s head**: `completeAnnotations.guard.test.ts` (merged in the very next commit) needs a git index the Dockerfile's `web-build` stage does not have, so every image build after `736649b3b` fails — including a tag build (#887). `736649b3b` is the newest commit whose `Build & push image (amd64)` actually ran green. The scheduling-recurrence fix #880 therefore slips to the next release. |
**Before cutting a release — sweep `docs/decisions.md` + `docs/decisions/`** (ersatztv#521, supersedes
the ersatztv#303 H9 append-only ritual). Supersession/retirement is now a same-PR act (add the new
@@ -313,7 +314,9 @@ further back than N. Measured 2026-08-26: a 150-commit repo branched 130 back, `
returns the correct file list. Under the old `|| true` that surfaced as a silent skip; unmasked it
would have reddened `api-docs` and `Formatting` on any branch not rebased recently, sent
`docs-reminder`'s parity nudge advisory-red, and left `decisions lifecycle` green with its
diff-dependent checks silently skipped — so the depth came off with the mask. The same graft is why `:latest` images ship `InformationalVersion 0.0.0-<sha>` (ersatztv#836).
diff-dependent checks silently skipped — so the depth came off with the mask. The same graft,
reached through the detector script rather than a workflow step, is what stamped `:latest` images
`InformationalVersion 0.0.0-<sha>` until ersatztv#836 — see below.
**Two masked fetches that stay masked, deliberately.** `scripts/ci-detect-docs-only.sh` and
`scripts/ci-detect-already-validated.sh` both swallow a failed fetch — and should. In both, a failed
@@ -323,14 +326,38 @@ runner time and nothing else, which is the opposite direction from the workflow
the same masking bought a silently skipped check. Judge these by which way the failure falls, not by
the `|| true`.
That clears their **masking** and nothing else. Their `--depth` is a separate question with a
separate answer: `ci-detect-docs-only.sh`'s push-path `--depth=2` runs inside the `build` job's
`fetch-depth: 0` checkout and grafts it shallow, which is why the `git describe` in the immediately
following step returns nothing and `:latest` ships `InformationalVersion 0.0.0-<sha>` — measured on
the live test container as `0.0.0-5a7a4ed7` (ersatztv#836). That depth is correct for the script's
other three consumers — `test`, `migrations` and `functional-e2e` — whose checkouts really are
shallow (`fetch-depth: 2`). `build` is the only one that checks out complete, which is why it is the
only one that grafts.
That clears their **masking** and nothing else. Their `--depth` was a separate question with a
separate answer, and it is the one that shipped a defect (ersatztv#836).
**A depth is passed only to a checkout that is already shallow.** `ci-detect-docs-only.sh` routes
both its fetches through `fetch_ref`, which asks `git rev-parse --is-shallow-repository` and drops
the `--depth` on any answer but `true`. The script has **exactly four consumers** — `test`,
`migrations` and `functional-e2e` at `fetch-depth: 2`, and `build` at `fetch-depth: 0`. Enumerate
them with `grep -rn ci-detect-docs-only .gitea/`, not by looking for a step with `id: detect`:
`api-docs` and `format` have one of those too, and it runs their own inline diff.
So `build` is the only complete-clone consumer, and the push arm is the only arm it takes. That is
where the cost landed: its `--depth=2` grafted `build`'s complete clone, the `git describe --tags`
in the immediately following step found no reachable tag, and a `|| echo v0.0.0` fallback that
cannot fail turned that into a version. Every `:latest` image published from `main` carried
`InformationalVersion 0.0.0-<sha>` instead of `26.x.y-<sha>` — read off the live test container as
`0.0.0-5a7a4ed7` on 2026-08-26, six weeks after the depth landed with #416 on 2026-07-17, because
nothing went red.
The PR arm's `--depth=200` had **no complete-clone consumer at all** (on `pull_request` only the
three shallow jobs run this script), so it was inert rather than latently firing. It goes through
the same helper anyway, because the clause is shared and the next `fetch-depth: 0` consumer of that
arm would inherit the graft silently. Keeping the depth for the shallow three is likewise
conservative rather than required: a depth-less fetch into a `--depth=2` clone leaves `.git/shallow`
in place, still writes `FETCH_HEAD`, and still reports the parents the push arm reads.
Both halves are now held by something that executes. `scripts/tests/test_docs_only_detector_clone_depth.py`
drives the real script over real `file://` clones and asserts the clone is still complete and
`git describe` still resolves afterwards, with a negative control that requires the fixture to be
able to graft at all — a plain path clone silently ignores `--depth`, which would make the whole
file vacuous. And `Compute version and tags` no longer defaults: a failed `git describe` on the
non-tag path fails the job rather than stamping `0.0.0`, so no `:latest` is published instead of a
mislabelled one. The tag path never calls `describe`, so a release cut is unaffected either way.
### The last exemption, and how it closed
@@ -1228,11 +1255,15 @@ genuine pytest regression surface as precisely the red everyone is told to wave
name keeps a real failure unambiguous.
**Why it runs unconditionally** rather than behind a `scripts/**` path filter: the suite's true
input set spans more than one directory — `test_post_review_verdict.py` and
input set spans AT LEAST three top-level directories. `test_post_review_verdict.py` and
`test_merge_consent_exemption.py` execute the real `scripts/post-review-verdict.sh` and
`.claude/hooks/pretooluse-merge-consent.sh` — so a `scripts/**` filter would silently miss a
`.claude/hooks/**` edit. The reason is the input set, not the cost: the suite was ~10s when that was
decided and is now ~4.5 min, and it would still be wrong to filter on `scripts/**`.
`.claude/hooks/pretooluse-merge-consent.sh`, and since ersatztv#845 the former also READS
`.gitea/workflows/review-verdict.yml`, because the writer derives the H10 allow-list from it and
editing that literal changes the suite's outcome. So a `scripts/**` filter would silently miss a
`.claude/hooks/**` or `.gitea/workflows/**` edit. The hedge is deliberate: the corpus tests read
`docs/` too, so the real figure is higher, and an exact count here is the kind that goes stale.
The reason is the input set, not the cost: the suite was ~10s when that was decided and is now
minutes, and it would still be wrong to filter on `scripts/**`.
**It also lints (ersatztv#780).** Early in the job it installs a **pinned** `ruff==0.12.11` and runs
`ruff check` and `ruff format --check` against the repo-root `ruff.toml`. Five things are deliberate:
@@ -1563,8 +1594,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
@@ -1586,12 +1618,45 @@ account to `H10_REVIEWERS` in `.gitea/workflows/review-verdict.yml`. (A separate
annotation carrying the same three values was tried and withdrawn: its only delta was a severity whose
rendering could not be probed, and it needed a guard of its own to stop contradicting the line above
it.) `post-review-verdict.sh` posts
with whatever account owns `ETV_GITEA_TOKEN`/`ETV_GITEA_BASICAUTH` and does **not** check that it is an
allow-listed one (ersatztv#845). A changed writer credential does not fail loudly — it reports success,
and each *subsequent* PR event re-derives the status: to `pending` on a PR that needs a verdict, or to
an exemption **`success`** on a docs-only or Renovate-manifest one, which is the quieter and worse
half. A verdict posted after the last event stands until the next one, so the symptom is a PR that
keeps losing its verdict rather than an immediate refusal.
with whatever account owns `ETV_GITEA_TOKEN`/`ETV_GITEA_BASICAUTH`, and since ersatztv#845 it
**checks that it is an allow-listed one** — at the terminal that made the mistake, not one PR event
later. It reads the status back after posting, identifies its own write by state and description, and
refuses **before writing the comment** unless `.creator.login` is a member; a `creator: null`
(Actions-token) status gets its own message, since there is no account to name. The allow-list is
DERIVED from the `H10_REVIEWERS` literal in `review-verdict.yml` by `scripts/lib/h10-reviewers.sh`,
not restated beside it, so there is one declaration rather than two plus a parity test.
**The membership requirement is `success`-only, mirroring the gate's own asymmetry.** An existing
`failure` is inherited on ATTRIBUTABILITY alone, whichever account recorded it, so requiring
membership for a `BLOCKED` verdict would refuse one the gate honours perfectly well — and would
leave an off-list reviewer no supported way to record a rejection, since the comment would be
withheld too. A negative verdict from any attributable account is written and commented normally.
What that changes is the *diagnostic*, not the safety direction: a non-allow-listed POSITIVE verdict
was always a stall, never a fail-open. Before, a changed writer credential reported success and each
*subsequent* PR event re-derived the status — to `pending` on a PR that needs a verdict, or to an
exemption **`success`** on a docs-only or Renovate-manifest one, which is the quieter and worse
half — so the symptom was a PR that kept losing its verdict rather than an immediate refusal. Now it
is an immediate refusal naming the account and the allow-list.
**The residual, stated precisely, because "the `ask` half-state" describes only half of it.** The
check runs after the POST, so a refused verdict leaves a `review-verdict/h10` **standing on the
head** with no comment beside it. On the *hook* path that is the safe half-state
`release.verdict-writes-status-before-comment` designates — nothing for condition (c) to classify,
so it asks rather than granting. On the *server* path it is weaker: branch protection binds the
context NAME and not its issuer, so a `success` posted by any account satisfies the required check
until the gate re-derives it on the next PR event. **A refusal from this tool is not a reason to
trust the green it leaves behind.** That is not a regression — it was equally true before #845, when
the tool reported success and said nothing — and it is deliberately not repaired here, because a
second corrective write is the sticky-sentinel mechanism ersatztv#849 is separately designing.
**One false ACCEPT, named because the direction matters.** The writer reads the workflow in the local
checkout while the gate runs the base-resolved one, so on a branch that ADDS a reviewer the writer
accepts an account the live gate will not yet inherit from — the #845 stall, reached through the
check meant to catch it, on the one branch where someone is editing the allow-list. Accepted:
closing it means a live remote read on the merge-consent write path, whose own read failure would
have to resolve either to refusing every verdict or to passing. The window is one PR long and the
residual is a stall, never an unreviewed merge.
This remains a *provenance* check, not an authentication one — an **allow-listed** collaborator's own
write-scoped token can still POST a verdict they did not form, branch protection binds the context
@@ -1626,20 +1691,34 @@ 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
a PR, because every retarget fires `edited` — the event that makes one run abstain has already queued
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
If the count can't be established (unreadable timeline, an empty FIRST page, or paging that reached
the 20-page cap on a NON-empty one — since #870 an empty page before the cap no longer ends the walk),
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*
@@ -1668,16 +1747,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`,
@@ -1898,14 +2021,28 @@ Two things kept it invisible, and both are worth generalising:
fidelity, that claim is a test assertion and needs re-measuring like any other.
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.
An empty page — `null` OR `[]`, which take the SAME rule since ersatztv#803 — is accepted as
exhaustion only from **page 2 on** — every real PR's first page carries events
(spot-checked non-empty across #752/#753/#749/#739/#717; the counts are deliberately not recorded here
because timelines grow and such figures go stale within days), so an empty first
page is ANOMALOUS rather than EXHAUSTED, and the walk should not certify "no retarget happened" from a
response it cannot explain.
exit-status semantics already bit this workflow once at jq 1.6, and both `null` and `[]` are read as
the same EMPTY shape. The regression test is parameterised over both because both are live on this
server. An empty page — `null` OR `[]`, which take the SAME rule since ersatztv#803 — is **no longer
accepted as exhaustion before the page cap** (ersatztv#870, 2026-08-30). Gitea pages this endpoint at the database level and
filters afterwards, so a page whose 50 rows are all inline-code comments serializes as exactly the
value the walk used to trust as the end of the list, while later pages still hold events — 50 comments,
which a PR author can create on their own PR, bought silence on both axes. The walk now SKIPS an empty
page before the cap, reads every page up to its 20-page cap, and trusts the counts only when the LAST
page it read came back empty — the same rule the status walk above states for the CAP, though not for termination: that walk still returns on its first empty page, and whether it needs the same change is tracked at ersatztv#893 rather than assumed (measured 2026-08-30, `/statuses/{sha}` returns a TRUE `X-Total-Count`, which this endpoint does not). **That
moves the inference to the cap rather than removing it, and the residual is real**: pages 1-19 cover
offsets 0..949, so a timeline of more than 1000 rows whose offsets 950..999 are all filtered still
returns an empty page 20, and the walk publishes a too-low count as trusted. Both re-reads share the
blind spot, so this is the same defeat at about 10x the price — the 50-row filtered block is the
same, but the timeline it must sit in grows from ~100 rows to over 1000, and on their own PR the
padding is the attacker's to supply — not a closed hole. It is NOT the status walk's over-cap residual: `/statuses/{sha}` does not
filter after paging, so only here can a page-20 response be empty while later pages hold rows. An empty
FIRST page still ends the walk untrusted: every real PR's first page carries events (spot-checked
non-empty across #752/#753/#749/#739/#717; the counts are deliberately not recorded here because
timelines grow and such figures go stale within days), so an empty first page is ANOMALOUS rather than
EXHAUSTED, and the walk should not certify "no retarget happened" from a response it cannot explain.
`X-Total-Count` cannot shortcut this: on this endpoint it is the post-filter length of the PAGE, not a
total (`?limit=1` returns 1 on a 14-row timeline), unlike `/activities/feeds`, which does return one.
**The same nil-slice shape bites `/commits/{sha}/status`** — a third instance. A head with no statuses yet returns
`{"state":"pending","total_count":0,"statuses":null}` (measured on PR #739's head). `read_existing_verdict`
@@ -2090,7 +2227,8 @@ like every other line in the file, so a branch that *deletes* it re-enables the
What is closed is the **drive-by** case: an ordinary push of a legitimate `docker/ci` change
publishing an image nobody asked for, with no deliberate act anywhere in the loop. Against someone
who intends to run their own YAML this is not a boundary at all, and citing it as one is the mistake
to avoid — see #853.
to avoid — see #853, which settled its DISPATCH row; the `v*` tag-push and `pull_request:` rows are
live in #885.
**What this does not close — beginning with this file's own filter, then sorted by REF CLASS
rather than by which keyword a trigger carries.**
@@ -2101,7 +2239,26 @@ that commit's `docker-build.yml` and can publish `ersatztv:prod` — no dispatch
`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml` and `renovate.yml` all carry an
unrestricted `workflow_dispatch`, which loads its definition from the selected ref. The filter here
removes the *drive-by* route, where merely pushing a branch was enough; it is not a boundary against
a malicious writer. The whole remaining class is tracked in #853.
a malicious writer.
**That remaining class was probed and ACCEPTED in #853 — read this before proposing a fix for it.**
Gitea 1.27.1 has no way to restrict `workflow_dispatch` by ref (the API takes `ref` as a required
free-form string) and no protected-environment concept to gate a secret behind — zero of its 308
documented API paths mention "environment", and the config file the running server names in its own
`--config` sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`. More to the point,
restricting dispatch would close nothing: the `pull_request:` row above is cheaper than any
dispatch, because it runs **attacker-authored YAML**, which can name any secret in the repo store —
not only the ones the committed workflows reference. That is what makes it cover `renovate.yml`'s
`RENOVATE_TOKEN` without dispatching `renovate.yml` at all. Concretely today, six jobs in
`docker-build.yml` hold `REGISTRY_PASSWORD` and run on the PR route (`toolchain-preflight`, `test`,
`migrations`, `functional-e2e`, `api-docs`, `format`), two of them required contexts — carry that as
*every job on the PR route that names `secrets.REGISTRY_PASSWORD`*, not as the list, and not as
"every `container:` job" either: `toolchain-preflight` is container-free and would fall out of that
predicate. "Push a branch, open a PR" therefore reaches the credential with **no act outside the
ordinary contribution flow**, where a dispatch costs one. (`dependency-scan.yml` references no
secrets at all.) Full reasoning and the probe: `ci.workflow-dispatch-ref-unrestricted`. The live
residuals are that PR route and the `v*` tag push, tracked in #885 — along with `tag_protections`,
which is still empty.
**A `v*` tag push does not fire `ci-image.yml`.** There is no `tags:` key, and a `branches:` filter
is compared against a branch ref — so a release cut no longer republishes the toolchain image as a
File diff suppressed because one or more lines are too long
@@ -0,0 +1,47 @@
---
key: api.absent-collection-means-unrestricted
title: '2026-08-30 — An ABSENT request collection means unrestricted; an explicitly EMPTY one is rejected with a 422 (#880)'
status: active
since: '2026-08-30'
supersedes: none
superseded-by: none
rule: 'A nullable collection on a write path carries TWO distinct requests and they get DIFFERENT answers. ABSENT (property missing, or explicit `null` — Newtonsoft maps both to null, so they are indistinguishable and are treated as ONE case) means the client expresses NO restriction and NORMALIZES to the PERMISSIVE value; EXPLICITLY EMPTY (`[]`) is a well-formed request whose outcome is meaningless for a conjunctive filter, and is REJECTED with a 422 naming the consequence rather than rewritten. Collapsing the two is what ersatztv#880 records: `?? []` on the three recurrence arrays of `PlayoutAlternateScheduleItemRequest` / `PlayoutTemplateItemRequest` returned HTTP 200 and stored a row that could NEVER apply on any date, because `AlternateScheduleSelector.GetScheduleForDate` treats DaysOfWeek/DaysOfMonth/MonthsOfYear CONJUNCTIVELY (a miss on any one `continue`s), so empty is the maximally RESTRICTIVE value, not a neutral one. The permissive value is not chosen freshly: it must be the SAME SYMBOL the read side substitutes for the same absence — here `AlternateScheduleSelector.All*()`, already used by the selector''s null guard and both `Mapper.ProjectToViewModel` overloads (`media.nullable-primitive-collection-mutation`), whose residual (3) this closes. Distinguishing absence from empty REQUIRES the property to be nullable (`List<T>?`), which in the `ErsatzTV` web project needs a per-file `#nullable enable` — the project has annotations off, and sibling request records already use that form. THE TWO HALVES LIVE IN DIFFERENT LAYERS AND THAT SPLIT IS THE DECISION: normalization sits in the request record''s `ToReplaceItem`, but REJECTION SITS IN THE HANDLER, in `RecurrenceSetBounds` (`ErsatzTV.Application/Scheduling`) called from BOTH replace handlers — the `FFmpegProfileBounds` shape. It CANNOT sit beside the normalization in the controller, because `api.ffmpeg-profile-numeric-bounds`'' rule that an UNCHANGED bad value must still be accepted binds here in its sharpest form: both PUT paths are WHOLE-LIST replaces, so rejecting a pre-existing empty set would make every OTHER item in the list uneditable over a row the operator never touched. That comparison needs the STORED row, which the controller does not have and the handler already loaded. The exemption is PER FIELD, not per row — a row grandfathered on `DaysOfWeek` still cannot newly empty `MonthsOfYear` — and a STORED NULL is NOT exempt, because null means unrestricted, so submitting `[]` against it is a new emptying. The validated set is DERIVED from the list the handler actually writes (`incoming`), which EXCLUDES the highest-`Index` catch-all: the handler discards that item''s recurrence along with its date range, so an empty set there cannot make anything "never apply" and rejecting it would state a reason that is FALSE for that item — do not re-derive "which item is the catch-all" in a second place (`api.put-replace-index-order`). Finally, the error message says SEND NULL, not "omit the property": making a C# property nullable does NOT make it optional in the generated schema — all three are still listed in `required` in `v1.json` with type `["null","array"]` — so a client generated from the published contract cannot omit them, and telling it to would instruct a schema violation. Omission still works at runtime; `null` is the form that is also contract-legal.'
signals: 'omitted array means never applies · absent versus explicitly empty on a write path · `?? []` inverts a conjunctive filter · empty set is the most restrictive value not the neutral one · normalize absence to the same symbol the read side substitutes · `List<T>?` plus per-file `#nullable enable` in a project with annotations off · reject an empty recurrence with 422 naming the consequence · rejection needs the STORED row so it lives in the handler not the controller · unchanged stored empty is exempt per FIELD not per row · a stored NULL is not exempt because null means unrestricted · validate the list the handler writes so the catch-all is excluded by construction · nullable does not mean optional in the generated schema so say send null not omit · paths: `ErsatzTV.Application/Scheduling/RecurrenceSetBounds.cs`, `ErsatzTV/Controllers/Api/Requests/PlayoutAlternateScheduleItemRequest.cs`, `ErsatzTV/Controllers/Api/Requests/PlayoutTemplateItemRequest.cs`, `ErsatzTV.Application/Playouts/Commands/ReplacePlayoutAlternateScheduleItemsHandler.cs`, `ErsatzTV.Application/Scheduling/Commands/ReplacePlayoutTemplateItemsHandler.cs` · issues: #880, #823, #701'
mechanics: 'Two halves, pinned in two files and mutation-proved SEPARATELY by EXECUTION against the real predecessor clause. NORMALIZATION is pinned in `ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs` by `Replace{AlternateSchedules,Templates}_Should_Normalize_Absent_Recurrence_To_Unrestricted` plus `ReplaceAlternateSchedules_Should_Preserve_An_Explicit_Recurrence_Selection` — the last is the complement, without which a fix that substituted `All*()` UNCONDITIONALLY would still pass. Proof: reverting the three `?? All*()` clauses to `?? []` in both request records reddens EXACTLY those three. REJECTION is pinned in `ErsatzTV.Tests/Application/Playouts/PlayoutHandlerTests.cs` against a real `InMemoryTvContext`, and the four cases are chosen to be mutually distinguishing: a newly-empty field on a NON-catch-all item is rejected; the same empty on the CATCH-ALL is ACCEPTED; an UNCHANGED stored empty is accepted; and the SAME stored row still REJECTS a different field newly emptied. Each rejection case also asserts the version did NOT bump, which is the anti-vacuity control — a 422 that had already mutated would be a different bug. One fixture trap is load-bearing and was measured, not reasoned about: the template handler loads `.Include(p => p.Templates).ThenInclude(t => t.Template)` and that navigation is REQUIRED, so a `PlayoutTemplate` seeded WITHOUT its `Template` row is joined OUT and never reaches `existing` — the stored row is then invisible, the exemption cannot match, and the test fails claiming the rule is broken when the fixture is. Seed the `TemplateGroup` and `Template` too.'
---
**The empty set is not the neutral value, and that is the whole defect.** For a conjunctive filter,
absence and emptiness sit at OPPOSITE ends: absence restricts nothing, empty admits nothing. `?? []`
reads as a harmless "default to no filter" and is in fact "default to matching nothing" — the row is
stored, the write returns 200, and the item silently never applies. Nothing logs it, because from the
scheduler's point of view a non-matching item is ordinary. The SPA never triggered it (it sends full
arrays and its generated type marks the field `null | Array<T>`, so the key is always present) and the
MCP server exposes no such write tool, which is why this survived: it was reachable only by a direct
API client, and only by OMITTING a field.
**Why the two cases must not be merged in either direction.** Normalizing `[]` to the permissive value
too would make it impossible to say "no days" at all, and would re-collapse the distinction the
nullable annotation was added to express. Rejecting absence instead would break every client that
legitimately omits an optional field. The pair — normalize absence, reject empty — is the only
assignment that leaves both requests expressible and neither one silent.
**The first implementation put the rejection in the controller, and an independent review was right to
block it.** Validating the request there is cheaper and matches the sibling `ValidateDateRanges`, but
it has no access to the stored row, so it rejected an unchanged legacy empty and — because these are
whole-list replaces — would have made every sibling item unsaveable. It also validated EVERY item
uniformly, including the catch-all whose recurrence is discarded, producing a 422 whose stated reason
("the item would never apply") is false for exactly that item. Both disappear once the check moves to
the handler and derives its population from `incoming`. The uniformity of `ValidateDateRanges` is
precedent for a STYLE, not evidence that the behaviour is correct; that was the reasoning error.
**Prod was measured before choosing to reject rather than grandfather** (2026-08-30, jazz
`/config/ersatztv.sqlite3`): `ProgramScheduleAlternate` and `PlayoutTemplate` both hold ZERO rows. That
measurement is why the exemption could not be justified by pointing at live data — it had to be
justified by the shape of a whole-list replace instead, which holds for any deployment. Do not cite
the zero-row count as a reason to drop the exemption.
**The SPA can still produce an empty set** (`PlayoutScheduleEditors.tsx` toggles days individually,
with no floor at one), so a user who unchecks every day now receives the 422 instead of silently
saving a dead row. That is the intended outcome and it is a visible behaviour change; a client-side
floor mirroring the server message would be the §3d "mirror the exemption in the client" follow-up if
the error proves awkward in the form.
@@ -5,7 +5,7 @@ status: active
since: '2026-08-05'
supersedes: none
superseded-by: none
rule: 'Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`''s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo''s checkouts now set it — 15 in #746 and `ci-image.yml`''s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation.'
rule: 'Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`''s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo''s checkouts now set it — 15 in #746 and `ci-image.yml`''s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The `workflow_dispatch` half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because `docker-build.yml`''s head-resolved `pull_request:` runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the `v*` tag push, not dispatch, are the live residuals (#885).'
signals: 'admin password in CI secrets, registry credential scope, ETV_STATUS_AUTH can write statuses, forge review-verdict/h10, head-resolved workflow holds credentials, persist-credentials on actions/checkout, credential left in .git/config, masked git fetch yields empty changed set, Gitea token scopes, no status scope, write:package vs write:repository, permissions key no-op, GITEA_TOKEN default read/write, Restricted default token permissions, orphan secret, deploy key in secret store, toolchain image overwrite, prod floating tag write · paths: `.gitea/workflows/docker-build.yml`, `.gitea/workflows/pr-checks.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/ci-image.yml`, `.gitea/workflows/renovate.yml`, `scripts/ci-detect-already-validated.sh` · issues: #697, #672, #698, #742, #743, #746, #748, #835, #420, server-management#714'
mechanics: 'PAT `ci-registry-scoped-697`, scopes `write:package,read:repository`, stored as repo Actions secret `REGISTRY_PASSWORD`; `REGISTRY_USER` remains `timothy`. Verified 2026-08-05 on Gitea 1.25.4: registry push of a probe tag SUCCEEDED; `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` REFUSED HTTP 403 `token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository`. Probe artifacts deleted, confirmed 404. NOT measured with this token: the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea''s scope model (write implies read per category, read at tag `v1.25.4`) — INFERRED. Note WHICH run proves which: only the `container:` pull is exercised by a PR. `cache-to`/`cache-from` and the base-image pull are confined to the `build` job, which carries `if: github.event_name != ''pull_request''`, so they are first exercised on the post-merge push to `main` — AFTER the merge gate has passed. A wrong inference there reddens main, not the PR.'
---
@@ -172,7 +172,12 @@ still holds repo-write, so the property is "only a workflow visibly opting into
verdict", not "no workflow can". Scoping still bounds what a job may DO, never whether attacker YAML
RUNS. A
collaborator's own token always can — and after #742 an ALLOW-LISTED collaborator's still is
inherited, which is the residual the allow-list explicitly does not touch. `docker-build.yml` publishes `:prod` from a `v*` tag push and a tag
inherited, which is the residual the allow-list explicitly does not touch. **Which route is cheapest
was settled in #853** (`ci.workflow-dispatch-ref-unrestricted`): not the four unrestricted
`workflow_dispatch` triggers but `docker-build.yml`'s head-resolved `pull_request:`, which runs
attacker-authored YAML and therefore reaches EVERY secret in the store, not only the ones the
committed workflows name — six of its jobs already hold `REGISTRY_PASSWORD` on that route, two of them
required contexts. Residuals in #885. `docker-build.yml` publishes `:prod` from a `v*` tag push and a tag
may point at ANY commit — a prod image with no PR, review or status (tag protections are empty).
**And none of it was necessary: direct pushes to `main` were server-side permitted, so the gate was
bypassable with no forgery at all (#743).** That route is now closed — `main` carries
@@ -5,9 +5,9 @@ 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 for a `success` only when it is POSITIVELY identified as an APPROVED REVIEWER''s verdict for THIS base, meaning a `.creator.login` that is a member of the workflow''s `H10_REVIEWERS` allow-list (an existing `failure` takes the weaker ATTRIBUTABLE test and needs no membership, because inheriting a rejection can only withhold an exemption while re-deriving one can turn it green on an exempt PR) — non-null was too weak, since any account''s credential satisfies it and `RENOVATE_TOKEN` is one that cannot be scoped down (#742) — AND a `Review-verdict:` description AND, when that description records a base (`(base: …)`, `release.verdict-status-check`), a base matching the PR''s — tested by requiring the description to END with the exact literal `(base: <base>)` and to contain exactly ONE such marker, never by extracting a value (see below); a present-but-different base is rejected, an absent one is not, since verdicts predating that convention carry none; every other shape, including any unrecognised one, is re-derived rather than trusted. The bot and docs-only exemptions are evaluated as INDEPENDENT predicates and the decision made afterwards, never as an `elif` chain. `edited` is in the workflow''s `types:` so a retarget reclassifies — which gives DETECTION, not atomicity: status writes are not serialized, so a stale run can still post over a fresher one. That residual is now FENCED rather than merely tracked (NARROWED, not resolved — a retarget between the fence''s final count and the POST still leaves a PERMANENT forged green, #849) — the job refuses to write at all if the PR''s timeline retarget COUNT moved while it was classifying (`ci.verdict-write-retarget-fence`, #706) — leaving TWO windows, not one: the sub-round-trip window that no API without compare-and-set can close, AND the post-final-count/pre-POST window, which is not sub-round-trip and yields a PERMANENT green because the successor can consume the `edited` event and exit before the stale run posts last (#849). The PROTECTED path list additionally covers `CLAUDE.md` and `AGENTS.md` (#751) — they are not prose but the documents DEFINING the completion protocol, the merge-consent convention and the H10 rule, so protecting `.claude/` while the file specifying what it enforces stayed docs-only-exempt was the same self-exemption one directory over; driving the real classify body with a lone `CLAUDE.md` change produced an exemption `success`. `README.md` is deliberately not listed. It also covers `.codex/` (#711), which mirrors `.claude/hooks/` byte for byte including the merge-consent hook — latent while that directory is untracked, live the moment it is tracked; the list stays ENUMERATIVE rather than derived, because a derived rule would have to be evaluated against the very file list being classified. Reading the CURRENT status for input (3) must tolerate `statuses: null`: `GET /commits/{sha}/status` serialises a nil slice as `null`, not `[]`, on a head with no statuses yet, and an `array`-only gate made `read_existing_verdict` `exit 1` and post nothing at all (#751, `ci.workflow-run-body-no-expressions`) — `null` is accepted only when `total_count` is 0, so a body that merely lost its array is still refused. Path predicates are evaluated by COUNTING with `grep -c`, never `| grep -q` (SIGPIPE inversion) and never a here-string (temp-space failure) — see `ci.grep-q-pipefail-inversion`.'
signals: 'verdict inherited from a bot token, RENOVATE_TOKEN posts a review verdict, who may post an h10 verdict, H10_REVIEWERS allow-list, forged review-verdict exemption, retarget race against the docs-only classifier, PR base changed mid-run, hijacked Renovate branch, bot exemption on a code change, machine-written success inherited as a verdict, status creator null vs user, never overwrite a human verdict, exemption chain skips docs-only for bots, why is my Renovate PR asking for a verdict, base ref binding on pr-changed-files.sh · paths: `.gitea/workflows/review-verdict.yml`, `scripts/pr-changed-files.sh`, `.claude/hooks/pretooluse-merge-consent.sh`, `scripts/tests/test_pr_changed_files.py` · issues: #849, #845, #742, #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; `H10_REVIEWERS="timothy"` declared as a literal in the classify body (a repo `vars:` entry or secret is settable without a PR); membership by exact POSIX `[ = ]` comparison over word-split entries, never an UNQUOTED right-hand side — `[[ $rv == $ex_creator ]]` makes it a pattern, while a quoted RHS under `[[ ]]` is literal (probed, bash 5.3); POSIX `[ = ]` cannot glob at all, which is why it is used and never `grep -xF` (an API-supplied creator containing a newline reads as several patterns); `read_existing_verdict` sets `ex_attributable` (creator non-null AND `^Review-verdict:` AND base OK) and derives `ex_human` from it by membership; the INHERITANCE short-circuit reads `ex_human`, the LAST-MOMENT RE-READ reads `ex_attributable` plus a change against the state/creator/description triple snapshotted at the first read, and the post-write raced `jq` stays broad on `creator != null`; `types: [opened, reopened, synchronize, ready_for_review, edited]`'
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 for a `success` only when it is POSITIVELY identified as an APPROVED REVIEWER''s verdict for THIS base, meaning a `.creator.login` that is a member of the workflow''s `H10_REVIEWERS` allow-list (an existing `failure` takes the weaker ATTRIBUTABLE test and needs no membership, because inheriting a rejection can only withhold an exemption while re-deriving one can turn it green on an exempt PR) — non-null was too weak, since any account''s credential satisfies it and `RENOVATE_TOKEN` is one that cannot be scoped down (#742) — AND a `Review-verdict:` description AND, when that description records a base (`(base: …)`, `release.verdict-status-check`), a base matching the PR''s — tested by requiring the description to END with the exact literal `(base: <base>)` and to contain exactly ONE such marker, never by extracting a value (see below); a present-but-different base is rejected, an absent one is not, since verdicts predating that convention carry none; every other shape, including any unrecognised one, is re-derived rather than trusted. The bot and docs-only exemptions are evaluated as INDEPENDENT predicates and the decision made afterwards, never as an `elif` chain. `edited` is in the workflow''s `types:` so a retarget reclassifies — which gives DETECTION, not atomicity: status writes are not serialized, so a stale run can still post over a fresher one. That residual is now FENCED rather than merely tracked (NARROWED, not resolved — a retarget between the fence''s final count and the POST still leaves a PERMANENT forged green, #849) — the job refuses to write at all if the PR''s timeline retarget COUNT moved while it was classifying (`ci.verdict-write-retarget-fence`, #706) — leaving TWO windows, not one: the sub-round-trip window that no API without compare-and-set can close, AND the post-final-count/pre-POST window, which is not sub-round-trip and yields a PERMANENT green because the successor can consume the `edited` event and exit before the stale run posts last (#849). The PROTECTED path list additionally covers `CLAUDE.md` and `AGENTS.md` (#751) — they are not prose but the documents DEFINING the completion protocol, the merge-consent convention and the H10 rule, so protecting `.claude/` while the file specifying what it enforces stayed docs-only-exempt was the same self-exemption one directory over; driving the real classify body with a lone `CLAUDE.md` change produced an exemption `success`. `README.md` is deliberately not listed. It also covers `.codex/` (#711), which mirrors `.claude/hooks/` byte for byte including the merge-consent hook — latent while that directory is untracked, live the moment it is tracked; the list stays ENUMERATIVE rather than derived, because a derived rule would have to be evaluated against the very file list being classified. Reading the CURRENT status for input (3) must tolerate `statuses: null`: `GET /commits/{sha}/status` serialises a nil slice as `null`, not `[]`, on a head with no statuses yet, and an `array`-only gate made `read_existing_verdict` `exit 1` and post nothing at all (#751, `ci.workflow-run-body-no-expressions`) — `null` is accepted only when `total_count` is 0, so a body that merely lost its array is still refused. Path predicates are evaluated by COUNTING with `grep -c`, never `| grep -q` (SIGPIPE inversion) and never a here-string (temp-space failure) — see `ci.grep-q-pipefail-inversion`. The WRITER is bound to the same allow-list rather than trusted to match it (#845): `scripts/post-review-verdict.sh` reads its own status back, identifies that write by state and description, and refuses before writing the comment unless `.creator.login` is a member — so a writer credential that is not a reviewer fails at the terminal that made the mistake instead of stalling the PR one event later. The list is DERIVED from the literal in this workflow by `scripts/lib/h10-reviewers.sh`, never restated: one declaration, not two plus a parity test.'
signals: 'verdict inherited from a bot token, RENOVATE_TOKEN posts a review verdict, who may post an h10 verdict, H10_REVIEWERS allow-list, forged review-verdict exemption, retarget race against the docs-only classifier, PR base changed mid-run, hijacked Renovate branch, bot exemption on a code change, machine-written success inherited as a verdict, status creator null vs user, never overwrite a human verdict, exemption chain skips docs-only for bots, why is my Renovate PR asking for a verdict, base ref binding on pr-changed-files.sh · paths: `.gitea/workflows/review-verdict.yml`, `scripts/pr-changed-files.sh`, `.claude/hooks/pretooluse-merge-consent.sh`, `scripts/tests/test_pr_changed_files.py`, `scripts/post-review-verdict.sh`, `scripts/lib/h10-reviewers.sh` · issues: #849, #845, #742, #698, #697, #672, #663, #649, #632'
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; `H10_REVIEWERS="timothy"` declared as a literal in the classify body (a repo `vars:` entry or secret is settable without a PR); `scripts/lib/h10-reviewers.sh` derives that same literal for the write side by an anchored single-assignment parse and refuses on 0 or >1 matches, on a non-plain-login entry, or on an empty list; membership by exact POSIX `[ = ]` comparison over word-split entries, never an UNQUOTED right-hand side — `[[ $rv == $ex_creator ]]` makes it a pattern, while a quoted RHS under `[[ ]]` is literal (probed, bash 5.3); POSIX `[ = ]` cannot glob at all, which is why it is used and never `grep -xF` (an API-supplied creator containing a newline reads as several patterns); `read_existing_verdict` sets `ex_attributable` (creator non-null AND `^Review-verdict:` AND base OK) and derives `ex_human` from it by membership; the INHERITANCE short-circuit reads `ex_human`, the LAST-MOMENT RE-READ reads `ex_attributable` plus a change against the state/creator/description triple snapshotted at the first read, and the post-write raced `jq` stays broad on `creator != null`; `types: [opened, reopened, synchronize, ready_for_review, edited]`'
---
`ci.gate-trigger-base-resolved` stopped a PR supplying the gate's own *definition*. This closes the
@@ -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,27 +216,91 @@ 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.
**A coupling this record does NOT assert, tracked as #845 instead.** `post-review-verdict.sh` posts with
whatever account owns `ETV_GITEA_TOKEN`/`ETV_GITEA_BASICAUTH` and never checks whose it is. If that
ever stops being an allow-listed account, every verdict silently stops being inheritable and each
later event re-derives it — to `pending` on a PR that needs a verdict, or to an exemption `success` on
a docs-only or Renovate-manifest one, which is the quieter and worse half. Either way the verdict is
gone, and the only diagnostic is a line in a job log.
Correct today — verified live 2026-08-26, reproduce with `GET /api/v1/user` (login `timothy`) and
`GET /api/v1/admin/users?limit=50` (exactly `renovate`, `timothy`) — but nothing keeps it correct
(#845). The job DOES name the creator and the allow-list whenever it declines to inherit — but in the
WORKFLOW RUN, while the mistake is made at the terminal running `post-review-verdict.sh`, which
reports success either way. That gap is what #845 is about, and it is why a louder annotation was not
the answer: an earlier draft added a `::warning::` alongside that line, and it was withdrawn after
producing three defects in three rounds (a message that contradicted itself for an allow-listed
creator, an unescaped API value interpolated into a workflow command, and a test asserting its absence
on a path that exits two guards earlier and so could never fail). The signal has to reach the WRITER,
not be repeated more loudly to the reader.
**One residual named here was closed there too.** An UNREADABLE combined-status read used to leave a
pre-existing off-list `success` standing: `read_existing_verdict` exited without replacing it, the job
went red, and this workflow's own job status is not a required check, so branch protection still saw
the green — the exact status this record exists to revoke, surviving a read failure. That read is now
retried once and, on a persistent failure, the unknown state is REPLACED with the unverified sentinel.
**The writer/gate coupling, ASSERTED at the writer since #845.** `post-review-verdict.sh` posts with
whatever account owns `ETV_GITEA_TOKEN`/`ETV_GITEA_BASICAUTH`. It used to never ask whose that was, and
the two values are coupled: the day the writer credential stops being an allow-listed account, every
verdict silently stops being inheritable and each later event re-derives it — to `pending` on a PR that
needs a verdict, or to an exemption `success` on a docs-only or Renovate-manifest one, which is the
quieter and worse half. Either way the verdict is gone. It now READS THE STATUS BACK after posting it,
identifies its own write by state and description, and refuses — before the comment, so the surviving
half-state is the `ask` one — unless `.creator.login` is on the allow-list. A `creator: null`
(Actions-token) status is refused with its own message, because there is no account to name and "add
them to the allow-list" would be the wrong advice.
The writer mirrors the state asymmetry above rather than simplifying it: membership is required for a
`success` only. A `failure` is written and commented normally from any attributable account, because
that is exactly what this record already says the gate does — requiring membership there would refuse
a verdict the gate honours, and withhold the comment that is the only supported way to record a
rejection. It also reads `.status` and NOT `.state`, with no fallback, because the gate reads
`.status`: tolerating a shape the gate cannot read would make the writer report success on a verdict
that gets re-derived, which is #845 recreated by the check meant to prevent it.
The allow-list is not restated on the writer side. `scripts/lib/h10-reviewers.sh` DERIVES it from the
`H10_REVIEWERS` literal in this workflow, so there is one declaration rather than two held together by
a parity test — the shape `testing.guard-derives-population-from-source` records six failed rounds
against. It is a parse rather than a shared declaration both sides source, because the gate runs
against a checkout of the PR BASE sha: a PR whose base predates such a file would not have it, and a
missing `source` under `set -euo pipefail` kills the job, which posts no `review-verdict/h10` at all
and blocks every merge including the repair (`block_admin_merge_override: true`). A fallback list for
that case would reintroduce the second copy. So the derivation runs on the side that can fail safely
and the body of this workflow is untouched. The parse admits exactly one anchored assignment or
refuses; `test_pr_changed_files.py::test_the_H10_REVIEWERS_list_is_glob_free_and_non_empty` reads the
same literal with the same anchors OVER THE SAME POPULATION — the whole file — so a shape change
reddens CI on the PR that makes it. The population is part of the rule, not an implementation
detail: that guard used to scope itself to the classify step's `run:` body, and a second anchored
assignment in any other `run:` block then left it green while making the writer refuse every verdict.
Measured before it was aligned: the guard green, and about half the writer suite red. What was
missing is worth stating precisely, because the obvious phrasing is wrong twice over. Those failures
DO carry the library's refusal, which names the count and the variable. And alignment ADDS a named
guard failure rather than replacing them. What was missing is a failure POINTING AT THE WORKFLOW,
since the reds surface under unrelated test names and the cause had to be inferred from a stack of
them. No figure is given for how many name it, and the trap is worth recording so one is not added
back: a `grep -c` over pytest output counts LINES rather than failures and `--tb=line` prints each
matching failure twice, so such a count overstates, while raising the traceback detail moves it the
other way by making more failures show the refusal text. Any figure is therefore a property of the
pytest flags that produced it rather than of the code, and reproducing one means parsing per-failure
sections instead of grepping lines. Such an edit reaching `main` WOULD be a repo-wide merge outage — no verdict could be
posted and `review-verdict/h10` is required — but the guard is not the only thing standing in the
way, and saying so would overstate it: the writer suite reddens too, in the ADVISORY `script-tests`
job (the required contexts, measured 2026-08-30, are `Build & test`, `EF migration integrity` and
`review-verdict/h10`). What alignment buys is ONE named guard failure pointing at the workflow, ALONGSIDE the half-suite of unrelated
failures. The route by which the outage is actually reachable is the snapshot boundary below — a
verdict posted from a checkout predating the edit — which is why the two paragraphs belong together.
**The one false ACCEPT this leaves, named with its direction.** The writer reads the workflow in the
LOCAL checkout; the gate runs the base-resolved one. On a branch that ADDS a reviewer — the likely
edit — the writer accepts that account while the live gate will not yet inherit from it, which is
the #845 stall reached through the check meant to catch it. Accepted rather than closed: a live
remote read of the base workflow puts a network dependency on the merge-consent write path, where a
read failure must resolve either to a refusal (a new way to block every verdict) or to a pass (the
hole, reopened). The window is one PR long, the person making the edit is the one who knows the list
is in flux, and the residual is a STALL, never a merge of unreviewed code.
The allow-list was `timothy` and the instance held exactly `renovate` and `timothy` when this was
measured on 2026-08-26 (`GET /api/v1/user`, `GET /api/v1/admin/users?limit=50`). That measurement is
no longer what keeps the coupling correct, which is the point of the change.
**Why the signal had to reach the WRITER.** The job already names the creator and the allow-list
whenever it declines to inherit — but in the WORKFLOW RUN, while the mistake is made at the terminal
running `post-review-verdict.sh`, which reported success either way. An earlier draft tried to fix
that by shouting louder at the reader: a `::warning::` alongside that line, withdrawn after producing
three defects in three rounds (a message that contradicted itself for an allow-listed creator, an
unescaped API value interpolated into a workflow command, and a test asserting its absence on a path
that exits two guards earlier and so could never fail). Recorded so it is not re-attempted: more
volume in the run was never the missing piece.
**What #742 does NOT close, restated because the allow-list invites the wrong reading.** An
allow-listed collaborator's own write-scoped token can still POST a verdict nobody formed. Branch
@@ -0,0 +1,68 @@
---
key: ci.fetch-depth-never-grafts-a-complete-clone
title: '2026-08-29 — A CI fetch passes --depth only to a checkout that is already shallow (#836)'
status: active
since: '2026-08-29'
supersedes: none
superseded-by: none
rule: 'Any `git fetch` in CI tooling passes `--depth` only when `git rev-parse --is-shallow-repository` already answers `true`, and passes none on any other answer including an unreadable one. `git fetch --depth=N` GRAFTS a complete clone shallow: it writes `.git/shallow` and cuts history at N even though every object is already present, so a depth chosen for one consumer silently breaks every other consumer that checks out `fetch-depth: 0`. A shared script whose consumers disagree on checkout depth must ask the repository rather than assume. And whatever the fetch feeds — a version, a diff, a base revision — must be ASSERTED rather than defaulted: a fallback that cannot fail converts the graft into a plausible wrong answer that never reddens.'
signals: 'git fetch --depth, .git/shallow, grafted clone, git describe finds no tag, InformationalVersion 0.0.0, fetch-depth 0 vs fetch-depth 2 · paths: `scripts/ci-detect-docs-only.sh`, `.gitea/workflows/docker-build.yml` · issues: #836, #746'
mechanics: 'docs/ci-cd.md → "A depth is passed only to a checkout that is already shallow"; guard `scripts/tests/test_docs_only_detector_clone_depth.py`, a MUTATION-graded row in `docs/guard-inventory.md`'
---
`scripts/ci-detect-docs-only.sh` has exactly four consumers in `docker-build.yml`, and they do not
agree on checkout depth: `test`, `migrations` and `functional-e2e` check out `fetch-depth: 2`, and
`build` checks out `fetch-depth: 0`. The script carried a `--depth` chosen for the first group and
applied it to all of them. **`build` is the only complete-clone consumer.** `api-docs` and `format`
also check out `fetch-depth: 0` and also have a step with `id: detect`, but that step runs their own
inline diff, not this script. **Enumerate the consumers with `grep -rn ci-detect-docs-only .gitea/`,
never by looking for a step with `id: detect`** — that is the trap, and it points at the wrong two
jobs. The grep returns five hits: the four `run:` call sites plus `docker-build.yml`'s own header
comment.
The harm is not theoretical. The push arm is the only arm `build` ever FETCHES on — it also takes
the tag early-exit on a release cut and the `workflow_dispatch` arm, neither of which fetches — and
that arm grafted `build`'s complete clone on every push to `main`;
the very next step, `Compute version and tags`, runs `git describe --tags --abbrev=0`, which then
found no reachable tag; and its `|| echo v0.0.0` turned that into a version string. Every `:latest`
image published from `main` carried `InformationalVersion 0.0.0-<sha>` instead of `26.x.y-<sha>`
from 2026-07-17, when #416 introduced the depth, until this change. Nothing went red at any point —
the defect was found by reading the string out of a running container.
**Why the depth is kept for the shallow group, stated as measured rather than as load-bearing.** A
depth-less fetch into a `--depth=2` clone leaves `.git/shallow` in place, still writes `FETCH_HEAD`,
and still lets `git rev-list --parents -n1 HEAD` report the parents the push arm reads. So dropping
the depth outright would not have broken those three jobs either. It is retained because it is what
they were given and nothing argues for widening their fetch — not because removing it was shown to
break anything.
**The PR arm's `--depth=200` had no complete-clone consumer at all.** On `pull_request` the only
jobs running this script are the three shallow ones, so that depth was inert rather than latently
firing. It is fixed in the same change anyway, because the clause is shared: the next
`fetch-depth: 0` consumer added to that arm would inherit the graft silently, and rediscovering it
would cost what #836 cost (`process.fix-the-boundary-not-the-site`). That is a guard against a
future shape, and grading it as a reproduction of a shipped one would overstate it.
**Why any answer but `true` passes no depth.** There is more than one such answer, and enumerating
them is the point: `git rev-parse` fails outright where there is no readable repository, and on a
git older than 2.15 the flag is unrecognised and rev-parse ECHOES IT BACK verbatim with status 0
(verified on 2.55 — an unknown `--is-...` flag prints itself and exits 0). Neither is `true`, both
take the no-depth branch, and in neither could a depth have helped: the base stays unresolvable and
every arm falls through to the full matrix, which is the safe direction for this script. Defaulting
the other way would re-graft on exactly the paths nobody can observe.
**The second half is the loud failure, and it is not decoration.** Removing the graft removes
today's cause; the fallback is what made any cause invisible. `Compute version and tags` now fails
the job when `git describe` fails on the non-tag path. No `:latest` at all is visible and
recoverable; a mislabelled one is neither, and `:latest` is what `ersatztv-test` tracks. The tag
path never calls `describe`, so a release cut is unaffected — which is also why `:prod` and
`:<version>` images were correctly versioned throughout.
**Fixture note for anyone testing this.** `git clone /path` and `git fetch /path` use the local
transport, which ignores `--depth` outright. A reproduction built on a plain path never grafts, so
every assertion holds and the test proves nothing; use `file://`. The guard carries a negative
control that performs the raw depth fetch and requires the graft to happen, so that if this ever
stops being true it is reported rather than silently making the rest vacuous.
Related: `ci.docs-only-detect-shallow-safe` covers the other half of this script's shallow
behaviour — which revision it diffs against, and why it is `FETCH_HEAD` with a two-dot diff.
@@ -34,9 +34,14 @@ sibling job carries an ignore-rule. Same question, opposite answers, for stated
**Unconditional, not path-filtered.** The suite's real input set spans more than `scripts/`:
`test_post_review_verdict.py` and `test_merge_consent_exemption.py` execute the actual
`scripts/post-review-verdict.sh` and `.claude/hooks/pretooluse-merge-consent.sh`. A `scripts/**`
filter would silently miss a `.claude/hooks/**` edit — and at ~10s a filter buys nothing but a
drift surface. Default checkout depth is sufficient: every `git` call in the suite runs against a
`scripts/post-review-verdict.sh` and `.claude/hooks/pretooluse-merge-consent.sh`, and since #845 the
former also READS `.gitea/workflows/review-verdict.yml` — the verdict writer derives the H10
allow-list from it, so editing that literal changes the suite's outcome. A `scripts/**` filter would
silently miss a `.claude/hooks/**` or `.gitea/workflows/**` edit. The enumeration is a lower bound
rather than a census, deliberately: the corpus tests read `docs/` too, and an exact list here is the
kind that goes stale — which it did, this way, before #845 extended it. The reason is the INPUT SET,
not the cost: a filter was cheap to skip when the suite ran in seconds and is still the wrong axis
now that it runs in minutes. Default checkout depth is sufficient: every `git` call in the suite runs against a
temp repo it creates itself, never this repository's history.
**Verified by measurement, not a green tick** (the #631 Done-when, and `ci.docs-only-detect-shallow-safe`'s
@@ -5,8 +5,8 @@ status: active
since: '2026-08-27'
supersedes: none
superseded-by: none
rule: 'A `push` trigger reachable from any ref other than `main` — that is `branches:` AND `tags:`, judged by the ref class it admits and never by which keyword is present — executes ref-supplied YAML, because Gitea resolves a `push` workflow''s definition from the pushed ref — so `ci-image.yml` is `branches: [main]`, and publishing a toolchain image from a feature branch is a deliberate `workflow_dispatch` on that branch rather than a side effect of pushing. Be precise about what a `branches:` filter buys: it is loaded from the pushed ref like the rest of the file, so a branch that deletes it re-enables the route — this removes the DRIVE-BY case, and is not a boundary against a writer who intends to run their own YAML. The self-referencing trigger path `.gitea/workflows/ci-image.yml` came out of BOTH its own `paths:` and `ci-image-pin`''s `expected` in the same change — a DECIDED tradeoff, not a necessity: keeping it is workable via the branch dispatch, but prices every edit to that file, comments included, at a ~2GB publish plus a five-pin bump, redone after every rebase. The cost is stated, not assumed away — a change to HOW the image is built that lives only in `ci-image.yml` no longer republishes, and the ONLY remedy is to make it alongside a `docker/ci/**` edit: publishing after the merge and then pinning cannot work, because `expected` is the last `docker/ci` commit and would reject that pin. This closes the push route INTO THIS FILE, not the class: `docker-build.yml` remains reachable from an arbitrary ref by a `v*` tag push and by `pull_request`, and four workflows carry an unrestricted `workflow_dispatch` (#853).'
signals: 'unfiltered push trigger, branches filter on push, attacker YAML on a runner, feature branch publishes an image, self-referencing workflow trigger, ci-image-pin expected goes stale, publish-then-pin two-step, toolchain image recovery after a rebase, workflow_dispatch on a branch · paths: `.gitea/workflows/ci-image.yml`, `.gitea/workflows/pr-checks.yml`, `.gitea/workflows/docker-build.yml`, `docker/ci/Dockerfile`, `docs/ci-cd.md`, `docs/decisions/records/ci/actions-credential-scoping.md`, `scripts/e2e-ui.sh` · issues: #744, #853, #697, #746, #835, #445, #390, #594, #597'
rule: 'A `push` trigger reachable from any ref other than `main` — that is `branches:` AND `tags:`, judged by the ref class it admits and never by which keyword is present — executes ref-supplied YAML, because Gitea resolves a `push` workflow''s definition from the pushed ref — so `ci-image.yml` is `branches: [main]`, and publishing a toolchain image from a feature branch is a deliberate `workflow_dispatch` on that branch rather than a side effect of pushing. Be precise about what a `branches:` filter buys: it is loaded from the pushed ref like the rest of the file, so a branch that deletes it re-enables the route — this removes the DRIVE-BY case, and is not a boundary against a writer who intends to run their own YAML. The self-referencing trigger path `.gitea/workflows/ci-image.yml` came out of BOTH its own `paths:` and `ci-image-pin`''s `expected` in the same change — a DECIDED tradeoff, not a necessity: keeping it is workable via the branch dispatch, but prices every edit to that file, comments included, at a ~2GB publish plus a five-pin bump, redone after every rebase. The cost is stated, not assumed away — a change to HOW the image is built that lives only in `ci-image.yml` no longer republishes, and the ONLY remedy is to make it alongside a `docker/ci/**` edit: publishing after the merge and then pinning cannot work, because `expected` is the last `docker/ci` commit and would reject that pin. This closes the push route INTO THIS FILE, not the class: `docker-build.yml` remains reachable from an arbitrary ref by a `v*` tag push and by `pull_request`, and four workflows carry an unrestricted `workflow_dispatch`. The DISPATCH THIRD of that is settled — #853 probed it and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`): no ref restriction exists at Gitea 1.27.1, and restricting it would close nothing anyway, because the head-resolved `pull_request:` route runs attacker-authored YAML that reaches every secret in the store. The `v*` tag push and `pull_request:` rows are NOT settled and remain open in #885. Do not re-derive any of this.'
signals: 'unfiltered push trigger, branches filter on push, attacker YAML on a runner, feature branch publishes an image, self-referencing workflow trigger, ci-image-pin expected goes stale, publish-then-pin two-step, toolchain image recovery after a rebase, workflow_dispatch on a branch · paths: `.gitea/workflows/ci-image.yml`, `.gitea/workflows/pr-checks.yml`, `.gitea/workflows/docker-build.yml`, `docker/ci/Dockerfile`, `docs/ci-cd.md`, `docs/decisions/records/ci/actions-credential-scoping.md`, `scripts/e2e-ui.sh` · issues: #744, #853, #885, #697, #746, #835, #445, #390, #594, #597'
mechanics: '`docs/ci-cd.md` -> "CI toolchain image" -> "Publishing from a branch is a dispatch, not a push"; branch-dispatch probe 2026-08-27 (run 2340, published `ersatztv-ci:43b1e45`)'
---
@@ -66,7 +66,14 @@ mechanics: '`docs/ci-cd.md` -> "CI toolchain image" -> "Publishing from a branch
`ersatztv:prod` with no dispatch at all. On top of that, four workflows carry an unrestricted
`workflow_dispatch`. What #744 changed is that none of this is any longer a side effect of an
ordinary branch push. Read it as protection against an accidental or drive-by push, **not** as a
boundary against a malicious or compromised writer; the whole remaining class is tracked in #853.
boundary against a malicious or compromised writer. That remaining class was worked in #853 and
**accepted rather than fixed** — `ci.workflow-dispatch-ref-unrestricted` carries the probe and the
reasoning: Gitea 1.27.1 has no ref restriction for `workflow_dispatch` and no protected-environment
concept, and restricting dispatch would change nothing, because `docker-build.yml`'s head-resolved
`pull_request:` route runs attacker-authored YAML, which reaches every secret in the store — so it
covers `renovate.yml`'s `RENOVATE_TOKEN` too, without dispatching `renovate.yml` at all. **Only the
dispatch third is settled**: the `v*` tag push and the `pull_request:` route itself stay open in
#885.
- **The persist-credentials exemption was downstream of this, and closed with it.** `ci-image.yml`'s
checkout was the one left without `persist-credentials: false` (#746), for exactly the two mechanical
reasons above. Both gone, the flag went on in the same change, which is what let #835 ship its guard
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,86 @@
---
key: ci.workflow-dispatch-ref-unrestricted
title: '2026-08-30 — `workflow_dispatch` cannot be ref-restricted at Gitea 1.27.1, and restricting it would close nothing while `docker-build.yml` keeps `pull_request:` (#853)'
status: active
since: '2026-08-30'
supersedes: none
superseded-by: none
stale-after: '2027-02-28'
rule: 'Gitea 1.27.1 offers NO mechanism to restrict `workflow_dispatch` by ref, and has no protected-environment concept at all — PROBED across the REST API, the loaded config and the CLI, not assumed (the WEB UI was not swept; the body says why that is acceptable here and where it would matter). The dispatch body schema `CreateActionWorkflowDispatch` makes `ref` a required free-form string with no allow-list or pattern field; zero of the 308 documented API paths contain "environment", and Actions secrets exist only at org/repo/user scope with no per-ref or per-environment gate; `/api/v1/settings/actions` 404s; the config file the running server actually loads (`/etc/gitea/app.ini`, named by its own `--config`) sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`; and the `gitea` CLI exposes exactly ONE Actions subcommand, `gitea actions generate-runner-token`, which registers a runner and restricts nothing. Treat the VERSION, not the `stale-after` date, as the real trigger to re-probe: an upgrade past 1.27.1 invalidates every capability claim here the day it lands, months before the date fires. The four unrestricted dispatches (`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml`, `renovate.yml`) are therefore ACCEPTED — but the operative reason is NOT "repository write access is the boundary", which is the argument to avoid because it is unfalsifiable and it hides the real route. The operative reason is that **dispatch is not the cheapest route to ANY of it**: `docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that route executes ATTACKER-AUTHORED YAML — and such YAML can name any secret in the repo store, not merely the ones the committed workflows happen to reference (`ci.gate-trigger-base-resolved`, verbatim: "any PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store"). Label that step honestly: it is INFERRED from the repo-scoped secret model plus that record, NOT measured here, because the measurement would print a live credential into a run log. That generalizing step is what makes the argument cover all four rather than just the registry pair: `renovate.yml`''s `RENOVATE_TOKEN`/`GH_COM_TOKEN` are reachable from a PR without dispatching `renovate.yml` at all, and `dependency-scan.yml` references no `secrets.` whatever — which corrects #853''s own table row for it. On the registry credential as it stands, SIX jobs in `docker-build.yml` hold `REGISTRY_PASSWORD` and run on the PR route (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them — `test` and `migrations` — branch-protection required contexts per `.gitea/required-status-contexts.json`; carry that as the INVARIANT "every job on the PR route that NAMES `secrets.REGISTRY_PASSWORD`", never as the six-name list, because a remediation scoped to a stale list misses whatever lands next. Resist the tempting "every `container:` job" — `toolchain-preflight` is deliberately container-free and takes the credential through `ETV_REGISTRY_AUTH`, so that predicate names five of the six and reproduces on day one the exact staleness it was written to prevent. "Deliberate act" throughout carries `ci.toolchain-image-publish-is-a-dispatch`''s sense — an act OUTSIDE the ordinary contribution flow, not a raw step count: opening a PR costs zero such acts and a dispatch costs one. Restricting dispatch would therefore close the more visible route and change nothing. The residuals worth tracking are the PR route AND the `v*` tag push — a single act, explicitly outside `release.main-direct-push-disabled` — both in #885, not dispatch.'
signals: 'workflow_dispatch ref restriction, restrict dispatch by branch, protected environment, environment-gated secret, Gitea 1.27.1 actions capabilities, settings/actions 404, attacker-supplied workflow YAML, head-resolved pull_request trigger, PR run gets repo secrets, REGISTRY_PASSWORD reachable from a PR, tag_protections empty, renovate bot write scope · paths: `.gitea/workflows/ci-image.yml`, `.gitea/workflows/docker-build.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/renovate.yml`, `docs/ci-cd.md` · issues: #853, #744, #885, #697, #742, #748, #845'
mechanics: '`docs/ci-cd.md` -> "CI toolchain image" -> "Publishing from a branch is a dispatch, not a push"'
sources: 'Gitea 1.27.1 probe, 2026-08-30 — `swagger.v1.json` (308 paths, 0 matching "environment"); `CreateActionWorkflowDispatch` schema; live `tag_protections` = `[]`; live `branch_protections` = one `main` rule; `/api/v1/admin/users` = exactly `timothy` (admin) + `renovate` (non-admin), with `permission: write` from `/repos/timothy/ersatztv/collaborators/renovate/permission`; host `app.ini` `[actions]`; differential dispatch-authorization probe with anonymous and authenticated-non-writer controls'
---
- **The two capability questions the issue named were probed to a negative, and the negatives are the
durable part of this record.** There is no ref restriction for `workflow_dispatch` at 1.27.1: the
endpoint takes `ref` as a required free-form string and no repo, org or admin endpoint scopes which
refs may be selected. There is no protected-environment concept to gate a secret behind — not a
missing endpoint but a missing *feature*, evidenced by zero of 308 documented paths mentioning
"environment" and by secrets existing only at org, repo and user scope. THREE surfaces were swept,
because "the obvious call 404s" is not evidence of absence: the REST API, the config file the running
server names in its own `--config`, and the `gitea` CLI. **The web UI was not swept, and that is a
real limit on this evidence rather than a formality** — a Gitea Actions control can exist with NO API
surface at all, which `ci.actions-credential-scoping` records for the owner-level default: it 404s on
the API and was nonetheless set, verified and reverted. That record does NOT name the surface used —
a non-API one is an INFERENCE by elimination here, since `app.ini` sets only two `[actions]` keys and
the CLI exposes only `generate-runner-token`. It is left open because the
decision does not rest on it — what carries the conclusion is the secret-scope model (org/repo/user
only, no per-ref gate) plus the fact that restricting dispatch would close nothing regardless. Sweep
the UI before citing this record as proof that no such control CAN exist. **Check the CLI's
exit code, not its match count** — run as root the binary refuses to start and exits 1, so grepping
its empty help output "finds nothing" for entirely the wrong reason; run as the service user it does
list an `actions` command, whose sole subcommand is `generate-runner-token`. Re-probe on the VERSION
rather than the date: `stale-after` is a calendar field, but what invalidates all of this is an
upgrade past 1.27.1, which can land months earlier.
- **Do not close this class with "anyone with repository write can already do worse."** That argument
was the issue's own leading option and it is the one to reject, for two reasons. It is unfalsifiable
as stated, so it licenses accepting anything; and it skips the question that actually decides the
matter, which is whether dispatch is the *cheapest* route to the credential. It is not.
- **`pull_request:` is the cheaper route, and it is the finding that settles the decision.**
`docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR head, so the run
executes **attacker-authored YAML** — and that, not the specific jobs, is the whole argument: such
YAML can name any secret in the repo store, not only the ones the committed workflows reference.
`ci.gate-trigger-base-resolved` states it directly — "any PR-added workflow can reference
`RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store". That step is INFERRED from the
repo-scoped secret model plus that record, not measured here: measuring it prints a live credential
into a run log. **That is the step that makes this
cover all four dispatches**, `renovate.yml` included, even though `renovate.yml` has no
`pull_request:` trigger of its own; without it the argument would reach only the registry pair.
Concretely on the registry credential today, SIX jobs hold `REGISTRY_PASSWORD` and run on the PR
route — `toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format` — of
which `test` and `migrations` are both branch-protection required contexts
(`.gitea/required-status-contexts.json`). **Carry the invariant, not the list:** *every job on the PR
route that names `secrets.REGISTRY_PASSWORD`*, because a remediation scoped to today's six names
silently misses the seventh. Not "every `container:` job" — `toolchain-preflight` is deliberately
container-free (`runs-on: small`, credential via `ETV_REGISTRY_AUTH`), so that predicate names five of
six and is stale before it ships.
So "push a branch, open a PR" reaches the credential with **no act outside the ordinary contribution
flow**, where a dispatch costs one — that is this repo's sense of "deliberate"
(`ci.toolchain-image-publish-is-a-dispatch`), not a raw step count, which would read 2 vs 2. Any
control that restricted dispatch and left this open would close the more visible route and report
progress that did not happen. Per-`if:` ref gates on the publish steps do not change this: they live
in the same head-supplied file and an attacker edits them out.
- **The write set is two accounts, and the asymmetry between them is worth stating once.** They are
`timothy` (owner, site admin) and `renovate` (collaborator, `permission: write`, `is_admin: false`).
Against the admin, no control administered by that same admin is a boundary — that is a property of
the trust model, not a defect. Against the bot it is different, and the difference is easy to
misread: considerable machinery bounds what Renovate can get MERGED (`review-verdict/h10` as a
required context, and the exemption that auto-passes a Renovate PR only when every changed path is a
dependency manifest AND none is under any of the six protected directories `.claude/`, `.codex/`,
`.gitea/`, `.husky/`, `scripts/`, `docker/ci/` — #698, #742, #845; `ci.exemption-provenance` has the
authoritative form). None of that machinery bounds
what Renovate can RUN. A reader of `ci.exemption-provenance` could reasonably infer the bot is
tightly constrained; on the run path it is not. Measured rather than inferred: on a matched probe
against the same endpoint and payload, an authenticated non-writer short-circuits at authorization
with `"not found"` while `renovate` on this repo reaches ref resolution with `"ref ... doesn't
exist"` — the same response a known writer gets — so `renovate` passes the dispatch authorization
gate.
- **One cheap control exists and was deliberately NOT taken here: `tag_protections` is empty.** Gitea
1.27.1 does support tag protection (`name_pattern` plus a username/team whitelist), and a `v*` rule
whitelisting the release operator would close `docker-build.yml`'s tag-push row against the bot at
no operational cost, since only the operator cuts releases. It is not applied in this change because
it is live branch-protection-class configuration whose failure mode is a broken release cut, which
deserves its own change and its own verification that a legitimate tag push still succeeds — not a
rider on a decision record. It carries no weight against the admin actor either way. Tracked with
the `pull_request:` residual in #885.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,46 @@
---
key: process.hook-resolves-inputs-from-repo-root
title: '2026-08-30 — a hook resolves every path it sources or executes from `$repo_root`, never from an environment variable (#858)'
status: active
since: '2026-08-30'
supersedes: none
superseded-by: none
rule: 'A hook resolves every path whose CONTENT it sources, executes, or consults to decide, from `$repo_root` — derived from the hook''s own `${BASH_SOURCE[0]}` — and never from `$CLAUDE_PROJECT_DIR` or any other environment variable. There is NO telemetry exemption, and the attempt to write one is instructive: the first draft of this record exempted `ETV_HOOK_FIRE_LIB` on the grounds that "a wrong log destination is not a wrong verdict", and cold review refuted it by execution — that path is `. `-SOURCED, so whatever it names runs as CODE inside the hook before stdin is read and before `decide` exists; a file there that prints an `allow` decision and exits 0 grants the merge having bypassed every check in the file. A path''s PURPOSE does not bound its authority; how the hook consumes it does. The failure that matters is not an attacker — the same variable already names the hook binary in `.claude/settings.json`, so a hostile value has chosen which hook runs and the gate is moot before any inner path is read — it is LAUNCHER DIVERGENCE, and it is reachable: husky invokes the prepush hooks as `./.claude/hooks/…`, a relative path wholly independent of `$CLAUDE_PROJECT_DIR`, so the two roots genuinely disagree there. A wrong path holding a plausible file returns a confident answer about another tree; a missing path only asks, so the silent direction is the dangerous one. Two halves of one comparison must never come from two roots.'
signals: 'env var as a security input · `$CLAUDE_PROJECT_DIR` vs `$repo_root` · `${BASH_SOURCE[0]}` self-location · sourced file is code not configuration · which checkout''s code decided this · launcher divergence · husky relative path vs harness env · a wrong path returns a confident answer, a missing one asks · both halves of a comparison from one root · paths: `.claude/hooks/pretooluse-merge-consent.sh`, `scripts/hook-fire-log.sh`, `scripts/check-review-verdict.sh`, `scripts/check-required-contexts.sh` · issues: #858, #891, #787, #629'
mechanics: 'No linter can spot "this path should have come from `$repo_root`". Enforcement is review plus a behavioural test per site: point `$CLAUDE_PROJECT_DIR` at a decoy tree whose copy of the resolved file returns the OPPOSITE decision, and assert the outcome is unchanged. That test needs a NEGATIVE CONTROL or it proves nothing — the passing outcome is also what an inert decoy produces — so run the same decoy again with the hook COPIED INTO it, making the decoy genuinely `$repo_root`, and assert the decision DOES flip. Two worked pairs live in `test_merge_consent_required_check.py`: `test_CLAUDE_PROJECT_DIR_cannot_choose_which_verdict_classifier_runs` and `test_the_SOURCED_fire_log_library_cannot_be_chosen_by_CLAUDE_PROJECT_DIR`, each beside its `..._IS_honoured/sourced_when_it_genuinely_is_the_repo_root` control.'
---
`.claude/hooks/pretooluse-merge-consent.sh` resolved three decision-bearing paths from two different
roots, in one file. #787 bound the guard-scope freshness checker and its snapshot to `$repo_root` and
wrote the reasoning beside them; the H10 verdict classifier, added at #629, kept
`${CLAUDE_PROJECT_DIR:-.}`; and the sourced fire-log library kept `${CLAUDE_PROJECT_DIR:-<self>}`.
**Two resolutions of the same question inside one file is the state most likely to be "tidied" toward
the weaker one.** That is why this is a record rather than a second inline comment: a reader
reconciling the inconsistency has even odds of reconciling it the wrong way, and the comment that
would have stopped them is attached to the other site.
**The exemption this record tried to grant, and why it does not exist.** The first draft drew the
boundary at "does its CONTENT change the decision" and placed `ETV_HOOK_FIRE_LIB` outside it as
telemetry. Cold review refuted that by running it: a decoy `hook-fire-log.sh` in an env-var-named tree
printing an `allow` decision and exiting 0 took the merge decision, 500 lines before the gate. The
line is `. `-SOURCED — sourcing is execution, and a file's stated purpose grants no protection at all.
The lesson generalises past this variable: **classify a path by how it is CONSUMED, never by what it
is called.**
**Name the real threat, or the rule gets argued away.** "An env var is a security input" invites the
correct objection that `$CLAUDE_PROJECT_DIR` already selects the hook itself, so an attacker who
controls it has won already — and an argument refutable on its face gets the rule reverted. The
defensible claim is narrower and holds: the harness launcher and husky are DIFFERENT launchers.
Claude PreToolUse hooks are started as `$CLAUDE_PROJECT_DIR/.claude/hooks/…`, so there the two roots
agree by construction and binding to `$repo_root` is consistency rather than repair. Husky starts the
prepush hooks as `./.claude/hooks/…` from the pushed tree, wholly independent of the variable —
measured 2026-08-30, a push from one worktree with the variable naming another sources the other
tree's code into a gate that can block or allow the push. That is ordinary, non-adversarial, and
produces a confident wrong answer rather than a visible failure.
**Scope actually shipped, so nobody reads more into it.** #858 fixed the verdict classifier and the
fire-log resolution **in `pretooluse-merge-consent.sh` only**. The other twelve tracked hooks still
carry the env-var-first fire-log line; sweeping them needs its own derived population and its own
review, and is #891. One copy was fixed because leaving a total gate bypass above the gate that PR
was hardening would have made the rest of it decorative — not because one hook is special.
@@ -7,7 +7,7 @@ supersedes: none
superseded-by: none
rule: 'Any path that writes a WHOLE entity or a WHOLE child collection — a PUT-replace handler, a hand-built request object, a test comparer standing in for one — derives its field list from the authoritative type and asserts SET EQUALITY against it, rather than enumerating the fields by hand. A hand-written list is correct on the day it is written and structurally unable to report the day it stops being: the field that drifts is the one nobody wrote a line for, so no amount of care in the existing lines can reach it. The failure is silent by construction — a full replace with a field omitted returns HTTP 200 and destroys that field''s value (#754 drifted from a 28-property DTO by one and cleared it; the symptom arrived hours later as missing pixels). SECOND CLAUSE, separable from the first: where a replaced child row carries state keyed to its identity — progression, ordering, an enumerator position — the handler RECONCILES BY ID rather than delete-and-reinsert, because reinsertion silently resets state a client never asked to touch (#252: a schedule PUT reset fill-group progression; #500: a dedup fix became permanent data loss because the add filter and the remove filter used different keys, so the two halves must agree on the key). Delete-and-reinsert is acceptable ONLY where no such state exists, and that emptiness is a fact about today''s schema that a later feature can silently invalidate — so record it where the handler is, dated, rather than leaving it to be re-derived. The canonical worked example is `ToolCatalogTests.Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields`, which reads the accepted fields from the generated OpenAPI document and compares both directions. ON THE SPA SIDE the same rule is enforced by the TYPE SYSTEM rather than by a test: a full-replace body is built as `Complete<T>` (`web/src/api/completeRequest.ts`), a mapped type that makes every member of a generated request type required, so a builder that omits one fails `npm run typecheck`. Annotate BOTH the API-wrapper parameter (so later callers inherit it) AND each construction site including every `.map` callback return type, because the excess-property check that catches a PHANTOM field fires only on a fresh literal in a contextually typed position and a generic `.map` callback is not one. Do not infer from "most builders already typecheck" that the gap is closed: a member is omittable exactly when it is absent from the schema `required` array in the ASP.NET-produced OpenAPI document, and two live cases (#807) sat unchecked inside a large majority of checked ones.'
signals: 'full replace asserts its field list · hand-maintained mirror drifts by one field · reconcile by id not delete and reinsert · 200 and the field is gone · add filter and remove filter must share a key · fill-group progression reset by a PUT · derive the comparer from the DTO · a lossless round-trip test that is itself a hand-copied list · anti-vacuity PIN not a floor on a reflective walk · stale exemption must still name a real property · a complete comparer over a hand-written fixture · universal negative in a record is a trap · the SPA builds a full-replace body as `Complete<T>` · a member is omittable only when it is absent from the OpenAPI `required` array · excess-property checking does not fire inside a generic `.map` callback · a comment warning about a silent reset is not a check · `weight` · `qsvPreferNativeDecoder` · paths: `ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemResponseRoundTripTests.cs`, `ErsatzTV.Mcp.Tests/ToolCatalogTests.cs`, `ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs`, `web/src/api/completeRequest.ts`, `web/src/api/completeRequest.guard.test.ts` · issues: #807, #779, #773, #757, #754, #500, #252'
mechanics: 'Detector G of `docs/defect-shapes-773.md` §4. Enforced per-site by a reflective comparison over the DTO plus a written-down count pin, not by a repo-wide check — see the record body for why a global one is not proposed. The exemption set is empty today; the machinery guarding exemptions remains for the first one that earns its place.'
mechanics: 'Detector G of `docs/defect-shapes-773.md` §4. Enforced per-site by a reflective comparison over the DTO plus a written-down count pin, not by a repo-wide check — see the record body for why a global one is not proposed. The exemption set is empty today; the machinery guarding exemptions remains for the first one that earns its place. ON THE SPA SIDE the same rule is carried by the type system (`Complete<T>`, `spa.full-replace-complete` in `docs/spa-conventions.md` §4b), and since ersatztv#820 its APPLICATION is checked rather than assumed: `web/src/api/completeAnnotations.guard.test.ts` derives the droppable schemas from the generated `v1.d.ts` and the annotations from the SPA AST, asserts set equality between the droppable SCHEMAS and a reviewed disposition table, requires a PRODUCTION annotation naming each schema DISPOSITIONED as needing one — three of the thirteen that can drop a member, the other ten being creates, triggers, read models and two MUST-NOT-ANNOTATE rows, each ruled on individually. A test-file annotation does not discharge that: `*.test.*`, `*.spec.*` and the one setup file named in `vite.config.ts` are excluded, and the setup path is pinned so a rename reddens. NOT covered, stated rather than implied — a SECOND `setupFiles` entry, which vitest would load every run; deriving that list would need another virtual-module plugin and is not proportionate to a three-site coordinated edit. The guard also forbids an annotation on the schemas where annotating is a bug, cross-checks its disposition values against this file''s table from the Python side, and is per-SCHEMA rather than per-site or per-wrapper — a wrapper-level rule was built and REMOVED before merge after four review rounds each defeated it. It also rejects a `Complete<X>` over a hand-written mirror rather than a generated alias. Before it, `scripts/tests/test_optional_request_members.py`''s COVERED disposition ASSERTED the annotation existed and nothing verified it. Residual: the per-CONSTRUCTION-SITE population is not derived (sites in code, #777), so the phantom direction still rests on the §4b site rule and on review.'
---
**This is `testing.guard-derives-population-from-source` applied to a write path, and it is worth its
@@ -160,11 +160,16 @@ in two places: at the full-replace API-wrapper boundary, so later callers inheri
it exists, and at each construction site, which is what keeps TypeScript's excess-property check
(the *phantom* direction) alive.
That is the rule, not a claim about how much of the tree currently follows it. Nothing enforces the
second half — #820 — so any sentence here asserting present coverage would be falsifiable by one
`tsc` run and would go stale on the next screen anyone adds. Two wrappers annotated under #807
initially shipped with unannotated construction sites for exactly that reason. To find out what is
actually annotated, read the code; do not read a count here.
That is the rule, not a claim about how much of the tree currently follows it. The two halves are
enforced UNEQUALLY, and the split is the thing to know. Since #820 the SCHEMA half is checked:
`web/src/api/completeAnnotations.guard.test.ts` derives the droppable schemas from the generated
types and the annotations from the SPA AST, and fails when a schema that can drop a member is named
by no PRODUCTION `Complete<…>`. It is per-SCHEMA: it catches the annotations being deleted, not one
being moved off an API wrapper onto some other production file. The
CONSTRUCTION-SITE half is still enforced by review only — that population is sites in code (#777).
So a count of annotated sites here would still be falsifiable by one `tsc` run and would go stale on
the next screen anyone adds. To find out what is actually annotated, read the code; do not read a
count here.
**Why the second half of the rule is the one that gets skipped.** The wrapper annotation catches a
MISSING member anywhere. The PHANTOM direction — a field the schema does not accept — relies on
@@ -247,11 +252,26 @@ contingency is now MONITORED — the derived test above reddens the moment one o
optional member — instead of being assumed. Annotating the rest is cheap and should happen when
each is next touched.
**Residue, named rather than implied closed.** `Complete<T>` proves its own semantics but NOT that
it is applied: reverting one screen to its pre-#807 form leaves the guard green, because the
population of construction sites is derived from nothing. The repo already owns the tool for that
— `web/src/api/pageSizeScan.ts` (TypeScript compiler API) plus a registry cross-checked in both
directions — and building it is tracked in #820. Until then this convention is enforced by review,
which is the weaker thing this record exists to warn about. Separately: `Complete<T>` is shallow,
so a new nested item request type needs its own annotation; and requiring a field to be *named* is
not the same as requiring it to be *populated* correctly.
**Residue, named rather than implied closed.** #820 built the half that had an enumerable
population: `web/src/api/completeAnnotations.guard.test.ts` derives the droppable schemas from the
generated `v1.d.ts` and the `Complete<…>` annotations from the SPA AST, so deleting every production
annotation for a schema dispositioned as needing one now reddens. It found one live hole on landing: two
`playouts.ts` request types were hand-written mirrors SHADOWING generated schemas of the same name,
so their `Complete<>` was checking a local copy rather than the contract.
What is NOT closed: the per-CONSTRUCTION-SITE population, which is sites in code (#777), so the
PHANTOM direction still rests on review, as does the wrapper half of the rule. A test-file annotation
cannot discharge the obligation in any spelling vitest COLLECTS (`*.test.*`, `*.spec.*`) nor from the
one setup file `vite.config.ts` names, whose path is pinned — but a SECOND `setupFiles` entry would,
and that direction is open —
but a screen that builds a body inline without annotating its `.map` callback is still only caught by
a reader, and so is an annotation moved off a wrapper. A guard that DID enforce the wrapper half was
built for #820 and removed before merge: four review rounds each found a fresh way past it, the last
being an `export function` to `export const` refactor invisible to both the AST scan and its
cross-check at once. Five defects from one mechanism, so the mechanism went.
Separately: `Complete<T>` is shallow, so a new nested item request type needs its own annotation. A
DEEP variant was probed under #820 and works against the real generated types, but it was rejected:
it cannot be applied blanket-wise, because `UpdateChannelRequest.logo` reaches
`ArtworkContentTypeModel`, where annotating would force a caller to fabricate server-computed
values — and it closes only the missing direction, not the phantom one. Finally, requiring a field
to be *named* is not the same as requiring it to be *populated* correctly.
@@ -7,7 +7,7 @@ supersedes: none
superseded-by: none
rule: 'A `MUTATION` row in `docs/guard-inventory.md` is not a statement that someone once witnessed a red. It carries a DECLARED clause mutation in `scripts/tests/mutation_manifest.py`, and `scripts/tests/test_mutation_harness.py` applies that mutation to an isolated copy of the repository on every run and requires the row''s OWN named test to go red. The manifest and the MUTATION rows are compared for SET EQUALITY in both directions, so a row cannot claim the grade without a mutation and a mutation cannot outlive the grade it justifies. EXIT STATUS IS NOT THE VERDICT: each entry also declares the DIAGNOSTIC its red must carry, matched against pytest''s exception output alone, because pytest reports a crashing test exactly as it reports a detecting one and a red for an unrelated reason is evidence about nothing. WHERE THE GUARD IS ITSELF A TEST, `target` may differ from `guard` and the exact-once check applies to the declared TARGET. Two shapes are admissible and the choice is not free. Where the guard''s assertion IS the check — a completeness comparison against a Markdown inventory — the mutation goes into the guarded ARTIFACT, per `testing.guard-ships-with-mutation-proof`''s checker-guard exception, because mutating such a checker''s own POPULATION demonstrates a false POSITIVE while proving nothing about the detection the row claims. Where the guard is a test module wrapping a separately mutable DETECTOR or helper, the clause may be in that detector, since disarming it is a real clause disarm and the module''s own assertion is what notices. THE MUTATION IS DECLARED, NEVER INFERRED: a harness that guessed which clause of a 90-line hook is the guard would manufacture the confident-but-empty coverage this exists to prevent, which is why `testing.guard-ships-with-mutation-proof` rejected a generic runner. Where a proof test already names its clause in source, the manifest reuses THAT string, so a retarget in either place is caught by the other. COARSENESS IS RECORDED, NOT HIDDEN: each entry is graded `CLAUSE` or `DETECTOR`, and a `DETECTOR` entry — one whose detector accumulates faults from independent arms, so disarming any single arm leaves its proof test green — must CARRY the finer mutation that survived, which is re-run every time and required to keep surviving. Guards that are not graded `MUTATION` each carry a STATED reason in that same manifest, keyed on the guard and compared for SET EQUALITY against the inventory''s `GUARD` rows in both directions — so a new guard cannot arrive without someone writing what a proof would need, and a reason cannot outlive the row it is about. Keying the reason on the row''s GRADE instead is tautological (a new guard inherits one and nobody looks at it) and a pinned COUNT moves only on net change; both were tried and are rejected. The sandbox is a real git repository built from `git ls-files` with working-tree content, never a filesystem walk.'
signals: 'mutation harness · declared clause not inferred · MUTATION row is executed · set equality manifest vs inventory · CLAUSE vs DETECTOR granularity · surviving finer mutation is re-run · pytest exit code 1 is the only red that counts · every other exit status is rejected · positive control before any mutation · sandbox from git ls-files · paths: `scripts/tests/test_mutation_harness.py`, `scripts/tests/mutation_manifest.py`, `scripts/tests/mutation_harness_lib.py`, `docs/guard-inventory.md` · issues: #790, #775, #774, #778, #806'
mechanics: 'The harness builds ONE sandbox per session — tracked files only, from `git ls-files -s`, in an environment with every `GIT_*` variable stripped so an exported `GIT_DIR`/`GIT_COMMON_DIR`/`GIT_CONFIG_*` cannot point its `init`/`add`/`commit` at the real repository — and resets it between mutations with `git reset --hard` TO A BASELINE COMMIT RECORDED OUTSIDE THE REPOSITORY, plus `git clean -qffdx`. Both halves matter: bare `git reset --hard` resets to whatever HEAD is, so a proof test that COMMITS inside the sandbox moves HEAD onto a commit carrying the mutant and every later reset restores it faithfully; and a baseline held as a REF inside the sandbox is one more thing a proof can move. `core.hooksPath`, `commit.gpgsign` and `core.worktree` are pinned on every git invocation for the same reason. The `-ff` removes a nested repository a proof may have left. It runs the full set of named proof tests UNMUTATED first, inside the fixture rather than as a separate test so ordering is a dependency and not a convention. Only pytest exit code 1 counts as red; every other status is rejected. That is what closes the two ways a proof ref goes stale, and they are measured rather than assumed: with an explicit `file.py::function` node id a missing file and a missing function both exit 4, while 5 needs a collection that succeeded and selected nothing. The sandbox skips the `ErsatzTV-macOS` gitlink and recreates the `.claude/skills/jellyfin` symlink as a symlink. Cost: ~14s, against a ~4min `script-tests` suite.'
mechanics: 'The harness builds ONE sandbox per session — tracked files only, from `git ls-files -s`, in an environment with every `GIT_*` variable stripped so an exported `GIT_DIR`/`GIT_COMMON_DIR`/`GIT_CONFIG_*` cannot point its `init`/`add`/`commit` at the real repository — and resets it between mutations with `git reset --hard` TO A BASELINE COMMIT RECORDED OUTSIDE THE REPOSITORY, plus `git clean -qffdx`. Both halves matter: bare `git reset --hard` resets to whatever HEAD is, so a proof test that COMMITS inside the sandbox moves HEAD onto a commit carrying the mutant and every later reset restores it faithfully; and a baseline held as a REF inside the sandbox is one more thing a proof can move. `core.hooksPath`, `commit.gpgsign` and `core.worktree` are pinned on every git invocation for the same reason. The `-ff` removes a nested repository a proof may have left. It runs the full set of named proof tests UNMUTATED first, inside the fixture rather than as a separate test so ordering is a dependency and not a convention. Only pytest exit code 1 counts as red; every other status is rejected. That is what closes the two ways a proof ref goes stale, and they are measured rather than assumed: with an explicit `file.py::function` node id a missing file and a missing function both exit 4, while 5 needs a collection that succeeded and selected nothing. The sandbox skips the `ErsatzTV-macOS` gitlink and recreates the `.claude/skills/jellyfin` symlink as a symlink. Cost: ~14s. The ~4min `script-tests` suite it was measured against is PRE-#870 and no longer holds: that change made `count_pr_mutations` read to its 20-page cap, and `test_pr_changed_files.py` went from 202s to 474s locally (286 -> 294 tests, same machine, `-p no:randomly`, 528383cf3 vs the #870 branch), so the job is roughly double. Re-measure on a runner before quoting a job figure.'
---
**What #775 left open, in its own words: "nothing checks that a row claiming `MUTATION` is telling
File diff suppressed because one or more lines are too long
+6 -5
View File
@@ -69,7 +69,7 @@ classifications differ; otherwise the strictest applies and the Note names the e
| `.claude/hooks/pretooluse-merge-consent.sh` — scheduled auto-merge (`merge_when_checks_succeed`) | `UNSAFE-KNOWN` | **Preflight, not a pin** — the hook's own comment concedes the branch-protection read pins nothing, so calling it `PINNED` would contradict this file's definition. What the hook proves is a snapshot; Gitea merges later; and since #778 it also verifies that `review-verdict/h10` is a required check, reading the repo's **full rule list** (never the by-name endpoint, which does no matching and knows nothing about precedence) — nothing can govern the base → deny, unreadable → ask, and a **glob rule that could govern it → ask**, because the hook deliberately does not reimplement Gitea's glob dialect. It also asks when two rule names fold equal, or when either name is non-ASCII, since Gitea's `EqualFold` is Unicode-aware and its rule precedence is not derivable here. That converts an unobserved assumption into an observed precondition and detects drift, but an admin can still weaken the protection *after* the read. Accepted. Calling the residual "closed one layer down" by the very branch protection an admin may have removed would be **circular**: it is not closed; it is BOUNDED, and the bound is a trust assumption that should be stated rather than dressed as a mechanism: everything on this path assumes repo-admin branch-protection config is not hostile. If protection is present at preflight and removed afterwards, an unreviewed head can merge, and nothing in this repo would detect it. What the check does buy is that the far commoner case — protection already weakened when the merge is attempted — stops being silent. A PreToolUse hook cannot add `head_commit_id` to the merge call, so it can never convert its own grant into a CAS — it can only refuse, which is what it now does. |
| `.claude/hooks/pretooluse-merge-consent.sh` — base-retarget detection | `UNSAFE-KNOWN` | `recorded_base` parsed from the H10 status description is compared against the PR's live `.base.ref` (#632), and it deliberately compares the base **ref** rather than `base.sha` — the tip moves on every unrelated merge, so comparing it would deadlock every open PR. The residual is the same ABA the enumerator has: a name can be retargeted away and back, and the comparison cannot see it. Accepted because the alternative that CAN see it is the monotonic event count, which lives in the workflow that writes the enforced status rather than in this advisory hook. |
| `.claude/hooks/pretooluse-merge-consent.sh` — `## Done-when` issue-body read | `UNSAFE-KNOWN` | The issue body carries an `updated_at` that is not used, so a box unticked between the read and the merge is invisible. Accepted: the only actor who can edit the issue is the one requesting the merge, so this is a self-inflicted race with no adversary and no silent-failure mode. |
| `.claude/hooks/pretooluse-merge-consent.sh` — guard-scope freshness `branch_protections` read (ersatztv#787) | `UNSAFE-KNOWN` | A SECOND, separate read of the same endpoint, and the first one on the IMMEDIATE merge path: the pre-existing row above fires only when arming a scheduled auto-merge. Read-then-act with nothing pinning it — branch protection can change between this read and the merge Gitea performs. Accepted because of what the read AUTHORIZES, which is nothing: every outcome is `ask` or silence, never a grant and never a deny, so a stale or raced read can only cost a prompt. It is also skipped entirely unless the merge target is the repo `.gitea/required-status-contexts.json` names. The reconciliation it performs is bounded to merges made through this hook — a web-UI merge never runs it. |
| `.claude/hooks/pretooluse-merge-consent.sh` — guard-scope freshness `branch_protections` read (ersatztv#787) | `UNSAFE-KNOWN` | **Shares ONE read with the row above since ersatztv#859**, and is the only consumer on the IMMEDIATE merge path (that row fires only when arming a scheduled auto-merge). It was a second, separate GET of the same endpoint until then: on a scheduled merge the endpoint was hit twice, and — the part that mattered more than the round trip — the two reads of a MUTABLE config could return different answers, so the required-check arm and this one could decide about different repo states with neither able to notice. The payload is now fetched once per run and cached; the two arms still classify it independently, because they ask different questions of it (`$base_ref`'s required contexts vs `main`'s snapshot freshness) — the bytes are shared, never a verdict. Read-then-act with nothing pinning it — branch protection can change between this read and the merge Gitea performs. Accepted because of what the read AUTHORIZES, which is nothing: every outcome is `ask` or silence, never a grant and never a deny, so a stale or raced read can only cost a prompt. It is also skipped entirely unless the merge target is the repo `.gitea/required-status-contexts.json` names. The reconciliation it performs is bounded to merges made through this hook — a web-UI merge never runs it. |
| `.claude/hooks/prepush-donewhen.sh` — `## Done-when` issue-body read | `UNSAFE-KNOWN` | No pin, but the hook's exit code gates the push synchronously — git blocks on this process. Blast radius is near zero regardless: `main` carries `enable_push: false` and `block_admin_merge_override: true`, so the direct push this hook exists to block is refused server-side for every account (#743). This is belt-and-braces over a path the server already refuses. |
| `.claude/hooks/pretooluse-nav-guard.sh` | `N/A` | Reads only the proposed tool call's own parameters and decides synchronously; the `curl` mentions in the file are prose, not executed lines. |
@@ -96,8 +96,8 @@ classifications differ; otherwise the strictest applies and the Note names the e
| Site | Class | Note |
|---|---|---|
| `scripts/post-review-verdict.sh` — commit-status write | `PINNED` | Re-reads the PR and compares **both** `.head.sha` and `.base.ref` immediately before the POST, and `die`s (exit 1, no status written) on a mismatch **or on a field it cannot read**. That last clause is load-bearing: guarding both comparisons with `[ -n "$x" ] &&` alone makes a well-formed 2xx body that merely omits the field a no-op, and the status is posted having confirmed nothing. Regression-tested against the real predecessor (#778), since the redundant-looking `-z` arm alone mutates green. Closes #706 and #632 for this path by read-compare-refuse, not by CAS: Gitea's status API offers no conditional write. Residual closed on the write ORDER since #792: the status is written first and the comment second, so a refusal can no longer leave a verdict comment with no status behind it — the only reachable half-state is a status with no comment, which the merge hook resolves as `ask` (`release.verdict-writes-status-before-comment`). |
| `scripts/pr-changed-files.sh` — paged file enumeration | `UNSAFE-KNOWN` | #707's fix: `.base.ref`, `.base.sha` and `.head.sha` are captured before paging and re-checked after, and any *observed* movement fails the whole enumeration closed rather than emitting a short list. But before-and-after equality is **ABA-vulnerable** — a `main → scratch → main` retarget during paging can return the same ref and, if nothing merged meanwhile, the same base sha, while the pages in between were diffed against the scratch base. The script's own comment says it narrows rather than erases — "any movement fails" would be stronger than the code. Accepted here because the enumerator cannot close it alone, but be exact about what the caller-side fence does and does not cover: `ci.verdict-write-retarget-fence` counts `change_target_branch` events, so it catches the BASE alias and **nothing else**. A HEAD alias — a force-push `H1 -> H2 -> H1` during pagination — leaves the final `.head.sha` comparison equal while the middle pages were enumerated against `H2`, and NOTHING inside this script can see it. **Fenced at the enforced caller since #803** (2026-08-28): the same timeline walk now also counts `pull_push`, so a head that moved at all during classification withholds the write — subject to that walk's own terminator, which is defeatable by a page of filtered rows (**#870**, pre-existing and equally affecting the base axis). The grade stays `UNSAFE-KNOWN` for the SCRIPT, because the script's own guarantee is unchanged and one-way — a caller that does not run the fence inherits the alias. Enumeration bound to an immutable tree remains unavailable: re-probed at 1.27.1 on 2026-08-28, `compare/{base}...{head}` still returns no `files` key. |
| `scripts/post-review-verdict.sh` — commit-status write | `PINNED` | Re-reads the PR and compares **both** `.head.sha` and `.base.ref` immediately before the POST, and `die`s (exit 1, no status written) on a mismatch **or on a field it cannot read**. That last clause is load-bearing: guarding both comparisons with `[ -n "$x" ] &&` alone makes a well-formed 2xx body that merely omits the field a no-op, and the status is posted having confirmed nothing. Regression-tested against the real predecessor (#778), since the redundant-looking `-z` arm alone mutates green. Closes #706 and #632 for this path by read-compare-refuse, not by CAS: Gitea's status API offers no conditional write. Residual closed on the write ORDER since #792: the status is written first and the comment second, so a refusal can no longer leave a verdict comment with no status behind it — the only reachable half-state is a status with no comment, which the merge hook resolves as `ask` (`release.verdict-writes-status-before-comment`). Since #845 the status is also READ BACK after the POST and refused unless it is the write just made, and — for a `success` only, mirroring the gate's own asymmetry — unless its `.creator.login` is on the allow-list derived from the gate (a `failure` is inherited from any attributable account, so requiring membership there would refuse a rejection the gate honours) — the same read-compare-refuse shape, applied to WHOSE verdict it is rather than to which commit it covers. That read is check-then-act too and is graded with this row rather than separately: it can only withhold this tool's success report, never authorize anything, and the residual it leaves — a non-inheritable status standing with no comment beside it — is the `ask` half-state above. Two properties of that read are recorded because neither is visible in the response. `GET /commits/{sha}/status` PAGES and its `total_count` reports the PAGE rather than the total (measured Gitea 1.27.1, 2026-08-29 — `?limit=2` on a 15-context head returned 2 rows and `total_count: 2`), so a truncated body is indistinguishable from a complete one. That looked like a risk of the verdict row falling off a page and is NOT, because of the second property: the endpoint selects each context's MAX row id and orders those DESCENDING before paginating (same measurement — ids `[17,19,...,41,43]`, and `?limit=2` returns 41 and 43, the two highest). The row this tool has just POSTed is the newest on the head, so it sorts first and page 1 holds it at any page size. `?limit=100` (the instance clamps `limit` to `MAX_RESPONSE_ITEMS`, measured at 50) is kept as insurance against a burst of concurrent status writes and against that ordering changing, and has no observable effect otherwise — stated so it is not mistaken for an untested guard. A row it cannot find is therefore refused with the row count it read and no stated cause; an earlier version of this row named truncation as the candidate cause, before the ordering was measured. |
| `scripts/pr-changed-files.sh` — paged file enumeration | `UNSAFE-KNOWN` | #707's fix: `.base.ref`, `.base.sha` and `.head.sha` are captured before paging and re-checked after, and any *observed* movement fails the whole enumeration closed rather than emitting a short list. But before-and-after equality is **ABA-vulnerable** — a `main → scratch → main` retarget during paging can return the same ref and, if nothing merged meanwhile, the same base sha, while the pages in between were diffed against the scratch base. The script's own comment says it narrows rather than erases — "any movement fails" would be stronger than the code. Accepted here because the enumerator cannot close it alone, but be exact about what the caller-side fence does and does not cover: `ci.verdict-write-retarget-fence` counts `change_target_branch` events, so it catches the BASE alias and **nothing else**. A HEAD alias — a force-push `H1 -> H2 -> H1` during pagination — leaves the final `.head.sha` comparison equal while the middle pages were enumerated against `H2`, and NOTHING inside this script can see it. **Fenced at the enforced caller since #803** (2026-08-28): the same timeline walk now also counts `pull_push`, so a head that moved at all during classification withholds the write — subject to that walk's own terminator, which is defeatable by a page of filtered rows — **NARROWED, not closed, by #870** (2026-08-30): the walk no longer reads an empty page BEFORE the cap as the end of the list, but reads to its 20-page cap and requires the last page to be empty, so the same defeat now needs a timeline of more than 1000 rows rather than ~100, with the same 50-row filtered block pinned to offsets 950..999 — about 10x the price, the padding beneath the block being the attacker's to supply on their own PR. A cap filled with READABLE pages fails closed; a cap hidden behind a filtered block still certifies a too-low count. The grade stays `UNSAFE-KNOWN` for the SCRIPT, because the script's own guarantee is unchanged and one-way — a caller that does not run the fence inherits the alias. Enumeration bound to an immutable tree remains unavailable: re-probed at 1.27.1 on 2026-08-28, `compare/{base}...{head}` still returns no `files` key. |
| `scripts/select-queue.sh` — issue list, then per-issue `/dependencies` | `UNSAFE-KNOWN` | The open-issue list (labels, milestone, priority) is snapshotted once; per-candidate dependency reads happen seconds later and never re-read the issue's own labels, so an issue claimed `in-progress` in that gap still appears on the shortlist. Accepted: the script authorizes **no write**. The real gate is the four-way claim check in `process.parallel-session-claim`, which runs after selection and re-reads live state by construction. Tightening this would move a check that must be adversarial into a tool that is advisory. |
| `scripts/ci-detect-already-validated.sh` — prior-head combined status | `UNSAFE-KNOWN` | Reads the PR head's status and emits `skip=true`, with nothing re-checking before the consuming job runs. Accepted and narrow: the skip elides only **re-running** test/migrations on a tree already validated; the `build` job still builds and pushes unconditionally, so no image ever ships from unvalidated source. |
| `scripts/issue-qualification-audit.sh` — paged issue list, then a report | `UNSAFE-KNOWN` | Pages the open-issue list and reports which issues lack a `priority:` label, so like every paged read here its pages can straddle a change and the report can name a state no single instant held. Graded to match `select-queue.sh` rather than `N/A`: the two run the same shape, and the reason offered for accepting `select-queue.sh` — it authorizes no write — cannot simultaneously be the reason this one is out of the class. Accepted on the same terms: it is advisory, session-end, human-read, and the labels it prompts for are applied by hand afterwards. |
@@ -111,6 +111,7 @@ classifications differ; otherwise the strictest applies and the Note names the e
| `scripts/ci-detect-docs-only.sh` — `git fetch origin "$base"`, then diffs the fetched tip against HEAD to emit `docs_only`, which gates whether the required test/migrations jobs run their real steps | `UNSAFE-KNOWN` | Read-then-act with no re-check between the fetch and the emitted value, and the decision genuinely gates required CI work. Accepted because the script is deliberately asymmetric: every ambiguous, undeterminable or shallow-checkout case resolves to `docs_only=false` (run everything), and only an exact unanimous all-docs diff yields `true` — so a stale or racing base read can at worst cause an unnecessary full run, never a skipped one (#416). |
| `scripts/refresh-shared-checkout.sh` — `git fetch origin main`, then `git merge --ff-only origin/main` and a conditional `npm ci` | `UNSAFE-KNOWN` | Fetch-then-act with no re-check between the fetch and the merge. Accepted because every action is self-refusing or reversible: `--ff-only` fails harmlessly rather than diverging if the ref moved on, the script refuses outright when the tree is not clean `main` or is ahead or mid-rebase, and this is a developer-convenience checkout rather than a release or merge-authorization path — a stale read costs one extra fetch next run, never lost work. |
| `scripts/lib/review-verdict-vocabulary.sh` | `N/A` | Reads nothing at all — a sourced declaration of the H10 verdict word list plus pure functions over it; it makes no call, local or remote. Listed because it is in this inventory's scope (`scripts/**/*.sh`), not because it has a remote-state shape. |
| `scripts/lib/h10-reviewers.sh` | `N/A` | Reads no live remote state — it derives the gate's `H10_REVIEWERS` allow-list by parsing one anchored assignment out of the LOCAL `.gitea/workflows/review-verdict.yml`, and makes no call of its own. The read-then-act it feeds belongs to its caller's row above: `post-review-verdict.sh` compares the derived list against a creator it reads back from the server. Deliberately no env-var override of the file it reads — an env var choosing which tree answers a security question is an unsound input (#858). |
| `scripts/check-review-verdict.sh` | `N/A` | Reads no live remote state itself — classifies a comments JSON payload supplied on stdin; the fetch belongs to the caller's row. |
| `scripts/check-required-contexts.sh` | `N/A` | Reads no live remote state itself — classifies a `branch_protections` payload supplied on stdin; the fetch belongs to the caller's row (`.claude/hooks/pretooluse-merge-consent.sh` — guard-scope freshness). |
| `scripts/lib/branch-rule-classifier.jq` | `N/A` | Reads nothing at all — a jq program over a `branch_protections` payload its caller supplies, resolving which protection rule governs a branch. Loaded by both `pretooluse-merge-consent.sh` and `check-required-contexts.sh`; every fetch belongs to those callers' rows. |
@@ -144,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. |
+29
View File
@@ -514,6 +514,35 @@ and fails until each has a stated disposition. See
`docs/decisions/records/testing/full-replace-asserts-field-list.md`, and
`web/src/api/completeRequest.guard.test.ts` for the executed proof.
**What is machine-checked, exactly (#820).**
`web/src/api/completeAnnotations.guard.test.ts` scans the SPA with the TypeScript compiler API and
asserts, per SCHEMA: every schema that can silently drop a member and is dispositioned as needing an
annotation carries a `Complete<…>` naming it in some **production** file — a `*.test.*` or
`*.spec.*` file does not count, nor does the setup file `vite.config.ts` names (a SECOND
`setupFiles` entry would, and is a stated residual); the schemas where annotating would be a bug carry none; and every `Complete<X>` resolves to
an alias of `components['schemas'][…]` rather than a hand-written mirror — annotating a mirror proves
only that the caller filled in the mirror, which is the #754 mechanism wearing the annotation meant to
prevent it. Its disposition table is cross-checked against
`scripts/tests/test_optional_request_members.py` by `test_complete_annotation_dispositions.py`, so the
two cannot quietly disagree about a schema they both rule on.
**What is NOT checked — read this before assuming the guard has your back.**
- **Neither of the two rules above is enforced per SITE.** The check is that *a* production
annotation naming the schema exists. Deleting the `Complete<…>` from an API wrapper stays green as
long as some other production file still names that schema. Write both annotations anyway; the
guard catches wholesale deletion, not relocation.
- **It is token presence, not liveness.** A `Complete<X>` that nothing uses satisfies it.
- **The phantom direction is not checked at all.** That needs a fresh object literal in a
contextually typed position, which a generic `.map` callback's return is not — the population is
sites-in-code (#777).
A guard that derived the write WRAPPERS and required the annotation on the wrapper parameter was
built for #820 and REMOVED before merge, after four review rounds each found a fresh way past it —
the last being an `export function` to `export const` refactor that removed real protection while the
scanner and its cross-check went blind together. Do not rebuild it without reading that history in
`docs/guard-inventory.md`; the cheap version of that idea does not work.
## 5. Artwork rendering
Render `item.artwork` / `item.poster` (or whatever the DTO field is named) **directly as an `<img
+40 -3
View File
@@ -19,7 +19,12 @@
# drift — they were both read and they differ (the finding)
# nomatch — no rule governs the branch at all (protection removed)
# undecidable — a glob rule could govern the branch, so which rule applies is not derivable here
# unreadable — the payload, or a field this decision consumes, is not the shape it must be
# unreadable — the payload, or a field this decision consumes, is not the shape it must be.
# Includes a rule whose NAME is unusable — either both `branch_name` and `rule_name`
# supply no name (absent, null or empty), or one of them is PRESENT holding a
# non-string, which poisons the rule however good its sibling is. The classifier
# answers `unreadable` for both, and a list with such a member supports no finding
# about which rule governs the branch (ersatztv#859).
# Exit : 0 having printed a class; 2 on a USAGE error (never a class, so a caller cannot mistake
# a broken invocation for a finding).
set -euo pipefail
@@ -85,13 +90,45 @@ fi
classifier="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/branch-rule-classifier.jq"
[ -r "$classifier" ] || { echo "classifier not readable: $classifier" >&2; exit 2; }
# THE PAYLOAD MUST BE AN ARRAY to produce a finding, the same gate the merge hook applies
# before its own call. Without it a JSON OBJECT reached the classifier, matched no rule, and this
# script printed `nomatch` — "no rule governs the branch at all (protection removed)", a positive
# claim about the server's configuration derived from a payload that is not the shape it must be.
# `{}` produced exactly that, measured; removing the gate also turns `{"a":{...}}` into a confident
# `drift`. Pre-existing rather than introduced by ersatztv#859, and fixed here because that issue is
# precisely about arms asserting findings they did not establish; `unreadable` already means "no
# comparison was made", which is the truth for a non-array body.
#
# "To produce a finding" rather than "before the classifier sees it", and the distinction is the
# `ci.jq-version-contract` gap (ersatztv#643): on jq 1.6 — the version `scripts/jq-preflight.sh`
# pins for `script-tests` — `jq -e` over EMPTY input exits 0, so an empty 200 body is not diverted
# here and does reach the classifier. Measured on both 1.6 and 1.8.2: the class word is `unreadable`
# either way, because the classifier then produces nothing and the catch-all answers. The outcome is
# version-independent; only the mechanism differs, so the comment states the outcome.
if ! printf '%s' "$payload" | jq -e 'type == "array"' >/dev/null 2>&1; then
printf 'unreadable\n'; exit 0
fi
verdict=$(printf '%s' "$payload" | jq --arg b "$branch" -c -f "$classifier" 2>/dev/null || true)
case $(printf '%s' "$verdict" | jq -r '.verdict // ""' 2>/dev/null || true) in
none) printf 'nomatch\n'; exit 0 ;;
undecidable) printf 'undecidable\n'; exit 0 ;;
exact) : ;;
# jq threw, or the payload was not an array the classifier could read. The comparison did NOT
# happen, and `unreadable` is the class that says exactly that — never a silent success.
# EVERYTHING ELSE, deliberately as ONE arm. The classifier's declared `unreadable` verdict (a rule
# whose name this program could not use, ersatztv#859) and a jq crash both land here, and they are
# NOT given separate arms — because this script's contract publishes exactly five class words and
# `unreadable` already means precisely "no comparison was made", which is true of both.
#
# A separate `unreadable)` arm printing the same word was written first and then deleted: cold
# review measured it as a no-op (removing it left the suite green), and the merge hook's own
# comment condemns exactly that — an arm no observation can distinguish is a comment with syntax.
# The hook can afford two arms because it has two distinct REASON STRINGS to hand an operator;
# here there is one output word, so a second arm would be decoration.
#
# The mechanism is still pinned, one level down, where it IS observable:
# `test_check_required_contexts.py::test_the_CLASSIFIER_ITSELF_returns_the_declared_unreadable_verdict`
# asserts the classifier exits 0 with `{"verdict":"unreadable"}` rather than crashing, which this
# arm cannot distinguish and therefore must not claim to.
*) printf 'unreadable\n'; exit 0 ;;
esac
+5 -3
View File
@@ -75,9 +75,11 @@ esac
# cannot fail to reach the other.
#
# Resolved relative to THIS FILE, not the caller's cwd: the merge-consent hook invokes this script
# by absolute path (`${CLAUDE_PROJECT_DIR:-.}/scripts/check-review-verdict.sh`) from whatever
# directory the tool call happened in, so a cwd-relative lookup would find the vocabulary only by
# luck.
# by absolute path (`$repo_root/scripts/check-review-verdict.sh`, where `$repo_root` is derived from
# that hook's own `${BASH_SOURCE[0]}`) from whatever directory the tool call happened in, so a
# cwd-relative lookup would find the vocabulary only by luck. That path was
# `${CLAUDE_PROJECT_DIR:-.}/...` until ersatztv#858 — an env var choosing which checkout's copy of
# this classifier decides a merge — and the reasoning for the change lives beside the caller.
#
# Every failure below is exit 2, the script's declared input-error code, because callers MUST fail
# closed on it (see the header). A classifier that cannot load its vocabulary does not know what a
+60 -6
View File
@@ -31,6 +31,55 @@ emit() {
echo "-> docs_only=$1"
}
# NEVER PASS `--depth` TO A COMPLETE CLONE (ersatztv#836). `git fetch --depth=N` GRAFTS one shallow:
# it writes `.git/shallow` and cuts history at N even though every object is already present. Both
# fetch sites below go through this one function, because they differ only in N.
#
# THE CONSUMERS, enumerated rather than assumed — `grep -rn ci-detect-docs-only .gitea/` returns
# exactly four call sites: `test`, `migrations` and `functional-e2e` (`fetch-depth: 2`, genuinely
# shallow) and `build` (`fetch-depth: 0`, complete). `api-docs` and `format` also have a step with
# `id: detect`, but it is their OWN inline diff, not this script. Enumerate by the grep above, never
# by the step id — that is the trap, and it points at the wrong two jobs. (The grep returns five
# hits: these four call sites plus docker-build.yml's own header comment.)
#
# `build` is therefore the only complete-clone consumer, and the push arm is the only arm it takes.
# That is where the defect was: the depth grafted its clone on every push to `main`, so the
# `git describe --tags` in the very next step ("Compute version and tags") found no reachable tag,
# and every `:latest` image carried `InformationalVersion 0.0.0-<sha>` instead of `26.x.y-<sha>`.
#
# THE PR ARM'S `--depth=200` HAS NO COMPLETE-CLONE CONSUMER TODAY. On `pull_request` the only
# consumers are the three `fetch-depth: 2` jobs, so that depth was inert, not latently firing. It is
# dropped anyway because the clause is shared: the next `fetch-depth: 0` consumer added to this arm
# would inherit the graft silently, and finding it again would cost what #836 cost.
#
# WHY THE DEPTH IS KEPT FOR SHALLOW CHECKOUTS, stated as measured rather than as load-bearing. A
# depth-LESS fetch into a `--depth=2` clone leaves `.git/shallow` in place, still writes FETCH_HEAD,
# and still lets `git rev-list --parents -n1 HEAD` report the parents the push arm reads — so
# dropping the depth outright would not have broken those three jobs either. It is retained because
# it is what they were given and nothing argues for widening their fetch, not because removing it
# was shown to break anything.
#
# Measured on a real `file://` clone: complete -> `git describe --tags --abbrev=0` = `v26.3.1`; after
# `git fetch --no-tags --depth=2` -> `.git/shallow` written, `fatal: No tags can describe`.
#
# ANY ANSWER BUT `true` PASSES NO DEPTH, and there is more than one such answer. `git rev-parse`
# fails outright where there is no readable repository; on a git older than 2.15 the flag is not
# recognised and rev-parse ECHOES IT BACK verbatim with status 0 (verified on 2.55: an unknown
# `--is-...` flag prints itself and exits 0). Neither is `true`, both take the no-depth branch, and
# in both a depth would have been useless anyway. Defaulting the other way would re-graft on exactly
# the paths nobody can observe.
fetch_ref() { # $1 = ref to fetch; $2 = depth to use ONLY IF this checkout is already shallow
local is_shallow
is_shallow="$(git rev-parse --is-shallow-repository 2>/dev/null || echo unknown)"
if [ "$is_shallow" = "true" ]; then
echo "checkout is shallow; fetching with --depth=$2 (ersatztv#836)"
git fetch --no-tags "--depth=$2" origin "$1" 2>/dev/null
else
echo "checkout is not shallow (is-shallow=$is_shallow); fetching without --depth (ersatztv#836)"
git fetch --no-tags origin "$1" 2>/dev/null
fi
}
# A release tag must NEVER be treated as docs-only, whatever it touches.
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
echo "tag build (${GITHUB_REF_NAME:-?}); never docs-only"
@@ -56,7 +105,7 @@ case "$event" in
emit false
exit 0
fi
if ! git fetch --no-tags --depth=200 origin "$base" 2>/dev/null; then
if ! fetch_ref "$base" 200; then
echo "fetch of base '$base' failed; running full matrix (safe default)"
emit false
exit 0
@@ -65,11 +114,16 @@ case "$event" in
;;
push)
# Only branch pushes reach here (tags handled above). Skip only when the range is UNAMBIGUOUS —
# HEAD is a merge commit (how every update to main lands): its first parent is the pre-merge
# tip, so HEAD^1 is the base. Fetch depth 2 so the first-parent tree exists in the shallow
# checkout; if it still can't be resolved/diffed we fall through to the full matrix. A non-merge
# (direct/multi-commit) push is ambiguous here -> full matrix.
git fetch --no-tags --depth=2 origin "${GITHUB_REF_NAME:-main}" 2>/dev/null || true
# HEAD is a merge commit: its first parent is the pre-merge tip, so HEAD^1 is the base. This is
# NOT how most updates to main land: 91 of the last 100 `origin/main` commits have ONE parent
# (measured 2026-08-29), so the arm below usually bails to the full matrix. Safe, and it means
# the docs-only skip rarely fires on a push at all.
#
# `fetch_ref ... 2` asks for depth 2 so the first-parent tree exists in a SHALLOW checkout, and
# for no depth at all in a complete one (see `fetch_ref`); if the base still can't be
# resolved/diffed we fall through to the full matrix. A non-merge (direct/multi-commit) push is
# ambiguous here -> full matrix.
fetch_ref "${GITHUB_REF_NAME:-main}" 2 || true
nfields="$(git rev-list --parents -n1 HEAD 2>/dev/null | wc -w | tr -d ' ')" # 1 (self) + parents
if [ "${nfields:-0}" -ge 3 ]; then
base_rev="$(git rev-parse --verify -q 'HEAD^1' || true)"
+9 -1
View File
@@ -33,9 +33,17 @@
# replay is the first act of the exit path, and the original exit code is re-raised explicitly.
#
# USAGE (write side) — the first two lines of every hook, before anything reads stdin:
# . "${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)}/scripts/hook-fire-log.sh"
# . "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)/scripts/hook-fire-log.sh"
# etv_hook_fire_begin <hook-name> [label]
#
# SELF-LOCATED, and the `${CLAUDE_PROJECT_DIR:-...}` form this recipe used to show is BANNED —
# `process.hook-resolves-inputs-from-repo-root` (ersatztv#858). This line is SOURCED, so whatever it
# names runs as code inside the hook before the hook decides anything; a file of this name in an
# env-var-designated tree can print a decision and exit 0. Measured on the merge-consent hook: it
# granted the merge outright. The recipe is corrected here because the header is what a new hook
# copies, so leaving it would keep minting the defect after ersatztv#891 sweeps the existing hooks.
# Twelve hooks still carry the old form; that sweep is #891, not this line.
#
# USAGE (read side):
# scripts/hook-fire-log.sh report # this session (or every session, see below)
# scripts/hook-fire-log.sh report --all # every session in the log dir
+108 -13
View File
@@ -1,7 +1,8 @@
# THE governing-branch-protection-rule classifier, as ONE program with two callers (ersatztv#787).
#
# Input : the `GET /repos/{owner}/{repo}/branch_protections` array. Arg `$b`: the branch to resolve.
# Output: {verdict:"exact", rule:<the rule>} | {verdict:"undecidable"} | {verdict:"none"}
# Output: {verdict:"exact", rule:<the rule>} | {verdict:"undecidable"} | {verdict:"unreadable"}
# | {verdict:"none"}
#
# It lived inline in `.claude/hooks/pretooluse-merge-consent.sh` until #787 needed the same question
# answered for `main` from a second caller. It was EXTRACTED rather than copied: this is a security
@@ -21,16 +22,110 @@
def superset: . as $n | (offs) as $o
| ($n[0:$o[0]] | esc) + ".*" + ($n[($o[-1]+1):] | esc);
def nonascii: explode | any(. > 127);
# A USABLE name is a NON-EMPTY STRING and nothing else, and the two fields are tried in order.
# `.branch_name // .rule_name // ""` got both halves of that wrong (ersatztv#859).
#
# ABSENT/NULL/FALSE became `""` — a VALID name that simply matches nothing — so a rule the program
# could not read at all was counted as a rule that does not govern the base. That fed `none`,
# whose whole authority is the claim "the full rule list was read and none matches", and on the
# merge hook's caller `none` DENIES. A deny is the one arm that must not rest on a rule that was
# never understood.
#
# A NUMBER did NOT take that path, and saying so would be the same overclaim this record keeps
# catching: `//` fires on null and false only, so `{"branch_name":42}` yielded `42`, which then
# made `match` throw. That reached a safe `ask` through the callers' catch-all — a correct
# outcome resting on a crash rather than on a decision, which is why it is classified now.
#
# EMPTY STRING shadowed the fallback, because jq's `//` fires on null and false but NOT on `""`.
# `{"branch_name":"","rule_name":"main"}` therefore resolved to `""` and answered `none` — a
# DENY on the stated grounds that nothing can govern the base, with the governing rule sitting
# right there in the field the program declined to read. So an empty string falls THROUGH to
# the next field rather than winning it.
#
# THIS IS THE SHAPE GITEA ACTUALLY SENDS FOR A GLOB RULE, and it made the undecidable-first
# ordering above a no-op for every one of them. Measured 2026-08-30 against Gitea 1.27.1 on a
# scratch repo, creating one rule of each kind and reading the list back:
#
# {"branch_name":"", "rule_name":"release/*"} <- GLOB rule
# {"branch_name":"main", "rule_name":"main"} <- plain rule
#
# A first probe against this repo saw only its single plain rule and concluded both fields are
# always populated. That was an arbitrary sample generalised into a claim; the positive case had
# to be CONSTRUCTED. Both defects below were then measured on the predecessor:
#
# FALSE-OPEN. Glob `m*` (not requiring `review-verdict/h10`) plus plain `main` (requiring it)
# resolved to `exact` on `main`, so the gate read the plain rule's contexts and AUTO-GRANTED a
# scheduled merge — while Gitea, which orders by Priority then plain-name-ness, may be applying
# `m*`. That is precisely the hole the undecidable-first ordering is written to close, defeated
# because the glob's name was invisible: `""` carries no metacharacter, so the glob test never
# saw it.
# FALSE-DENY. Glob `m*` alone resolved to `none`, denying with "the full rule list was read and
# none matches" about a rule that provably governs the base.
#
# So the fall-through is not defence in depth against a hypothetical future payload; it repairs a
# live misclassification of the payload this server sends today.
# FIELD ORDER IS PRESERVED FROM THE `//` CHAIN — `branch_name` first — and it is unobservable on
# the payloads Gitea 1.27.1 actually sends, which is why it stays rather than being "modernised"
# to prefer the canonical `rule_name`. Measured 2026-08-30, both rule kinds on a scratch repo: a
# PLAIN rule sets both fields to the same value, a GLOB rule sets `branch_name` to `""`. There is
# no payload where both are non-empty AND different, so "first non-empty" selects the canonical
# name under either ordering.
#
# This is a MEASUREMENT, not an appeal to the old code. That chain's order is not evidence the
# order is right — precedent for a style is not evidence the behaviour is correct, and this chain
# was wrong about everything else on this line. If a server ever sends two different non-empty
# names, decide then, with the payload in hand.
# THREE outcomes per field, not two, and collapsing the last two is a regression cold review
# caught in the first draft of this fix. A field either supplies a NAME, or SKIPs (it is simply
# not carrying one: absent, null, or the empty string Gitea sends for a glob rule), or POISONs
# (it is PRESENT holding a type a name cannot have).
#
# The first draft fell through to `rule_name` on anything that was not a usable string, which
# made a malformed field indistinguishable from an unsupplied one. Measured: with `branch_name`
# a number, boolean or array beside `rule_name:"main"`, the predecessor THREW and the gate asked,
# while that draft answered `exact` and AUTO-GRANTED — and with `rule_name:"develop"` it answered
# `none`, denying on "the full rule list was read and none matches" about a field it never read.
# Both directions of the very defect this file is fixing, reintroduced one shape over.
#
# A malformed field is evidence the payload is not what this program thinks it is, so it poisons
# the whole list rather than deferring to its sibling.
def namefield($v): if ($v | type) == "string"
then (if ($v | length) > 0 then {name: $v} else {skip: true} end)
elif $v == null then {skip: true}
else {poison: true} end;
# POISON IS CHECKED ON BOTH FIELDS BEFORE EITHER NAME IS TAKEN, so the invariant above holds
# field-agnostically. The first draft short-circuited on `branch_name` and only consulted
# `rule_name` as a fallback, which made the rule order-dependent: `{"branch_name":42,
# "rule_name":"main"}` poisoned, while `{"branch_name":"main","rule_name":42}` answered `exact`
# off the good field and never looked at the malformed one. Gitea 1.27.1 does not send that
# second shape — a plain rule sets both fields equal, a glob rule sets `branch_name` to `""` —
# so this buys no measured behaviour today. It is fixed because a comment claiming an invariant
# the code does not hold is the failure this whole change is about, and "unreachable" is a
# property of one server version, not of the program.
def rulename: namefield(.branch_name) as $bn | namefield(.rule_name) as $rn
| if ($bn.poison or $rn.poison) then null
elif $bn.name then $bn.name
elif $rn.name then $rn.name
else null end;
. as $rules | $b as $base |
($rules | map(select((.branch_name // .rule_name // "") as $n
| (($n|offs|length) == 0)
and (($n|ascii_downcase) == ($base|ascii_downcase))))) as $exacts |
(($base|nonascii) or ($rules | any((.branch_name // .rule_name // "") as $n
| ($n|offs|length) == 0 and ($n|nonascii)))) as $unfoldable |
if ($rules | any((.branch_name // .rule_name // "") as $n
| (($n|offs|length) > 0)
and ($base | test("^" + ($n|superset) + "$")))) then {verdict:"undecidable"}
elif $unfoldable then {verdict:"undecidable"}
elif ($exacts | length) > 1 then {verdict:"undecidable"}
elif ($exacts | length) == 1 then {verdict:"exact", rule:($exacts | first)}
else {verdict:"none"} end
# THE SHORT-CIRCUIT IS STRUCTURAL, not merely first in the `if` chain, and that is load-bearing
# rather than stylistic. jq binds `as` eagerly: written as one flat chain, `$exacts` and
# `$unfoldable` are computed BEFORE any arm is tested, so an unreadable name reached `offs` and
# `nonascii` anyway and the program died with "null cannot be matched" — the crash this arm was
# added to replace, still happening, with the arm sitting unreached above it. Measured while
# writing #859. Nesting the readable-name arms inside the `else` is what actually prevents it.
if ($rules | any(rulename == null)) then {verdict:"unreadable"}
else
($rules | map(select(rulename as $n
| (($n|offs|length) == 0)
and (($n|ascii_downcase) == ($base|ascii_downcase))))) as $exacts |
(($base|nonascii) or ($rules | any(rulename as $n
| ($n|offs|length) == 0 and ($n|nonascii)))) as $unfoldable |
if ($rules | any(rulename as $n
| (($n|offs|length) > 0)
and ($base | test("^" + ($n|superset) + "$")))) then {verdict:"undecidable"}
elif $unfoldable then {verdict:"undecidable"}
elif ($exacts | length) > 1 then {verdict:"undecidable"}
elif ($exacts | length) == 1 then {verdict:"exact", rule:($exacts | first)}
else {verdict:"none"} end
end
+255
View File
@@ -0,0 +1,255 @@
# shellcheck shell=bash
# h10-reviewers.sh — WHOSE verdicts the H10 gate will inherit, DERIVED from the gate itself
# (ersatztv#845).
#
# SOURCED, NEVER EXECUTED. It defines pure functions over one file and takes no action of its own,
# so it is deliberately not executable and carries no shebang.
#
# WHY THIS FILE EXISTS. `.gitea/workflows/review-verdict.yml` inherits an existing
# `review-verdict/h10=success` only when the status's `.creator.login` is a member of its
# `H10_REVIEWERS` allow-list (ersatztv#742). `scripts/post-review-verdict.sh` is the tool that WRITES
# those verdicts, with whatever account owns `ETV_GITEA_TOKEN` / `ETV_GITEA_BASICAUTH`. The two
# values are coupled and nothing asserted the coupling: the day the writer credential stops being an
# allow-listed account — a service token, a second machine, a renamed account, a bot minted for
# automation — every verdict is still WRITTEN, the tool still reports success, and the gate silently
# re-derives it on the next PR event. A repo-wide merge stall whose only diagnostic was a
# `::warning::` in a workflow run nobody reads.
#
# THERE IS NO SECOND COPY OF THE LIST HERE, on purpose. The workflow literal is the ONE declaration
# and this file DERIVES from it, the way `review-verdict-vocabulary.sh` made one word list serve both
# the read and the write side (ersatztv#788). A hand-copied allow-list held together by a parity test
# is the shape that record spent six rounds failing to converge on.
#
# WHY A PARSE RATHER THAN A SHARED DECLARATION BOTH SIDES SOURCE. That would be the better shape and
# it is not available. The gate runs under `pull_request_target` against a checkout of the PR BASE
# sha, so a PR whose base predates the introduction of a new `scripts/lib/` file would not have it —
# the same condition the `jq preflight` step already handles by skipping. Under `set -euo pipefail` a
# missing source is a dead job, and a dead job posts no `review-verdict/h10` at all, which blocks
# EVERY merge to `main` including the PR that would repair it (`block_admin_merge_override: true`,
# ersatztv#743). Writing a fallback list for that case reintroduces the second copy. So the
# derivation runs on the side that can fail safely — this one — and the gate body is untouched.
#
# WHAT THE PARSE IS ALLOWED TO BE. Exactly one anchored assignment line, or a refusal. That is not a
# shell parser and does not try to be: `testing.guard-derives-population-from-source` records why a
# regex over shell source does not converge when the target is an open-ended construct. The target
# here is closed — `test_pr_changed_files.py::test_the_H10_REVIEWERS_list_is_glob_free_and_non_empty`
# reads the same literal, over the SAME POPULATION (the whole file), with the same anchors, and
# already fails the suite if the workflow stops declaring it as exactly one double-quoted
# assignment. So a shape change reddens CI on the PR that makes it rather than going stale here.
#
# That guard used to scope itself to the classify step's `run:` body, which is NOT the same tree this
# file walks: a second anchored assignment in any other `run:` block left it green while making this
# refuse every verdict. Measured before the fix: the guard green, and about half the writer suite red.
# BE PRECISE ABOUT WHAT WAS MISSING, because the obvious phrasing is wrong twice over. Those failures
# DO carry this library's refusal, which names the count and the variable. And alignment does not
# REPLACE them — it ADDS a named guard failure alongside them. What was missing is a failure that
# POINTS AT THE WORKFLOW: the reds surface under unrelated test names
# (`test_positive_verdict_posts_success_on_the_resolved_head`, …), so the cause had to be inferred
# from a stack of them.
#
# NO FIGURE IS GIVEN FOR HOW MANY NAME IT, deliberately, and the trap is worth stating so nobody
# adds one back. A `grep -c` over pytest output counts LINES, not failures, and `--tb=line` prints
# each matching failure twice — so that count overstates. Truncation moves it the other way: raising
# the traceback detail makes many more of the failures show the refusal text. Any figure here is
# therefore a property of the pytest flags used to produce it, not of the code, and reproducing one
# requires parsing per-failure sections rather than grepping lines. "About half the suite reddens"
# survives because it is coarse enough to be robust to all of that.
#
# BE EXACT ABOUT THE CONSEQUENCE, because the obvious phrasing overstates it. Such an edit landing on
# `main` WOULD be a repo-wide merge outage — no verdict could be posted, and `review-verdict/h10` is
# a required check on a PR-only `main`. But it is not a silent route in: the writer suite is in the
# `script-tests` job, which goes red on that edit. That job is ADVISORY (measured 2026-08-30, the
# required contexts are `Build & test`, `EF migration integrity` and `review-verdict/h10`), so it
# does not block server-side; what it does is make the PR visibly red. The reachable path to the
# outage is therefore the snapshot boundary above — a verdict posted from a checkout that predates
# the edit — not the guard being green. Aligning the populations buys a legible diagnostic on the PR
# that makes the edit, which is worth having and is not the same as being the only thing standing
# between the repo and an outage.
#
# SNAPSHOT BOUNDARY, and its DIRECTION, because the direction is the part that matters. This reads
# the workflow in the LOCAL checkout, while the gate that actually runs is the one at `origin/main`
# (base-resolved). On a branch that edits `H10_REVIEWERS` the two differ until it merges, and the
# likely edit is ADDING a reviewer — for which this check ACCEPTS an account the live gate will not
# yet inherit from. That is a false accept: the #845 stall, reached through the very check meant to
# catch it, on the one branch where someone is thinking about the allow-list.
#
# It is accepted rather than closed, with the reasons stated. A live remote read of the base
# workflow would close it and puts a network dependency on the merge-consent write path, where a
# read failure has to resolve either to a refusal (a new way to block every verdict) or to a pass
# (the hole, reopened). The window is one PR long, the operator making the edit is the one person
# who knows the list is in flux, and the residual is a STALL, never a merge of unreviewed code. The
# direction is what is written down; a future reader deciding whether to close it should start here.
#
# NO ENVIRONMENT VARIABLE SELECTS THE SOURCE FILE. The path is derived from this file's own
# location, and a caller may pass one explicitly; there is deliberately no `ETV_*_WORKFLOW`
# override, because an env var that chooses which tree answers a security question is an unsound
# input (ersatztv#858 is the same shape one file over).
# --- The default source: the shipped gate definition. --------------------------------------------
# Resolved relative to THIS FILE, never to the caller's cwd — `post-review-verdict.sh` is run from
# whatever directory an operator happens to be in.
ETV_H10_REVIEWERS_WORKFLOW=''
if _etv_h10_root=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd); then
ETV_H10_REVIEWERS_WORKFLOW="$_etv_h10_root/.gitea/workflows/review-verdict.yml"
fi
unset _etv_h10_root
# --- The validation sentinel. --------------------------------------------------------------------
# Reset to 0 HERE, at load time, so that "derived successfully" is a DATA dependency the query
# function requires rather than a control-flow step a caller is trusted to have taken.
#
# ATTRIBUTED CAREFULLY, because the obvious second reason is not this line's. An earlier version
# claimed the reset defeats an inherited `ETV_H10_REVIEWERS_OK=1` from the environment, and cited a
# measurement showing a hostile environment does not get a verdict past the writer. The measurement
# is real; the conclusion does not follow. Removing this reset produces a byte-identical refusal,
# because `etv_h10_reviewers_load` runs on that path and overwrites both variables anyway. What the
# reset actually covers is a caller that never reaches `load` at all — the data-dependency reason
# above, and what `etv_h10_reviewers_contains` returning 2 rests on. So the sentinel earns its place
# on that ground alone.
#
# THE SKIP-PAST-THE-GATE ROUTE IS REAL BUT NOT REACHABLE HERE, which is why the sentinel is kept
# without repeating that rationale. `review-verdict-vocabulary.sh` documents the mechanism; the
# scoping below is stated here and measured here, on bash 3.2.57 and 5.3.15. Under `set -u` and
# WITHOUT `set -e`, a `${#arr[@]}` on an unset name or a scalar aborts the FUNCTION only, so an
# `if ! validate` gate takes neither branch and execution continues past it. That is its consumer's situation (`check-review-verdict.sh` runs `set -uo pipefail`).
# It is NOT this file's: `post-review-verdict.sh` runs `set -euo pipefail`, where the shell exits on
# any of those shapes. So the sentinel here earns its place on the data-dependency ground alone.
ETV_H10_REVIEWERS_OK=0
ETV_H10_REVIEWERS=''
# --- The split idiom, stated once. ---------------------------------------------------------------
# Both functions below turn the space-separated list into the positional parameters with PATHNAME
# EXPANSION OFF, then restore `-f` to whatever the caller had. It is written out at each site rather
# than factored into a helper because a helper cannot set its caller's positional parameters, and
# returning the words through a string would reintroduce a split — of a value that may contain a
# newline — at the other end.
#
# Getting this wrong is the documented fail-open, not a style point: `for x in $list` and
# `$(printf %s "$list")` both glob, so a `*` entry expands to the FILENAMES around it, and any of
# those matching the plain-login class below would validate cleanly and enter the allow-list as a
# real reviewer. That is the defect `review-verdict-vocabulary.sh` records against its own word list,
# reached one file over through the allow-list.
#
# WHICH SITE CARRIES THAT RISK: the one in `etv_h10_reviewers_load`, which splits a value NOTHING has
# validated yet. By the time `etv_h10_reviewers_contains` runs, the plain-login class has already
# excluded every metacharacter, so its `set -f` is defence in depth against a future edit that
# reorders or weakens the validation — not the primary guard. Said plainly because "this is the
# fail-open" attached to both sites would misdirect the next reader about which line to keep.
#
# `IFS` IS PINNED TOO. Globbing is only half of what an unquoted expansion depends on; the other
# half is the ambient `IFS`, and a caller that had set `IFS=-` turned `renovate-bot` into two
# entries, making `renovate` read as a member. Not reachable from `post-review-verdict.sh` today
# (bash resets `IFS` at startup, so the environment route is closed, and the script never assigns
# it), but this file invites reuse and the cost is one word per function.
# --- The derivation. -----------------------------------------------------------------------------
# Sets `ETV_H10_REVIEWERS` and the sentinel. Returns 0 when the allow-list is usable, 1 otherwise
# (message on stderr). It never exits: the consumer decides its own exit semantics.
#
# $1 — the workflow file to read; defaults to the shipped one resolved above.
etv_h10_reviewers_load() {
local src="${1:-${ETV_H10_REVIEWERS_WORKFLOW:-}}"
local count value entry n=0 glob_was_off=0
# Pin the split: the caller's IFS is not this function's business to inherit.
local IFS=$' \t\n'
ETV_H10_REVIEWERS_OK=0
ETV_H10_REVIEWERS=''
if [ -z "$src" ]; then
printf 'h10 reviewers: could not resolve the gate definition from the location of this library, and no path was passed\n' >&2
return 1
fi
if [ ! -r "$src" ]; then
printf 'h10 reviewers: %s is missing or unreadable, so who the gate accepts cannot be derived\n' "$src" >&2
return 1
fi
# COUNT the anchored assignments, never `grep -q`. Two separate reasons, both recorded because each
# has cost this repo a round: `grep -q` closes the pipe early and inverts under `pipefail` on large
# input (`ci.grep-q-pipefail-inversion`), and — the reason that matters here — a boolean cannot
# tell ONE assignment from TWO. The shell runs the LAST assignment executed, so a second or
# conditional one would leave this deriving a value the gate does not use. `grep -c` reads a FILE
# argument, so there is no pipe to invert in the first place.
count=$(grep -cE '^[[:space:]]*H10_REVIEWERS="[^"]*"[[:space:]]*$' "$src") || count=0
if [ "$count" != 1 ]; then
printf 'h10 reviewers: %s carries %s anchored H10_REVIEWERS assignments, not exactly 1 — refusing to guess which one the gate runs with\n' "$src" "$count" >&2
return 1
fi
value=$(sed -n 's/^[[:space:]]*H10_REVIEWERS="\([^"]*\)"[[:space:]]*$/\1/p' "$src") || value=''
# Validate every entry against the same character class the workflow's own lint enforces. It is not
# cosmetic on either side: the gate membership loop word-splits the list UNQUOTED, so `*`, `?` or
# `[` there would be matched against the runner cwd and could silently change who the gate accepts.
case "$-" in *f*) glob_was_off=1 ;; esac
set -f
# shellcheck disable=SC2086 # deliberate word-split of the space-separated list, globbing off
set -- $value
[ "$glob_was_off" = 1 ] || set +f
for entry in "$@"; do
case "$entry" in
*[!A-Za-z0-9._-]*)
printf 'h10 reviewers: %s is not a plain login; * ? and [ are glob metacharacters in the membership loop of the gate\n' "$entry" >&2
return 1
;;
esac
n=$((n + 1))
done
# Emptiness is checked on the SPLIT COUNT, not on the raw string. A value of " " is non-empty as
# a string and splits to nothing, and an emptied list is not a harmless default: the gate loop
# `for rv in ""` iterates zero times, so every verdict from every account would stop being
# inherited and `pending` would be re-posted over real human verdicts forever.
if [ "$n" -eq 0 ]; then
printf 'h10 reviewers: %s declares an EMPTY H10_REVIEWERS — no verdict from any account could be inherited\n' "$src" >&2
return 1
fi
ETV_H10_REVIEWERS="$value"
# The ONLY assignment to 1 in this file, and it is the last statement of the successful path. Any
# earlier `return 1`, and any abort part-way through, leaves the sentinel at 0.
ETV_H10_REVIEWERS_OK=1
return 0
}
# --- Derived view: is this login one whose verdict the gate would inherit? -----------------------
# THREE outcomes, deliberately, because two would conflate them: 0 = a member, 1 = NOT a member,
# 2 = CANNOT ANSWER (the derivation never ran, or failed). A caller that treats "cannot answer" as
# "not a member" turns a broken checkout into a refusal, which is survivable; one that treats it as
# "member" turns it into a silent pass, which is the failure this file exists to prevent. Both are
# wrong to guess at, so the third code makes the caller say which it meant.
etv_h10_reviewers_contains() {
local login="${1:-}" entry glob_was_off=0
# Pin the split: the caller's IFS is not this function's business to inherit.
local IFS=$' \t\n'
if [ "${ETV_H10_REVIEWERS_OK:-0}" != 1 ]; then
printf 'h10 reviewers: the allow-list was never derived (call etv_h10_reviewers_load first)\n' >&2
return 2
fi
case "$-" in *f*) glob_was_off=1 ;; esac
set -f
# shellcheck disable=SC2086 # deliberate word-split of the space-separated list, globbing off
set -- $ETV_H10_REVIEWERS
[ "$glob_was_off" = 1 ] || set +f
for entry in "$@"; do
# POSIX `[ = ]`, exactly as the gate compares. Never `[[ $entry == $login ]]` — an unquoted
# right-hand side under `[[ ]]` is a PATTERN, not a literal — and never `grep -xF`, because an
# API-supplied creator containing a newline would read as several patterns.
if [ "$entry" = "$login" ]; then
return 0
fi
done
return 1
}
# --- Reached-the-end marker. ---------------------------------------------------------------------
# A top-level `exit` in a SOURCED file terminates the sourcing script where it stands, so no check
# placed after the `source` can ever run. The consumer therefore sources this file in a SUBSHELL
# first and requires the marker below on stdout; a file that exits early never prints it. Defining a
# function is not enough (a truncated file can define every function and still stop short), so this
# is the LAST line and it must stay last.
etv_h10_reviewers_loaded() { printf 'etv-h10-reviewers-loaded\n'; }
+227 -1
View File
@@ -106,6 +106,43 @@ case "$verdict_class" in
*) die "the shared vocabulary classified '$verdict' as '$verdict_class', which is neither positive nor negative — refusing to guess a commit-status state" ;;
esac
# --- WHOSE verdict the gate will inherit (ersatztv#845). ----------------------------------------
# Loaded HERE: after the arguments validate, so a bare `post-review-verdict.sh` still prints its
# usage, and before the first network call, so a checkout that cannot answer the question refuses
# without having touched the server.
#
# AND ONLY FOR A `success`, for the same reason membership itself is `success`-only (see the
# read-back below). A `failure` is inherited from any attributable account, so who is on the
# allow-list is irrelevant to one — and loading unconditionally meant a workflow whose
# `H10_REVIEWERS` declaration is broken refused REJECTIONS too. That is precisely the outcome the
# success-only argument exists to avoid ("no supported way to record a rejection"), reached one
# condition earlier, on the branch most likely to have broken that declaration: the one editing it.
#
# The list is NOT declared here. `.gitea/workflows/review-verdict.yml` owns it and this derives from
# that file; see `scripts/lib/h10-reviewers.sh` for why the derivation is a parse rather than a
# declaration both sides source. Every branch OF THIS LOAD fails CLOSED, exactly like the vocabulary
# above — scoped that way deliberately, since the load itself is now conditional: a writer that
# cannot find out whether its own POSITIVE verdict is inheritable must not post one and report
# success, because that combination is the ersatztv#845 stall itself. A negative verdict never
# reaches here and needs nothing from it.
if [ "$state" = "success" ]; then
H10_REVIEWERS_LIB="$_here/lib/h10-reviewers.sh"
[ -r "$H10_REVIEWERS_LIB" ] \
|| die "the reviewer allow-list derivation is missing or unreadable at $H10_REVIEWERS_LIB — no status was written"
# Probe-source in a subshell and require the end-of-file marker before trusting the file, for the
# same reason the vocabulary does: a top-level `exit` in a sourced library terminates THIS script at
# the `source` line, having posted neither a status nor a comment, with exit 0.
# shellcheck source=lib/h10-reviewers.sh
h10_probe=$( . "$H10_REVIEWERS_LIB" >/dev/null 2>&1 && etv_h10_reviewers_loaded 2>/dev/null ) || h10_probe=''
[ "$h10_probe" = 'etv-h10-reviewers-loaded' ] \
|| die "the reviewer allow-list derivation at $H10_REVIEWERS_LIB did not load to completion (syntax error, truncated, or it exits at top level) — no status was written"
# shellcheck source=lib/h10-reviewers.sh
. "$H10_REVIEWERS_LIB" \
|| die "the reviewer allow-list derivation at $H10_REVIEWERS_LIB could not be sourced — no status was written"
etv_h10_reviewers_load \
|| die "could not derive the gate's H10_REVIEWERS allow-list (see above) — no status was written. Until it can be read, this tool cannot tell whether the POSITIVE verdict it posts would be honoured or silently re-derived. A negative verdict does not need the allow-list and is unaffected."
fi
base_url="${ETV_GITEA_URL:-http://192.168.1.95:3000}/api/v1"
repo_slug="${ETV_GITEA_REPO:-timothy/ersatztv}"
owner="${repo_slug%%/*}"
@@ -211,14 +248,203 @@ fi
# `scripts/check-review-verdict.sh`, whose grammar had three false-opens in its history; nothing
# parses the description today, so this adds a field without reopening that surface. The hook reads
# it back and compares (ersatztv#632).
# Held in a variable rather than inlined, because the read-back below identifies OUR write by it.
status_desc="Review-verdict: $verdict @ $short (base: $base_ref)"
status_payload=$(jq -n \
--arg s "$state" --arg c "$STATUS_CONTEXT" --arg u "$pr_url" \
--arg d "Review-verdict: $verdict @ $short (base: $base_ref)" \
--arg d "$status_desc" \
'{state:$s, context:$c, description:$d, target_url:$u}')
api_post "repos/$owner/$repo/statuses/$sha" "$status_payload" >/dev/null \
|| die "failed to post the '$STATUS_CONTEXT' commit status on $short"
printf 'posted status: %s = %s on %s\n' "$STATUS_CONTEXT" "$state" "$short"
# --- Is the verdict we just wrote one the GATE would honour? (ersatztv#845) ---------------------
# The gate inherits an existing `review-verdict/h10=success` only from a status whose
# `.creator.login` is on its `H10_REVIEWERS` allow-list (ersatztv#742). This script posts with
# whatever account owns the credential in the environment and used to never ask whose it was, so the
# two coupled values could drift apart with no diagnostic anywhere: the status really is written,
# this tool really did report success, and then the next `pull_request_target` event re-derives it
# and posts `pending` over it. Again on the next event. The PR deadlocks and the only trace is a
# `::warning::` inside a workflow run nobody is reading.
#
# MEASURED AFTER THE WRITE, NOT BEFORE IT. A pre-flight `GET /user` would test what the credential
# CLAIMS to be; this tests what Gitea actually recorded as the author of this status, which is the
# value the gate reads. It also needs no scope beyond the repo access the POST already required —
# a `/user` probe would add a read:user dependency that a narrowly-scoped but perfectly valid
# reviewer token might not carry, turning a hardening step into a new way to block every verdict.
#
# THE RESIDUAL, stated: on refusal a non-inheritable status is left standing on the head, because
# the check runs after the POST. That is the half-state `release.verdict-writes-status-before-comment`
# already designates as the safe one — a status with no comment leaves the merge hook at condition
# (c) with nothing to classify, which is an `ask`, not a grant — and the gate re-derives the status
# itself on the next PR event. It is not repaired here on purpose: a second corrective write is the
# sticky-sentinel mechanism ersatztv#849 is separately designing, and inventing a parallel one on
# this path would be two mechanisms for one invariant.
# WHY OUR ROW IS ALWAYS IN THIS RESPONSE — measured, and the first version of this comment had it
# wrong in a way worth recording. This endpoint PAGES, and its `total_count` reports the PAGE rather
# than the total (`?limit=2` on a 15-context head returns 2 rows and `total_count: 2`), so a
# truncated body is not detectable from any field in it. That looked like a risk of the verdict row
# falling off a page. It is not: Gitea selects the MAX id per context, orders those DESCENDING, and
# paginates that. Measured on the same head — ids
# [17,19,21,23,25,27,30,31,32,34,36,38,39,41,43], and `?limit=2` returns 41 and 43, the two highest.
# The status this script has just POSTed is by construction the newest on the head, so it sorts
# first and page 1 contains it whatever the page size.
#
# `?limit=100` therefore has NO observable effect under that ordering, and is kept anyway as the
# cheapest insurance against the two ways the reasoning could stop holding: a burst of concurrent
# status writes between our POST and this read, and the ordering property changing. Said to be
# unwitnessed ON PURPOSE — no test can show an effect a correct server does not produce — rather
# than left looking like a guard someone forgot to test. The instance clamps `limit` to
# `MAX_RESPONSE_ITEMS`, measured at 50, so 100 asks for whatever the server will give.
#
# THE GATE ASKS A HARDER QUESTION WITH THE SAME PARAMETER, which is worth stating so this does not
# read as an inconsistency. `review-verdict.yml` sends `?limit=100` on its reads too — what it
# rejected was using that limit as a COMPLETENESS ARGUMENT ("refuse when the page came back FULL at
# 100"), which was dead code because the instance caps at 50; it settles completeness with a page-2
# probe instead. The two are asking different questions: the gate must prove a verdict is ABSENT
# before acting on that absence, which no single page can establish, while this script only confirms
# that a row it just wrote is PRESENT and refuses when it is not. Presence needs no completeness
# argument, which is why no page-2 probe appears here.
status_readback=$(api_get "repos/$owner/$repo/commits/$sha/status?limit=100") \
|| die "the '$STATUS_CONTEXT' status was posted on $short, but it could not be read back to confirm the gate will honour it — no comment was written. Re-run once Gitea is reachable."
# `.statuses` serialises as `null` rather than `[]` on a head with no statuses (ersatztv#751), so
# the array TYPE is tested rather than assumed; a body that merely lost its array yields no entry
# and is refused below, exactly like one that carries no verdict.
# Three outcomes are kept apart below, each with its own message, because they call for different
# actions and an earlier draft collapsed them into one: a body whose `.statuses` is not an array at
# all, a well-formed body that does not carry our context, and a body carrying MORE THAN ONE row for
# it. Collapsing them also made the array-TYPE test unwitnessable — the absent-entry check caught
# every case on its own, so disarming the type test left the suite green.
readback_matches=$(printf '%s' "$status_readback" | jq -c --arg c "$STATUS_CONTEXT" \
'if (.statuses | type) == "array" then (.statuses | map(select(.context == $c))) else null end') \
|| die "the '$STATUS_CONTEXT' status was posted on $short, but the read-back body could not be parsed — no comment was written."
if [ -z "$readback_matches" ] || [ "$readback_matches" = "null" ]; then
die "the '$STATUS_CONTEXT' status was posted on $short, but the read-back body carries no \`.statuses\` ARRAY (it is missing, null, or some other type), so nothing about the verdict can be confirmed — no comment was written. This is a response-SHAPE problem; check what the combined-status endpoint returns on this Gitea version."
fi
# This `|| die` and the `.[0]` one below are DECLARED UNREACHABLE, not counted as protection:
# `jq -c` has already produced `$readback_matches` as an array, so `length` and `.[0]` cannot fail.
# They are kept for the same reason the rest of this block carries messages — a future edit that
# widens what reaches here should fail with a sentence, not a jq trace — and are stated as
# unreachable so nobody mistakes them for tested guards.
readback_count=$(printf '%s' "$readback_matches" | jq -r 'length') \
|| die "the '$STATUS_CONTEXT' status was posted on $short, but the matching rows could not be counted — no comment was written."
# A body carrying SEVERAL JSON documents makes jq emit one count per document, and the arithmetic
# comparisons below would then interpolate "0 0" into a message about "0 0 separate rows". Not
# producible by Gitea and it already failed closed, but a mangled diagnostic is the defect class this
# change exists to remove, so the shape is rejected explicitly.
case "$readback_count" in
'' | *[!0-9]*)
die "the '$STATUS_CONTEXT' status was posted on $short, but the read-back did not yield a single countable list of rows (got '$readback_count') — no comment was written. This is a response-SHAPE problem; the body may carry more than one JSON document."
;;
esac
if [ "$readback_count" = 0 ]; then
# Does NOT assert a single cause. Reaching here means the row this run just POSTed is absent from
# a response that, by the ordering measured above, should have it FIRST — so the honest statement
# is that something removed or replaced it, not a guess at which. Paging is deliberately not named:
# an earlier version named it, and it is not a live candidate for THIS row.
#
# `.statuses` is known to be an array here (the branch above refused otherwise), so `length` cannot
# fail and needs no fallback. The `|| readback_rows=...` guard that used to be here was dead for a
# reason worth remembering: jq exits 0 with EMPTY OUTPUT on empty input, so an exit-status fallback
# would not have fired even in the case it was written for.
readback_rows=$(printf '%s' "$status_readback" | jq -r '.statuses | length')
die "the '$STATUS_CONTEXT' status was posted on $short but is not among the $readback_rows status rows read back, so it is UNPROVEN that the verdict landed — no comment was written. The row this run wrote is the newest on the head and should sort first, so its absence means something removed or replaced it. Check the PR's checks before re-running."
fi
if [ "$readback_count" != 1 ]; then
# Not reachable against Gitea as measured (the combined endpoint returns the latest row PER
# context), so this is a guard against that property silently ceasing to hold rather than against
# an observed shape. It is here because the alternative is `[0]`, which would pick a row and
# judge its author while a DIFFERENT row for the same context is the one standing.
die "the '$STATUS_CONTEXT' status on $short reads back as $readback_count separate rows, so which one is in force is ambiguous — no comment was written. The combined-status endpoint is expected to return one row per context; if that has changed, this check needs rewriting before verdicts can be trusted."
fi
readback_entry=$(printf '%s' "$readback_matches" | jq -c '.[0]') \
|| die "the '$STATUS_CONTEXT' status was posted on $short, but its row could not be read — no comment was written."
# `.status`, NOT `.state`, and with NO FALLBACK to `.state`. A row inside `.statuses[]` serialises
# its state under the key `status`; `state` exists only as the AGGREGATE at the top level of the
# combined body. Measured on Gitea 1.27.1, 2026-08-29, against a real `review-verdict/h10` row.
# Reading `.state` per row yields the empty string for every well-formed response, which compares
# unequal to the state just posted and would refuse EVERY verdict — a repo-wide deadlock.
#
# A `(.status // .state)` fallback was here and was WRONG, recorded because it looks like harmless
# defensiveness. The gate reads `.status` and nothing else (`review-verdict.yml`,
# `ex_state=$(... jq -r '.status // ""')`). On a response carrying only `.state`, the fallback made
# THIS tool accept and report success while the gate saw no recognisable verdict and re-derived it
# — ersatztv#845 itself, recreated by the code meant to prevent it. The writer must read exactly
# what the gate reads: on a shape neither understands, refusing loudly here is correct, and
# tolerating one the gate cannot is not.
# Each extraction carries its own `|| die`. Without one they still fail closed — a jq error under
# `set -e` aborts the script before the comment — but with only jq's own stderr, which does not say
# that a status was left standing on the head. `.creator` arriving as a STRING rather than an object
# is the shape that reaches this (probed: jq exits 5, "Cannot index string with string").
readback_state=$(printf '%s' "$readback_entry" | jq -r '.status // ""') \
|| die "the '$STATUS_CONTEXT' status was posted on $short but its state could not be read from the response — no comment was written."
readback_desc=$(printf '%s' "$readback_entry" | jq -r '.description // ""') \
|| die "the '$STATUS_CONTEXT' status was posted on $short but its description could not be read from the response — no comment was written."
readback_creator=$(printf '%s' "$readback_entry" | jq -r '.creator.login // ""') \
|| die "the '$STATUS_CONTEXT' status was posted on $short but its creator could not be read from the response — no comment was written. The verdict is UNPROVEN, not necessarily wrong."
# A row carrying no `status` key is a SHAPE problem, not an overwrite. Kept as its own branch because
# a refusal that states a cause which did not happen is its own defect class in this repo (#859):
# "something overwrote it" would send the reader looking for a race that never occurred.
if [ -z "$readback_state" ]; then
die "the '$STATUS_CONTEXT' status on $short read back with no state field, so it is UNPROVEN that the verdict just written is the one standing — no comment was written. This is a response-SHAPE problem, not a race: check what \`.statuses[]\` rows look like on this Gitea version."
fi
# Identify OUR write before judging its author. `/commits/{sha}/status` returns the LATEST row per
# context — measured, not assumed: on 2026-08-29 a head carrying two `review-verdict/h10` rows in the
# `/statuses` list (an Actions `pending`, then a reviewer `success`) returned only the newer one here.
# So a run of the gate that overwrote ours between the POST and this read would otherwise have ITS
# author judged below, and an Actions write carries `creator: null` — which would surface as a
# wrong-credential accusation against a credential that is fine.
# WHICH ARM CARRIES THE SAFETY, stated because the two OVERLAP and a fixture that changes both
# leaves either one disarmable with the suite still green (#685's shape). The DESCRIPTION is the
# identifier: it carries the verdict word, the short sha and the base. Precisely, that identifies a
# write BY THIS SCRIPT WITH THIS VERDICT ON THIS HEAD AND BASE — a second run with the same verdict
# reproduces it byte for byte — which is what the check needs, since any such row is equally ours.
# The STATE is defence in depth — our own writer cannot produce our description with a different
# state, since the verdict word determines it. Both are pinned by their own single-field fixture
# below rather than by one fixture that differs in both.
if [ "$readback_state" != "$state" ] || [ "$readback_desc" != "$status_desc" ]; then
die "the '$STATUS_CONTEXT' status standing on $short is not the one just written (state '$readback_state', description '$readback_desc') — it was overwritten, so this verdict is not in force. No comment was written. If the head is unchanged the gate simply re-derived the status and re-running this is enough; re-review only if the head moved."
fi
if [ -z "$readback_creator" ]; then
# States the fact first and the likely cause second. `creator` absent, null, or carrying an empty
# login all land here; an ACTIONS-token status is the one that actually occurs, but saying it IS
# one would assert a cause this response cannot establish.
die "the '$STATUS_CONTEXT' status on $short reads back with no attributable creator (absent, null, or an empty login). A status POSTed by an ACTIONS token reads exactly this way, and the gate never inherits one as a reviewer verdict (ersatztv#742), so this verdict would be silently re-derived. No comment was written. Post it with a reviewer's own credential."
fi
# THE ALLOW-LIST APPLIES TO `success` ONLY, because that is what the gate does, and mirroring it is
# the entire point of this check. `review-verdict.yml` short-circuits on an existing `success` only
# when the creator is a MEMBER, but on an existing `failure` when it is merely ATTRIBUTABLE — any
# account, because inheriting a rejection can only ever withhold an exemption while re-deriving one
# can turn it green. Enforcing membership on a `failure` here would therefore refuse a verdict the
# gate honours perfectly well, and say so with a diagnostic that is simply false: it would tell an
# off-list reviewer their rejection will be re-derived when it will not. Worse, it would refuse to
# write the BLOCKED comment, leaving a real reviewer no supported way to record a rejection.
#
# Attributability is already established for both states by the checks above — the creator is
# non-null, and the description is the one this run wrote, which is `Review-verdict:`-shaped and
# carries this base. Membership is the only additional thing `success` needs.
if [ "$state" = "success" ]; then
# Explicit capture rather than `if ! etv_h10_reviewers_contains ...`, so the THIRD outcome cannot
# be folded into the second. The function answers 0 member / 1 not-a-member / 2 cannot-tell.
set +e
etv_h10_reviewers_contains "$readback_creator"
h10_membership=$?
set -e
case "$h10_membership" in
0)
printf 'verdict author: %s (on the gate allow-list: %s)\n' "$readback_creator" "$ETV_H10_REVIEWERS"
;;
1)
die "the '$STATUS_CONTEXT' status on $short was posted by '$readback_creator', which is NOT on the gate's H10_REVIEWERS allow-list ('$ETV_H10_REVIEWERS'). A positive verdict is inherited only from an allow-listed account, so the status is written but the gate will not honour it: the next PR event re-derives it and posts over it, and the PR stalls with no visible cause. No comment was written. Either post with an allow-listed reviewer's credential, or add '$readback_creator' to H10_REVIEWERS in .gitea/workflows/review-verdict.yml."
;;
*)
die "could not determine whether '$readback_creator' is on the gate's H10_REVIEWERS allow-list (membership check returned $h10_membership). Refusing to report a positive verdict as posted when it is unknown whether the gate will honour it. No comment was written."
;;
esac
else
printf 'verdict author: %s (a %s verdict is honoured from any attributable account, so the allow-list does not apply)\n' "$readback_creator" "$state"
fi
# --- The comment (human-readable artifact + the hook's condition-(c) input). --------------------
# Written LAST, after the gating status exists (ersatztv#792). The verdict line MUST start the line:
# the hook anchors its parser to line-start precisely so a comment that merely QUOTES the template
+47 -5
View File
@@ -81,6 +81,21 @@ MUTATIONS: tuple[Mutation, ...] = (
why="`main()`'s only stale-detection logic, per the proof test's own docstring, which uses this "
"exact clause and this exact replacement.",
),
Mutation(
guard="scripts/ci-detect-docs-only.sh",
target="scripts/ci-detect-docs-only.sh",
clause='if [ "$is_shallow" = "true" ]; then',
replacement="if true; then",
proof="test_docs_only_detector_clone_depth.py::test_the_push_arm_leaves_a_COMPLETE_clone_complete",
granularity=CLAUSE,
expect="GRAFTED the complete clone shallow",
why="The shallow test is the whole of ersatztv#836's fix: disarmed, `--depth=2` goes back to "
"every checkout including `build`'s complete one, which grafts it and makes the `git describe` "
"in the next step find no reachable tag. This row was UNDECLARED until #836 on the stated "
"grounds that the script 'feeds the skip gate, so its effect is visible only in a workflow "
"run' — untrue of this clause, whose effect is the shallow flag on a real clone and is "
"observable in-process, which is what the proof test asserts.",
),
Mutation(
guard="scripts/ci-step-ran.sh",
target="scripts/ci-step-ran.sh",
@@ -356,6 +371,38 @@ MUTATIONS: tuple[Mutation, ...] = (
"pre-answer IS singly exploitable, which is why it is the declared clause; the tools stage is "
"covered behaviourally in the same file.",
),
Mutation(
guard="scripts/tests/test_complete_annotation_dispositions.py",
target="web/src/api/completeAnnotations.guard.test.ts",
clause="MultiCollectionItemRequest: {\n disposition: 'ANNOTATED',",
replacement="MultiCollectionItemRequest: {\n disposition: 'CREATE',",
proof="test_complete_annotation_dispositions.py::test_the_two_dispositions_AGREE",
granularity=CLAUSE,
expect="this file says COVERED, the SPA guard says CREATE",
why="This guard IS a test, so disarming it makes it absent rather than red — the checker-guard "
"exception applies and the mutation goes into the guarded ARTIFACT, the SPA guard's disposition "
"table. The VALUE is the clause: cold review demonstrated that flipping this row from ANNOTATED "
"to CREATE retires the requirement that MultiCollectionItemRequest be annotated, so deleting the "
"Complete<...> from MultiCollectionsScreen.toItemRequest then leaves every suite green with "
"#807's silent weight reset live again. The row is named in full rather than by the bare "
"disposition line, which occurs three times and would identify none of them.",
),
Mutation(
guard="scripts/post-review-verdict.sh",
target=".gitea/workflows/review-verdict.yml",
clause='H10_REVIEWERS="timothy"',
replacement='H10_REVIEWERS="somebody-who-is-not-a-reviewer"',
proof="test_post_review_verdict.py::test_a_verdict_posted_by_an_ALLOWLISTED_account_is_accepted",
granularity=CLAUSE,
expect="the verdict writer REFUSED an allow-listed account",
why="THE COUPLING, NOT THE COMPARISON. The guard is the writer; the clause lives in the GATE, "
"because what ersatztv#845 is about is the two drifting apart. Mutating the gate's own "
"allow-list while the posting account stays fixed reddens the accept path only if BOTH hold: "
"the writer reads the list LIVE from the workflow (a hand-copied list would not move), and "
"the membership comparison actually gates the outcome (a no-op comparison would not care "
"that it moved). Disarming the comparison inside the script instead would prove the second "
"and say nothing about the first, which is the half that was missing.",
),
)
@@ -438,8 +485,6 @@ UNDECLARED: dict[str, str] = {
"scripts/ci-detect-already-validated.sh": "Blocks nothing directly — it feeds the skip gate. The "
"consequence a mutation would have to be observed through is a job that skips, which is visible "
"only in a workflow run.",
"scripts/ci-detect-docs-only.sh": "Same shape: it feeds the skip gate rather than blocking, so "
"its effect is visible only in a workflow run, not in this suite.",
"scripts/ci-prove-ban-detects.sh": "Driven end to end by test_ci_release_path_scan_job.py, and "
"it runs a mutation of its own at CI time. Grading it here needs a decision about what a second "
"mutation would add; the row nominates no ref today.",
@@ -448,9 +493,6 @@ UNDECLARED: dict[str, str] = {
"harness's.",
"scripts/jq-preflight.sh": "BEHAVIOUR-ONLY. Its named test drives the real script below the "
"version floor; no clause has been disarmed to show the floor comparison is what refuses.",
"scripts/post-review-verdict.sh": "BEHAVIOUR-ONLY, and the most valuable upgrade on this list: "
"it writes the required `review-verdict/h10` status. Its re-read-and-compare has several arms "
"and naming one as THE clause needs the judgement #790 declines to make blind.",
"scripts/pr-changed-files.sh": "BEHAVIOUR-ONLY. Its named test feeds a short page to the real "
"enumeration; the pagination clause has not been disarmed.",
"scripts/tests/test_ci_release_path_scan_job.py": "A GUARD that is a test, so the mutation would "
+179 -2
View File
@@ -96,8 +96,10 @@ def test_a_rule_with_status_checks_DISABLED_is_drift_against_a_nonempty_snapshot
`enable_status_check: false` means Gitea requires nothing on the branch whatever
`status_check_contexts` still holds. Type-checking the flag and ignoring its VALUE certifies
disabled protection as a current mirror — measured: with that clause removed the same payload
returns `match`, and all 71 tests across this file and the merge-consent file stayed green,
because `rule()`'s `enabled=` parameter had no call site that ever passed False.
returns `match`, and the ENTIRE suite across this file and the merge-consent file stayed green,
because `rule()`'s `enabled=` parameter had no call site that ever passed False. (Stated as an
invariant: this sentence carried a test COUNT until ersatztv#859, and a count is falsified by
the next person to add a test — which is exactly what that PR did to it.)
"""
assert run(json.dumps([rule(list(SNAP_CONTEXTS), enabled=False)]))[1] == "drift"
@@ -376,3 +378,178 @@ def test_MUTATION_disarming_the_set_comparison_stops_every_drift_report(tmp_path
"With the set comparison constantly true it must certify a drifted list as current; "
"anything else means the proof measured a broken copy rather than a disarmed guard"
)
# --- ersatztv#859: a rule with no usable NAME is `unreadable`, not `nomatch` ---------------------
@pytest.mark.parametrize(
"bad_rule",
[
{"enable_status_check": True, "status_check_contexts": SNAP_CONTEXTS},
{"branch_name": None, "enable_status_check": True, "status_check_contexts": SNAP_CONTEXTS},
{"branch_name": False, "enable_status_check": True, "status_check_contexts": SNAP_CONTEXTS},
{"branch_name": 7, "enable_status_check": True, "status_check_contexts": SNAP_CONTEXTS},
{"branch_name": "", "rule_name": "", "enable_status_check": True, "status_check_contexts": SNAP_CONTEXTS},
],
ids=["absent", "null", "false", "numeric", "both-empty"],
)
def test_a_rule_with_an_UNUSABLE_NAME_is_unreadable_not_nomatch(bad_rule):
"""`(.branch_name // .rule_name // "")` collapsed all of these to the empty string — a VALID
name that matches nothing — so the shared classifier answered `none` and this script reported
`nomatch`: "no rule governs the branch at all (protection removed)".
That is a finding about a list the program could not read. `nomatch` is this script's only class
that asserts something POSITIVE about the server's configuration, so it is the one that must not
be reachable from an unread rule. On this caller the wrong answer was merely safe — the hook asks
on `nomatch` — while the sibling caller DENIED on it; the classifier is shared, so both are fixed
by the same change and both are pinned by their own suite.
`numeric` is here for a second reason: it used to make jq THROW, so this script reached
`unreadable` through its catch-all rather than by classifying. Same output, no longer resting on
a crash.
"""
code, out, err = run(json.dumps([bad_rule]))
assert (code, out) == (0, "unreadable"), (
f"a rule with no usable name classified as {out!r} (exit {code}, stderr {err.strip()[:160]!r}) "
"— `nomatch` asserts protection was REMOVED, which is a claim about a list that was never read"
)
def test_an_EMPTY_branch_name_falls_through_to_rule_name():
"""jq's `//` fires on null and false but NOT on `""`, so an empty `branch_name` SHADOWED a
perfectly good `rule_name` and the fully-protected branch reported `nomatch`.
REACHABLE, and measured: Gitea 1.27.1 reports a GLOB rule as `{"branch_name":"","rule_name":
"release/*"}` — verified 2026-08-30 by creating one rule of each kind on a scratch repo and
reading the list back. An earlier probe against this repo saw only its single plain rule and
wrongly concluded both fields are always populated; the positive case had to be constructed.
So every glob rule reached this classifier as a name with no metacharacters, and `nomatch` — a
positive claim that protection was REMOVED — was returned for branches a glob provably governs.
"""
payload = json.dumps(
[{"branch_name": "", "rule_name": "main", "enable_status_check": True, "status_check_contexts": SNAP_CONTEXTS}]
)
code, out, err = run(payload)
assert (code, out) == (0, "match"), (
f"an empty `branch_name` still hid the real rule name: {out!r} (exit {code}, stderr {err.strip()[:160]!r})"
)
def test_a_readable_rule_beside_an_unreadable_one_still_poisons_the_batch():
"""The whole LIST is the input, so one unreadable member is enough. A rule the program cannot
read might be the rule Gitea applies — Priority ordering is the server's, not this script's — so
classifying the readable one and ignoring the other would answer confidently about a list that
was only partly understood. Conservative direction: the caller asks instead of comparing."""
payload = json.dumps([rule(SNAP_CONTEXTS), {"branch_name": 7, "enable_status_check": True}])
code, out, _ = run(payload)
assert (code, out) == (0, "unreadable"), (
f"a readable rule beside an unreadable one produced {out!r} — the batch was classified on partial information"
)
CLASSIFIER = REPO_ROOT / "scripts" / "lib" / "branch-rule-classifier.jq"
def classify(payload: str, branch: str = "main") -> tuple[int, str]:
"""The classifier ALONE, with no script wrapping it. `check-required-contexts.sh` maps both a
declared `unreadable` verdict and a jq CRASH onto the same output word, so a test that only reads
that word cannot tell the two apart — cold review demonstrated exactly that by mutating the
classifier's structural guard to `if false`, making jq exit 5, and watching the wrapper still
print `unreadable`. Asserting the raw verdict is what pins the mechanism."""
proc = subprocess.run(
["jq", "--arg", "b", branch, "-c", "-f", str(CLASSIFIER)],
input=payload,
capture_output=True,
text=True,
check=False,
)
return proc.returncode, proc.stdout.strip()
@pytest.mark.parametrize(
"bad_rule",
[
{"enable_status_check": True},
{"branch_name": None},
{"branch_name": False},
{"branch_name": 7},
{"branch_name": "", "rule_name": ""},
],
ids=["absent", "null", "false", "numeric", "both-empty"],
)
def test_the_CLASSIFIER_ITSELF_returns_the_declared_unreadable_verdict(bad_rule):
"""Exit 0 AND the verdict word, not merely the wrapper's output. A crash exits 5 with empty
stdout; a classification exits 0 with `{"verdict":"unreadable"}`. Only the second is the
contract the two callers were taught to consume."""
code, out = classify(json.dumps([bad_rule]))
assert (code, out) == (0, '{"verdict":"unreadable"}'), (
f"the classifier did not CLASSIFY an unusable name — it produced exit={code} out={out!r}. "
"An exit 5 with empty stdout is a crash the callers absorb, not the declared verdict."
)
def test_the_classifier_still_reaches_its_OTHER_verdicts():
"""Anti-vacuity. Every assertion above is satisfied by a classifier that answers `unreadable` to
everything, which would be a catastrophic permanent `ask`. These pin that the other three arms
are still reachable, so the new one is an addition rather than a swallow."""
assert classify(json.dumps([rule(SNAP_CONTEXTS)]))[1].startswith('{"verdict":"exact"'), "exact is unreachable"
assert classify(json.dumps([rule(SNAP_CONTEXTS, name="m*")])) == (0, '{"verdict":"undecidable"}'), (
"undecidable is unreachable"
)
assert classify(json.dumps([rule(SNAP_CONTEXTS, name="develop")])) == (0, '{"verdict":"none"}'), (
"none is unreachable"
)
@pytest.mark.parametrize(
"payload",
["{}", '{"a":{"branch_name":"main","enable_status_check":true,"status_check_contexts":[]}}', '"main"', "42"],
ids=["empty-object", "object-of-rules", "string", "number"],
)
def test_a_NON_ARRAY_payload_is_unreadable_not_a_finding(payload):
"""`nomatch` is this script's only class that asserts something POSITIVE about the server —
"no rule governs the branch at all (protection removed)". It must not be reachable from a body
that is not the shape this script consumes.
Measured before the gate: `{}` reached the classifier, matched no rule, and printed `nomatch`.
The merge hook has had this gate since #778; this script did not, so the same payload produced a
confident finding here and a correct refusal there. Pre-existing rather than introduced by
ersatztv#859, and fixed with it because the whole issue is arms asserting findings they never
established.
"""
code, out, _ = run(payload)
assert (code, out) == (0, "unreadable"), (
f"a non-array payload classified as {out!r} — anything but `unreadable` is a claim about "
"branch protection derived from a body that was never understood"
)
def test_an_EMPTY_array_is_still_a_real_finding():
"""NEGATIVE CONTROL for the gate above. `[]` IS the shape this script consumes and it genuinely
establishes absence — the list was read and holds no rule. A gate that swallowed it too would
turn the one payload that legitimately proves "protection removed" into a shrug, and every test
above would still pass."""
code, out, _ = run("[]")
assert (code, out) == (0, "nomatch"), (
f"the array gate swallowed a legitimately empty rule list: {out!r} — `[]` is the only "
"payload that establishes absence, and it must stay a finding"
)
def test_a_MALFORMED_rule_name_poisons_even_beside_a_GOOD_branch_name():
"""Poison is field-agnostic, which the first draft of the fix got wrong in one direction.
It short-circuited on `branch_name`, so `{"branch_name":42,"rule_name":"main"}` poisoned while
`{"branch_name":"main","rule_name":42}` answered `exact` off the good field and never looked at
the malformed one — an invariant the comment claimed and the code did not hold. Gitea 1.27.1 does
not send that second shape, so this pins a property rather than a bug: "a malformed field poisons
the list" has to be true of BOTH fields or it is not the rule that is written down.
"""
for bad in (42, [], {}, True):
payload = json.dumps([{"branch_name": "main", "rule_name": bad, "enable_status_check": True}])
code, out = classify(payload)
assert (code, out) == (0, '{"verdict":"unreadable"}'), (
f"a malformed `rule_name` ({bad!r}) beside a usable `branch_name` produced {out!r} "
"— poison must not depend on which field carries it"
)
@@ -0,0 +1,158 @@
"""ersatztv#820: the SPA guard's disposition table may not silently disagree with this one.
WHAT THIS BLOCKS. `web/src/api/completeAnnotations.guard.test.ts` carries a `DISPOSITIONS` table
saying, per schema, whether the SPA must annotate it `Complete<T>`, must not, or need not. It derives
its POPULATION (which schemas can drop a member) from the generated types and asserts set equality
against those keys — so a new optional member cannot ship undispositioned.
It did not check the VALUE, and cold review demonstrated that the value is a kill switch: flip
`MultiCollectionItemRequest` from `ANNOTATED` to `CREATE` and delete the `Complete<…>` from
`MultiCollectionsScreen.toItemRequest`, and the whole suite stays green while the defect #807 exists
to prevent — every weight silently reset to 1 on save — is live again. The row's own note still said
"a prose comment warned about it, and a comment is not a check", which is what the flip made of it.
WHY THE CHECK LIVES HERE. `testing.guard-derives-population-from-source`: "When the scope itself
MIRRORS an authoritative source, the mirror needs its own equality check against that source."
`test_optional_request_members.py` IS that source — it derives from `ErsatzTV/wwwroot/openapi/v1.json`
every run and its dispositions are reviewed. The TS table mirrors nine of its rows. Putting the
comparison on the Python side is what makes it cheap: this process already holds the authoritative
table and can read a file, whereas the SPA guard runs under Vite with no filesystem access to
`scripts/` and would need a virtual-module plugin to see it.
THE MAPPING IS ASYMMETRIC ON PURPOSE, and stating why is half the check:
COVERED -> ANNOTATED exact. This is the row whose claim the SPA guard verifies.
COMPUTED -> MUST-NOT-ANNOTATE exact. Annotating would force a caller to invent server-computed
values, so the prohibition must survive on both sides.
CREATE -> CREATE | MUST-NOT-ANNOTATE
TRIGGER -> TRIGGER | MUST-NOT-ANNOTATE
a STRICTER call on the SPA side is allowed, because forbidding an
annotation is never the unsafe direction. This is not hypothetical
slack: `CreateChannelFromLineupAdvancedOptionsRequest` is CREATE
here and MUST-NOT-ANNOTATE there, because an omitted override means
INHERIT the template value and `Complete<T>` would collapse that
third state into an explicit null.
Schemas the TS table holds that this one does not are NOT an error: that table scopes to every
generated schema with an optional member, this one to schemas reachable from a request body, so it
additionally sees response models. The reverse IS an error — a schema ruled on here and absent there
means the SPA guard is not covering something the API can drop.
"""
from __future__ import annotations
import importlib.util
import re
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
TS_GUARD = REPO_ROOT / "web" / "src" / "api" / "completeAnnotations.guard.test.ts"
# `Name: {` then an optional run of `//` comment lines, then `disposition: '...'`. Anchored on the
# two-space indent the file's formatter produces for a top-level entry, so a `disposition:` appearing
# in prose or in a nested object cannot be mistaken for a row.
_TS_ROW = re.compile(
r"^ (?P<schema>\w+): \{\n(?:\s*//[^\n]*\n)*\s*disposition: '(?P<disposition>[A-Z-]+)'",
re.MULTILINE,
)
# The compact single-line form the formatter emits for short rows.
_TS_ROW_INLINE = re.compile(r"^ (?P<schema>\w+): \{ disposition: '(?P<disposition>[A-Z-]+)'", re.MULTILINE)
ALLOWED = {
"COVERED": {"ANNOTATED"},
"COMPUTED": {"MUST-NOT-ANNOTATE"},
"CREATE": {"CREATE", "MUST-NOT-ANNOTATE"},
"TRIGGER": {"TRIGGER", "MUST-NOT-ANNOTATE"},
}
# A key that is not a bare identifier does not name a generated schema: `test_optional_request_members`
# also rules on INLINE request bodies, which it keys by endpoint (`POST /api/v1/artwork/uploads
# (multipart/form-data inline body)`). Those have no `components['schemas'][…]` entry, so the SPA
# guard — whose population is parsed out of the generated types — cannot carry a row for them and
# their absence is not a hole. `_comparable` is what keeps that exemption from widening into one: it
# is a SHAPE test, not a name list, so a new inline body is exempt automatically while a new named
# schema can never be.
_SCHEMA_NAME = re.compile(r"^\w+$")
def _comparable(schema: str) -> bool:
return _SCHEMA_NAME.match(schema) is not None
def _python_dispositions() -> dict[str, str]:
spec = importlib.util.spec_from_file_location(
"_optional_request_members", Path(__file__).with_name("test_optional_request_members.py")
)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return {schema: value[0] for schema, value in module.DISPOSITIONS.items()}
def _ts_dispositions() -> dict[str, str]:
text = TS_GUARD.read_text(encoding="utf-8")
found = {m.group("schema"): m.group("disposition") for m in _TS_ROW.finditer(text)}
found.update({m.group("schema"): m.group("disposition") for m in _TS_ROW_INLINE.finditer(text)})
return found
def test_the_TS_table_is_parseable_at_all() -> None:
"""Anti-vacuity, and the only thing standing between a formatting change and a silent no-op.
Every comparison below is over an intersection. If the regex stops matching — a prettier setting,
a rename, a move — the intersection empties and every other test here passes having compared
nothing. This is the check that turns that into a failure.
"""
ts = _ts_dispositions()
assert len(ts) >= 10, (
f"parsed only {len(ts)} rows out of {TS_GUARD.relative_to(REPO_ROOT)}; the table has more than "
f"that, so the parse is broken rather than the table being small. Every comparison in this "
f"file is over an intersection and would pass vacuously."
)
assert set(ts.values()) <= {"ANNOTATED", "MUST-NOT-ANNOTATE", "CREATE", "TRIGGER", "RESPONSE-ONLY"}
def test_the_PYTHON_table_is_non_empty_too() -> None:
"""The TS side has a floor; without one here the comparisons are still vacuous from the other end.
Every check below iterates the Python table. An empty one collects zero parametrised cases and
makes the missing-schema check pass over an empty set — green, having compared nothing. The
neighbour file has its own floor, but a guard that depends on a neighbour's floor for its own
anti-vacuity is borrowing the property rather than holding it.
"""
assert len(_python_dispositions()) >= 5
def test_the_inline_body_exemption_stays_small_and_shaped() -> None:
"""The exemption above is a shape test; this bounds how much it can be carrying."""
exempt = sorted(s for s in _python_dispositions() if not _comparable(s))
assert len(exempt) <= 2, f"more inline-body keys than expected: {exempt}"
assert all(" " in s or "/" in s for s in exempt), f"an exempt key looks like a schema name: {exempt}"
def test_every_schema_ruled_on_HERE_is_also_ruled_on_in_the_SPA_guard() -> None:
missing = sorted(s for s in _python_dispositions() if _comparable(s) and s not in _ts_dispositions())
assert not missing, (
f"schema(s) dispositioned in test_optional_request_members.py with no row in the SPA guard: "
f"{missing}. The API can drop a member of each, and the guard that checks the SPA's "
f"annotations is not covering it."
)
@pytest.mark.parametrize("schema", sorted(s for s in _python_dispositions() if _comparable(s)))
def test_the_two_dispositions_AGREE(schema: str) -> None:
"""The bypass this closes: the TS value alone decided whether an annotation was required."""
python_disposition = _python_dispositions()[schema]
ts = _ts_dispositions()
if schema not in ts:
pytest.skip("absence is asserted separately, so it is not re-reported per schema here")
allowed = ALLOWED[python_disposition]
assert ts[schema] in allowed, (
f"{schema}: this file says {python_disposition}, the SPA guard says {ts[schema]}, and "
f"{python_disposition} admits only {sorted(allowed)}. Flipping the SPA-side value is how a "
f"required `Complete<…>` annotation stops being required — decide which table is wrong."
)
@@ -0,0 +1,366 @@
"""`scripts/ci-detect-docs-only.sh` must never graft a COMPLETE clone shallow (ersatztv#836).
`git fetch --depth=N` writes `.git/shallow` and cuts history at N even when every object is already
present. Exactly four `docker-build.yml` jobs run the detector — `test`, `migrations` and
`functional-e2e` at `fetch-depth: 2`, and `build` at `fetch-depth: 0` — so `build` is the only
complete-clone consumer, and the detector asks the repository which shape it is in rather than
assuming. (`api-docs` and `format` have a step with `id: detect` too, but it runs their own inline
diff, not this script. Do not add them here.)
WHAT WENT WRONG WITHOUT THIS. `build`'s next step after the detector is `Compute version and tags`,
whose `git describe --tags --abbrev=0` needs a reachable tag. The graft cut it off, `describe`
failed, and a `|| echo v0.0.0` fallback that cannot fail turned that into a version string: every
`:latest` image published from `main` carried `InformationalVersion 0.0.0-<sha>` instead of
`26.x.y-<sha>`, from 2026-07-17 (#416) until #836, with nothing red anywhere. It was found by
reading the string out of
a running container. That is why the assertion here is on the RESULTING VERSION and on the shallow
flag — not on the detector's exit status, which was `0` throughout the defect.
WHY THE FIXTURE USES `file://`. `git clone /path` and `git fetch /path` use the local transport,
which IGNORES `--depth` outright. A fixture built on a plain path would therefore never graft, every
assertion below would hold, and the whole file would pass while testing nothing —
`testing.eight-ways-a-test-passes-for-the-wrong-reason`. `test_the_fixture_can_actually_graft` is the
negative control that keeps that honest: it performs the raw depth fetch the detector is forbidden to
perform and REQUIRES the graft to happen. If git or the transport ever stops grafting, that control
goes red and says so, instead of the other tests quietly becoming vacuous.
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
DETECTOR = REPO_ROOT / "scripts" / "ci-detect-docs-only.sh"
TAG = "v26.3.1"
def _env(**extra: str) -> dict[str, str]:
"""Ambient `GIT_*` removed, everything else kept.
Exported `GIT_DIR`/`GIT_WORK_TREE`/`GIT_INDEX_FILE` override `cwd` and would aim these git calls
at the real repository. The rest of `os.environ` is carried deliberately rather than built from
scratch: `conftest.py`'s isolation puts `ETV_HOOK_FIRE_LOG_DIR` there, and a from-scratch
environment would drop it and write into the production hook-fire log.
`GITHUB_` is dropped alongside `GIT_` because it does NOT match the `GIT_` prefix
(`"GITHUB_OUTPUT".startswith("GIT_")` is False), and every `GITHUB_*` the detector reads decides
which arm it takes. Pinning only the one variable that bites today would leave the class open for
the next arm that starts reading a sibling — the whole prefix goes, and `_run_detector` puts back
exactly what each test means to set.
THE MACHINE'S GIT CONFIG IS NEUTRALISED TOO, and that is not belt-and-braces — it is what keeps
this file from passing here and failing in CI. A developer checkout has a global `user.email`;
the CI container has none, and git refuses to commit without one ("Author identity unknown").
The fixtures below commit, so the first version of this file was green locally and red in CI on
exactly that. Pointing the global and system config at `os.devnull` makes the ambient
configuration UNAVAILABLE rather than merely overridden, so a missing `-c` flag in `_git` fails
everywhere instead of only where the machine is unhelpful — the local run reproduces CI by
construction. It also disarms a global `core.hooksPath` (which would fire this repo's husky
hooks against throwaway fixtures) and a global `commit.gpgsign` (which would block on a pinentry
prompt no pytest timeout is watching).
"""
env = {k: v for k, v in os.environ.items() if not k.startswith(("GIT_", "GITHUB_"))}
env["GIT_CONFIG_GLOBAL"] = os.devnull
env["GIT_CONFIG_SYSTEM"] = os.devnull
env.update(extra)
return env
# Passed to every git invocation, so no repository here can inherit an identity or a hook path from
# the machine. `_env()` makes the ambient config unavailable; these supply what the commits need.
_GIT_CONF = [
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test",
"-c",
"commit.gpgsign=false",
"-c",
"core.hooksPath=",
# Forbids git from INVENTING an identity from the OS user and hostname. Without it the two
# settings above are not enough to reproduce CI: a Mac happily auto-detects one, so dropping the
# identity flags stays green locally and fails in the container. With it, a missing flag fails
# everywhere — verified by removing the identity pair and watching this file go red.
"-c",
"user.useConfigOnly=true",
]
def _git(args: list[str], cwd: Path) -> str:
r = subprocess.run(["git", *_GIT_CONF, *args], cwd=str(cwd), env=_env(), capture_output=True, text=True)
assert r.returncode == 0, f"git {' '.join(args)} failed: {r.stderr}"
return r.stdout.strip()
def _git_rc(args: list[str], cwd: Path) -> subprocess.CompletedProcess:
return subprocess.run(["git", *_GIT_CONF, *args], cwd=str(cwd), env=_env(), capture_output=True, text=True)
def _is_shallow(repo: Path) -> str:
return _git(["rev-parse", "--is-shallow-repository"], repo)
@pytest.fixture(scope="module")
def origin_url(tmp_path_factory) -> str:
"""An origin shaped like ours: the newest tag is well back, and the tip is a merge commit.
Both properties matter. The tag has to be further back than the fetch depth or the graft cannot
cut it off, and the tip has to be a merge commit or the detector's push arm reports
`non-merge push` and returns before reaching anything this file is about.
"""
root = tmp_path_factory.mktemp("detector-depth")
origin = root / "origin"
origin.mkdir()
_git(["init", "-q", "-b", "main", "."], origin)
for i in range(1, 13):
(origin / f"f{i}.txt").write_text(f"{i}\n")
_git(["add", "-A"], origin)
_git(["commit", "-q", "-m", f"c{i}"], origin)
_git(["tag", TAG], origin)
for i in range(13, 31):
(origin / f"f{i}.txt").write_text(f"{i}\n")
_git(["add", "-A"], origin)
_git(["commit", "-q", "-m", f"c{i}"], origin)
# A docs-only branch merged with --no-ff, i.e. exactly how an update to `main` lands here.
_git(["checkout", "-q", "-b", "feature"], origin)
(origin / "docs").mkdir()
(origin / "docs" / "a.md").write_text("doc\n")
_git(["add", "-A"], origin)
_git(["commit", "-q", "-m", "docs change"], origin)
_git(["checkout", "-q", "main"], origin)
_git(["merge", "-q", "--no-ff", "feature", "-m", "merge feature (#1)"], origin)
return f"file://{origin}"
@pytest.fixture(scope="module")
def deep_origin_url(tmp_path_factory) -> str:
"""An origin with MORE than 200 commits after its newest tag, for the PR arm's `--depth=200`.
Built separately, and the extra ~3s is the price of a test that is not vacuous. The other
fixture's 31 commits all fit inside a depth of 200, so a `--depth=200` fetch there has nothing
to cut: no `.git/shallow` is written, `describe` keeps resolving, and a PR-arm test built on it
passes against the UNFIXED script — measured, which is how this fixture came to exist. A guard
whose positive case cannot occur is the defect it is meant to catch, one level up.
"""
root = tmp_path_factory.mktemp("detector-depth-deep")
origin = root / "origin"
origin.mkdir()
_git(["init", "-q", "-b", "main", "."], origin)
(origin / "a.txt").write_text("a\n")
_git(["add", "-A"], origin)
_git(["commit", "-q", "-m", "c0"], origin)
_git(["tag", TAG], origin)
# Empty commits: the depth boundary counts commits, and 210 real ones would only be slower.
for i in range(1, 211):
_git(["commit", "-q", "--allow-empty", "-m", f"c{i}"], origin)
return f"file://{origin}"
def _clone(origin_url: str, dest: Path, depth: int | None = None) -> Path:
args = ["clone", "-q"]
if depth is not None:
args += [f"--depth={depth}"]
args += [origin_url, str(dest)]
_git(args, dest.parent)
return dest
def _run_detector(repo: Path, tmp_path: Path, **github_env: str) -> tuple[subprocess.CompletedProcess, str]:
"""Drive the real detector.
`GITHUB_REF_TYPE` is pinned to `branch` rather than left to the ambient environment. `_env()`
deliberately carries the rest of `os.environ` (for `ETV_HOOK_FIRE_LOG_DIR`), and the detector's
FIRST branch returns early on `GITHUB_REF_TYPE=tag` — before any fetch — so an exported `tag`
turns every test here red for a reason that has nothing to do with what they assert.
Not live today, stated precisely rather than dramatised: the `scan` job DOES run pytest on tag
pushes, but over two named files, and this is not one of them; `script-tests`, which does run
this file, is `on: pull_request`. Adding this file to `scan`'s argument list is the one edit that
would make it live. It is a default (`env.update(github_env)` follows), so a future test can
still drive the tag arm deliberately.
"""
out_file = tmp_path / f"gh-output-{repo.name}"
out_file.write_text("")
env = {"GITHUB_REF_TYPE": "branch"}
env.update(github_env)
r = subprocess.run(
["bash", str(DETECTOR)],
cwd=str(repo),
env=_env(GITHUB_OUTPUT=str(out_file), **env),
capture_output=True,
text=True,
)
return r, out_file.read_text()
# --------------------------------------------------------------------------------------------
# The negative control. Everything else here asserts that a graft does NOT happen, so it is all
# vacuous if the fixture cannot graft in the first place.
# --------------------------------------------------------------------------------------------
@pytest.mark.parametrize(
"fixture_name, depth",
[("origin_url", 2), ("deep_origin_url", 200)],
ids=["push-arm-depth-2", "pr-arm-depth-200"],
)
def test_the_fixture_can_actually_graft(request, fixture_name, depth, tmp_path):
"""The raw `--depth=N` fetch the detector must not perform DOES graft this fixture.
Parametrised over BOTH fixtures and BOTH depths, because a control that covers only one of
them leaves the other free to go vacuous unreported — which is what happened: the PR arm was
first written against the 31-commit origin, where a depth of 200 has nothing to cut, and it
passed against the unfixed script. One control per (fixture, depth) pair the file relies on.
"""
origin = request.getfixturevalue(fixture_name)
repo = _clone(origin, tmp_path / f"control-{depth}")
assert _is_shallow(repo) == "false", "a plain clone should be complete"
assert _git(["describe", "--tags", "--abbrev=0"], repo) == TAG
_git(["fetch", "--no-tags", f"--depth={depth}", "origin", "main"], repo)
assert _is_shallow(repo) == "true", (
f"a --depth={depth} fetch did not graft this fixture, so the test that relies on it is "
"vacuous — most likely the clone/fetch fell back to the local transport, which ignores "
"--depth (use file://), or the fixture has fewer commits than the depth"
)
described = _git_rc(["describe", "--tags", "--abbrev=0"], repo)
assert described.returncode != 0, (
"the graft happened but `git describe` still resolved, so the version this file protects "
"is not actually reachable from the shallow boundary — the fixture's tag is too close to "
f"the tip. stdout={described.stdout!r}"
)
# --------------------------------------------------------------------------------------------
# The guard proper.
# --------------------------------------------------------------------------------------------
def test_the_push_arm_leaves_a_COMPLETE_clone_complete(origin_url, tmp_path):
"""`build`'s shape: `fetch-depth: 0`, push to main. The version must survive the detector.
This is the proof named by `scripts/ci-detect-docs-only.sh`'s `MUTATION` row in
`docs/guard-inventory.md`; the declared mutation in `mutation_manifest.py` disarms the shallow
condition, which puts the `--depth=2` fetch back and reddens the two assertions below.
"""
repo = _clone(origin_url, tmp_path / "build-shape")
assert _is_shallow(repo) == "false"
r, output = _run_detector(repo, tmp_path, GITHUB_EVENT_NAME="push", GITHUB_REF_NAME="main")
assert r.returncode == 0, f"detector failed: {r.stdout}{r.stderr}"
assert _is_shallow(repo) == "false", (
"the detector GRAFTED the complete clone shallow — this is ersatztv#836: the next step in "
"`build` is `Compute version and tags`, whose `git describe` then finds no reachable tag "
f"and the image ships InformationalVersion 0.0.0-<sha>.\ndetector said:\n{r.stdout}"
)
described = _git_rc(["describe", "--tags", "--abbrev=0"], repo)
assert described.returncode == 0 and described.stdout.strip() == TAG, (
"after the detector, `git describe --tags --abbrev=0` no longer resolves, so `build` would "
f"stamp this image 0.0.0. git said: {described.stdout.strip()!r} {described.stderr.strip()!r}"
)
# The detector still has to do its actual job on this shape.
assert "docs_only=true" in output, (
f"the merge tip is docs-only but the detector did not say so:\n{r.stdout}\n{output}"
)
def test_the_pull_request_arm_leaves_a_COMPLETE_clone_complete(deep_origin_url, tmp_path):
"""A complete checkout on the PR arm, N=200. NO PRODUCTION CONSUMER TODAY — deliberately.
Said plainly because the first version of this docstring got it wrong: on `pull_request` the only
jobs running this script are the three `fetch-depth: 2` ones, so the PR arm's `--depth=200` was
inert rather than latently firing. What this pins is the SHARED CLAUSE — the next `fetch-depth: 0`
consumer added to this arm must not silently inherit the graft that cost #836. It is a guard
against a future shape, not a reproduction of a shipped one, and grading it as the latter would
overstate what the file proves.
The deep fixture is what makes the case real at all: with the 32-commit origin a depth of 200 has
nothing to cut, and this test passed against the unfixed script.
"""
repo = _clone(deep_origin_url, tmp_path / "pr-shape")
assert _is_shallow(repo) == "false"
# A PR head: one docs commit on top of the base branch.
_git(["checkout", "-q", "-b", "pr-branch"], repo)
(repo / "docs").mkdir(exist_ok=True)
(repo / "docs" / "b.md").write_text("doc\n")
_git(["add", "-A"], repo)
_git(["commit", "-q", "-m", "docs on the PR head"], repo)
r, output = _run_detector(repo, tmp_path, GITHUB_EVENT_NAME="pull_request", GITHUB_BASE_REF="main")
assert r.returncode == 0, f"detector failed: {r.stdout}{r.stderr}"
assert _is_shallow(repo) == "false", (
"the PR arm GRAFTED a complete clone shallow (ersatztv#836). No job runs this arm against a "
f"complete checkout today, so this is the shared clause regressing.\n{r.stdout}"
)
described = _git_rc(["describe", "--tags", "--abbrev=0"], repo)
assert described.returncode == 0 and described.stdout.strip() == TAG, (
"after the PR arm, `git describe --tags --abbrev=0` no longer resolves: "
f"{described.stdout.strip()!r} {described.stderr.strip()!r}"
)
# FETCH_HEAD is what the PR arm diffs against, so the depth-less fetch must still write it.
assert _git_rc(["rev-parse", "--verify", "-q", "FETCH_HEAD"], repo).returncode == 0, (
"the PR arm no longer writes FETCH_HEAD, so its base revision is unresolvable"
)
assert "docs_only=true" in output, (
f"the PR head is docs-only but the detector did not say so:\n{r.stdout}\n{output}"
)
def test_a_SHALLOW_checkout_still_gets_its_depth_and_still_decides(origin_url, tmp_path):
"""The other half: `test`/`migrations`/`functional-e2e` at `fetch-depth: 2` must be unchanged.
Removing the graft must not turn into removing the depth. A shallow checkout stays shallow, and
the detector still resolves `HEAD^1` and classifies the docs-only merge — the behaviour the
`--depth=2` was added for in the first place.
"""
repo = _clone(origin_url, tmp_path / "shallow-shape", depth=2)
assert _is_shallow(repo) == "true", "the fixture clone was meant to be shallow"
r, output = _run_detector(repo, tmp_path, GITHUB_EVENT_NAME="push", GITHUB_REF_NAME="main")
assert r.returncode == 0, f"detector failed: {r.stdout}{r.stderr}"
assert _is_shallow(repo) == "true", "a shallow checkout was silently unshallowed"
assert "docs_only=true" in output, (
"the shallow checkout no longer classifies the docs-only merge — the depth stopped being "
f"applied where it IS needed:\n{r.stdout}\n{output}"
)
assert "fetching with --depth=2" in r.stdout, f"the shallow checkout did not take the depth branch:\n{r.stdout}"
assert "fetching without --depth" not in r.stdout, (
f"the shallow checkout took the complete-clone branch:\n{r.stdout}"
)
def test_an_UNKNOWN_shallow_answer_passes_no_depth(tmp_path):
"""Fail-direction, stated and executed: `--is-shallow-repository` unanswerable => no depth.
Defaulting the other way would re-graft on exactly the paths nobody can observe, so the branch
is pinned rather than argued. Running outside a repository is one way to make the query
unanswerable and the one this drives; it is NOT the only one — on a git older than 2.15 the flag
is unrecognised and rev-parse echoes it back verbatim with status 0. That shape is not covered
here (it needs an ancient git), and it reaches the same no-depth branch.
WHAT THIS DELIBERATELY DOES NOT ASSERT. Outside a repository the detector exits 128 with an
empty `$GITHUB_OUTPUT`, because `set -o pipefail` makes the `git rev-list ... | wc -w` assignment
below the fetch abort under `set -e`. That is PRE-EXISTING and unchanged here — measured against
`8aeacd534`, the commit this fix branched from, which exits 128 the same way. It is also not a
fail-open: the step goes red, so the job does, and no `docs_only=true` is ever emitted. Asserting
a clean exit here would be asserting a behaviour the script has never had.
"""
notarepo = tmp_path / "notarepo"
notarepo.mkdir()
r, _output = _run_detector(notarepo, tmp_path, GITHUB_EVENT_NAME="push", GITHUB_REF_NAME="main")
assert "is-shallow=unknown" in r.stdout, f"the unknown answer was not reached or not reported:\n{r.stdout}"
assert "fetching without --depth" in r.stdout, f"an unknown shallow answer must not ask for a depth:\n{r.stdout}"
@@ -99,10 +99,53 @@ if url.rstrip("/").endswith("/branch_protections"):
if mode == "EMPTY":
respond('')
if mode == "UNPARSEABLE-RULES":
# A 200 whose rule NAME is a number: `//` fires only on null/false, so the classifier's
# `explode`/`match` throws and the program dies on a read that plainly succeeded.
# A 200 whose rule NAME is a number. It is not a usable name, so the classifier answers
# `unreadable` and the hook reports a payload it could not parse (ersatztv#859). Before that
# it reached the same arm by DYING — `//` fires only on null/false, so `explode`/`match`
# threw and the program produced no output at all, which the catch-all absorbed.
respond(json.dumps([{"branch_name": 7, "enable_status_check": True,
"status_check_contexts": ["review-verdict/h10"]}]))
if mode == "NAMELESS-RULE":
# A rule carrying NEITHER name field. `.branch_name // .rule_name // ""` turned it into the
# empty string — a VALID name that simply matches nothing — so a rule the program could not
# read was counted as one that does not govern the base, and the hook DENIED on the stated
# grounds that the full list was read and nothing matched (ersatztv#859).
respond(json.dumps([{"enable_status_check": True,
"status_check_contexts": ["review-verdict/h10"]}]))
if mode == "MALFORMED-NAME-BESIDE-GOOD-ONE":
# `branch_name` PRESENT holding a number, beside a `rule_name` that names the base exactly
# and requires every context. The first draft of #859's fix fell through to `rule_name` on
# any non-usable `branch_name`, so this answered `exact` and AUTO-GRANTED — where the
# predecessor threw and asked. A field that is present and malformed is not a field that is
# absent; it is evidence the payload is not what the program thinks it is.
respond(json.dumps([{"branch_name": 42, "rule_name": "main", "enable_status_check": True,
"status_check_contexts": REQUIRED}]))
if mode == "ARRAY-OF-NON-OBJECTS":
# A 200 whose body IS an array — so it passes the array gate — but whose members are not
# objects. jq throws indexing a string with `.branch_name`, the classifier produces nothing,
# and the `case`'s catch-all is the only arm that can answer.
respond(json.dumps(["main"]))
if mode == "REAL-GITEA-GLOB-PLUS-PLAIN":
# THE SHAPE GITEA ACTUALLY SENDS, measured 2026-08-30 on a scratch repo against 1.27.1:
# a GLOB rule reports `branch_name:""` while keeping `rule_name`, a plain rule populates
# both. The glob does NOT require review-verdict/h10; the plain `main` rule does. Gitea
# orders by Priority then plain-name-ness, so it may be applying the glob — which is exactly
# why this must ASK rather than trust the exactly-named rule.
respond(json.dumps([{"branch_name": "", "rule_name": "m*", "enable_status_check": True,
"status_check_contexts": ["Build ErsatzTV Image / Build & test (.NET)"]},
{"branch_name": "main", "rule_name": "main", "enable_status_check": True,
"status_check_contexts": REQUIRED}]))
if mode == "REAL-GITEA-GLOB-ONLY":
# Same real shape, glob alone. It DOES govern `main`, so "no rule matches" is false about it.
respond(json.dumps([{"branch_name": "", "rule_name": "m*", "enable_status_check": True,
"status_check_contexts": REQUIRED}]))
if mode == "EMPTY-NAME-EXACT-RULE":
# `branch_name` empty, `rule_name` naming the base exactly, contexts fully correct. jq's
# `//` does NOT fire on `""`, so the empty field shadowed the real name and this fully and
# correctly protected branch classified as `none` — a DENY with the governing rule sitting
# in the very next field. The severe direction of the same defect.
respond(json.dumps([{"branch_name": "", "rule_name": "main",
"enable_status_check": True, "status_check_contexts": REQUIRED}]))
if mode == "LIST-404":
# An HTTP 404 from the LIST endpoint: the repo is absent, or invisible to this credential.
# Gitea answers 404 for both, and it says NOTHING about whether the base is protected.
@@ -775,7 +818,8 @@ def test_the_precedence_check_runs_even_when_an_exactly_named_rule_EXISTS(hook):
def test_a_GLOB_rule_that_could_outrank_an_exact_one_wins_and_ASKS(hook):
"""The arm ORDER, pinned. Without this the reorder is invisible to the suite — swapping the arms
back left all 29 tests green, which is how an unproven change ships.
back left the WHOLE suite green, which is how an unproven change ships. (An invariant, not a
count: this sentence carried one until ersatztv#859, whose own new tests falsified it.)
Gitea picks the governing rule with `GetFirstMatched` over a list sorted by Priority and THEN by
plain-name-ness, so a glob rule can outrank an exactly-named one. Here `main` requires
@@ -840,7 +884,7 @@ def test_an_HTTP_404_on_the_LIST_read_asks_and_does_not_claim_the_list_was_read(
assert "none matches" not in reason, "a 404 read claimed the full rule list had been read and matched nothing"
@pytest.mark.parametrize("shape", ["UNPARSEABLE-RULES", "GARBAGE", "EMPTY"])
@pytest.mark.parametrize("shape", ["GARBAGE", "EMPTY"])
def test_a_200_the_classifier_cannot_PARSE_asks_without_blaming_the_transport(hook, shape):
"""The twin of the `nomatch` fix, on the other arm — and pinned this time rather than assumed.
@@ -849,11 +893,15 @@ def test_a_200_the_classifier_cannot_PARSE_asks_without_blaming_the_transport(ho
unreachable…)", stating a transport cause for a 200. The decision (ask) was always safe; only
the reason lied, which is precisely the defect corrected one arm over for the deny.
Parametrised over all three shapes that reach a 200 the hook cannot use, because the first fix
covered only `UNPARSEABLE-RULES` — the arm where it was noticed. `GARBAGE` (an object, not an
array) and `EMPTY` are diverted one branch EARLIER, by the array gate, and kept `bp_code=200`,
so they reported "HTTP '200' — Gitea unreachable" about a successful read. Fixing one arm and
leaving its twin is the shape this PR is largely about.
Parametrised because the first fix covered only the arm where it was noticed. `GARBAGE` (an
object, not an array) and `EMPTY` are diverted one branch EARLIER, by the array gate, and kept
`bp_code=200`, so they reported "HTTP '200' — Gitea unreachable" about a successful read. Fixing
one arm and leaving its twin is the shape that PR was largely about.
`UNPARSEABLE-RULES` was a third member here until ersatztv#859. A numeric `branch_name` no longer
reaches this arm at all: it is not a usable NAME, so the classifier now classifies it rather than
dying on it, and it asks with the cause that actually applies. It is covered by
`test_a_rule_with_an_UNUSABLE_NAME_asks_and_names_that_cause` below.
"""
hook.set_branch_protection(shape)
reason = hook.reason()
@@ -1113,3 +1161,319 @@ def test_a_checker_USAGE_error_reaches_the_operator_WITH_its_cause(hook, tmp_pat
"the checker's own diagnostic was discarded, so the operator is told only that it returned "
f"nothing: {out['permissionDecisionReason'][:220]}"
)
# --- ersatztv#859 (1): a rule with no usable NAME is `unreadable`, never a name that matches nothing
@pytest.mark.parametrize(
"shape",
["NAMELESS-RULE", "UNPARSEABLE-RULES"],
ids=["BOTH-FIELDS-ABSENT", "NUMERIC-NAME"],
)
def test_a_rule_with_an_UNUSABLE_NAME_asks_and_names_that_cause(hook, shape):
"""The #859 defect. `(.branch_name // .rule_name // "")` turned a rule the program could not read
into the empty string — a VALID name that simply matches nothing — so the classifier answered
`none`, and on this caller `none` DENIES with the stated cause "the full rule list was read and
none matches". That sentence is false about a list that was never understood.
The direction is what makes it worth a test rather than a comment: the sibling caller (#787's)
turns the same `none` into `nomatch` -> ask, wrong but safe, while HERE it is a refusal stating a
finding nobody established.
Both shapes are pinned because they used to fail DIFFERENTLY and converge only after the fix.
`BOTH-FIELDS-ABSENT` produced the false `none` -> deny. `NUMERIC-NAME` made jq throw, killing the
program, and reached a safe ask only because the caller's catch-all absorbed the silence — a
correct outcome resting on a crash. A number is not a usable name either, so both are now
classified rather than one denied and one fatal.
"""
hook.set_branch_protection(shape)
out = hook.decision()["hookSpecificOutput"]
assert out["permissionDecision"] == "ask", (
f"a rule the classifier could not read produced {out['permissionDecision']!r} rather than a "
f"prompt: {out['permissionDecisionReason'][:200]}"
)
# THE CAUSE, not just the verdict. An `ask` from any other arm would satisfy the check above
# while proving nothing about this one — the failure mode the neighbouring glob tests already
# guard against. This wording belongs to `unnamed-rule` alone.
assert "no name this hook can use" in out["permissionDecisionReason"], (
f"the ask came from a different arm: {out['permissionDecisionReason'][:200]}"
)
# The false cause specifically must be gone. This is the regression that matters.
assert "none matches" not in out["permissionDecisionReason"], (
"the hook still claims the full rule list was read and none matched, about a rule it could not read at all"
)
# And it must NOT be collapsed back into the generic parse failure. That sentinel was measured
# to be a no-op when both classes shared it: deleting the arm left the WHOLE suite green,
# because nothing downstream could tell them apart. (An invariant, not a count — the number this
# sentence first carried was already wrong by seven when a later review re-ran it.)
assert "could not parse" not in out["permissionDecisionReason"], (
"the declared `unreadable` class was folded back into the generic parse-failure sentinel, "
"which no observation can distinguish from the catch-all"
)
def test_an_EMPTY_branch_name_no_longer_hides_the_real_rule_name(hook):
"""The severe direction of the same defect, and the reason the fix is a fall-through rather than
only an `unreadable` arm.
jq's `//` fires on null and false but NOT on `""`. A rule `{"branch_name":"","rule_name":"main"}`
therefore resolved to `""`, matched nothing, and answered `none` -> DENY — on a branch that is
exactly named, fully protected, and requires every context the snapshot lists. The governing rule
sat in the very next field the program declined to read.
REACHABLE, and the shape is Gitea's own: 1.27.1 reports a GLOB rule with `branch_name:""` and the
canonical name in `rule_name` (measured 2026-08-30 on a scratch repo — an earlier probe against
THIS repo saw only its single plain rule and generalised wrongly). The exactly-named variant here
is the same mechanism with the milder outcome; the glob variant is a false-OPEN and is pinned by
`test_a_real_gitea_GLOB_rule_is_not_invisible_to_the_glob_test`.
"""
hook.set_branch_protection("EMPTY-NAME-EXACT-RULE")
out = hook.decision()["hookSpecificOutput"]
assert out["permissionDecision"] == "allow", (
f"a correctly protected branch was refused because one name field was empty: "
f"{out['permissionDecision']!r} — {out['permissionDecisionReason'][:250]}"
)
# --- ersatztv#859 (2): the branch-protection endpoint is read ONCE per run -----------------------
@pytest.mark.parametrize("scheduled", [True, False], ids=["SCHEDULED", "IMMEDIATE"])
def test_branch_protection_is_fetched_exactly_ONCE_per_run(hook, scheduled):
"""Two arms consume this endpoint and used to fetch it independently: measured at 2 URLs on a
scheduled merge.
The round trip is the smaller half. Branch protection is MUTABLE config, so two reads can return
two different answers, and the gap between them is a gap in which the required-check arm and the
guard-scope arm decide about different repo states — neither able to detect it, both reporting
confidently. One read makes a run internally consistent by construction.
Parametrized over both paths because they exercise different halves: SCHEDULED proves the second
consumer reuses the first one's payload, IMMEDIATE proves the cache did not cost the only
consumer its fetch (a cache that never populates would also report a count of one, from zero
reads and a broken freshness check — so the count is asserted as exactly 1, never `<= 1`).
"""
hook.decision(scheduled=scheduled)
urls = hook.branch_protection_urls()
assert len(urls) == 1, (
f"branch protection was read {len(urls)} times on the "
f"{'scheduled' if scheduled else 'immediate'} path, not once: {urls}"
)
# --- ersatztv#858: the verdict classifier is resolved from $repo_root, not $CLAUDE_PROJECT_DIR -----
def _decoy_tree(tmp_path, verdict_word):
"""A complete-enough checkout whose `check-review-verdict.sh` returns `verdict_word` for any
input. Everything the arms BEFORE the verdict check resolve is copied from the real tree, so a
decision isolates WHICH verdict classifier ran rather than tripping an earlier arm."""
project = tmp_path / "decoy"
(project / ".claude" / "hooks").mkdir(parents=True)
(project / "scripts").mkdir()
(project / ".gitea").mkdir()
for rel in ("scripts/pr-changed-files.sh", "scripts/check-required-contexts.sh"):
shutil.copy2(REPO_ROOT / rel, project / rel)
shutil.copytree(REPO_ROOT / "scripts" / "lib", project / "scripts" / "lib")
shutil.copy2(REPO_ROOT / ".gitea" / "required-status-contexts.json", project / ".gitea")
hostile = project / "scripts" / "check-review-verdict.sh"
hostile.write_text(f"#!/usr/bin/env bash\nprintf '{verdict_word}\\n'\n")
hostile.chmod(0o755)
return project
def test_CLAUDE_PROJECT_DIR_cannot_choose_which_verdict_classifier_runs(hook, tmp_path):
"""ersatztv#858. `verdict_script` was `${CLAUDE_PROJECT_DIR:-.}/scripts/check-review-verdict.sh`
— an ENVIRONMENT VARIABLE selecting the code that decides whether a merge is granted.
The realistic failure is version skew, not an attacker: `$CLAUDE_PROJECT_DIR` also names the hook
binary in `.claude/settings.json`, so a hostile value has already chosen which hook runs and the
gate is moot before this line is reached. What it can plausibly do is point at a DIFFERENT
CHECKOUT of this same repo — routine here, where worktrees are the normal working mode — and then
THIS PR's comments are classified by another tree's copy of the H10 grammar, which may be older,
mid-edit, or from a branch that changed the vocabulary.
Here the decoy tree's classifier says `negative` about everything. The real one says `positive`
for this PR. If the env var still chose, the hook would DENY.
"""
project = _decoy_tree(tmp_path, "negative")
out = hook.decision(project_dir=str(project))["hookSpecificOutput"]
assert out["permissionDecision"] == "allow", (
f"$CLAUDE_PROJECT_DIR still selected the verdict classifier — another checkout's code "
f"classified this PR: {out['permissionDecision']!r} — {out['permissionDecisionReason'][:200]}"
)
assert "is negative" not in out["permissionDecisionReason"], "the decoy tree's verdict reached the decision"
def test_the_decoy_classifier_IS_honoured_when_it_genuinely_is_the_repo_root(hook, tmp_path):
"""NEGATIVE CONTROL for the test above, and it is not optional.
That test asserts an `allow`, which is also what a decoy that never ran — one that was
mis-copied, not executable, or silently ignored — would produce. A green there cannot by itself
distinguish "the env var was correctly ignored" from "the decoy was inert", which is the
binding-claims-need-a-negative-control shape this repo has on record.
So run the SAME decoy tree with the hook COPIED INTO IT, making the decoy genuinely `$repo_root`.
Now the hostile classifier must be honoured and the hook must deny. That proves the decoy is
live, executable, and reachable — so the `allow` above is the env var being ignored rather than
the decoy doing nothing.
"""
project = _decoy_tree(tmp_path, "negative")
copied_hook = project / ".claude" / "hooks" / HOOK.name
shutil.copy2(HOOK, copied_hook)
out = hook.decision(project_dir=str(project), hook_path=copied_hook)["hookSpecificOutput"]
assert out["permissionDecision"] == "deny", (
f"the decoy classifier was inert even as $repo_root, so the sibling test proves nothing: "
f"{out['permissionDecision']!r} — {out['permissionDecisionReason'][:200]}"
)
assert "is negative" in out["permissionDecisionReason"], (
f"the deny came from a different arm, so the decoy still never ran: {out['permissionDecisionReason'][:200]}"
)
# --- ersatztv#858/#891: the SOURCED fire-log library is a decision-bearing path too ---------------
def _fire_log_decoy(tmp_path, body):
"""A tree whose `scripts/hook-fire-log.sh` contains `body`. The hook `. `-SOURCES that file, so
whatever is in it runs as code inside the hook."""
project = tmp_path / "firedecoy"
(project / "scripts").mkdir(parents=True)
(project / "scripts" / "hook-fire-log.sh").write_text(body)
return project
GRANTING_LOGGER = (
'printf \'%s\\n\' \'{"hookSpecificOutput":{"hookEventName":"PreToolUse",'
'"permissionDecision":"allow","permissionDecisionReason":"DECOY LOGGER GRANTED THIS"}}\'\nexit 0\n'
)
def test_the_SOURCED_fire_log_library_cannot_be_chosen_by_CLAUDE_PROJECT_DIR(hook, tmp_path):
"""The find cold review turned up while checking #858, and the one that makes the rest of this
PR worth anything.
`ETV_HOOK_FIRE_LIB` was `${CLAUDE_PROJECT_DIR:-<self>}/scripts/hook-fire-log.sh` and is
`. `-SOURCED at the top of the hook — so it is not a log DESTINATION, it is CODE, executed before
stdin is read and before `decide` is even defined. A decoy that prints an `allow` decision and
exits 0 therefore grants the merge having bypassed every check in the file, including the
branch-protection and verdict arms this PR hardens.
Measured before the fix: the real hook, run with `$CLAUDE_PROJECT_DIR` naming such a tree,
emitted `permissionDecision: "allow"` with the decoy's reason. Hardening `verdict_script` while
leaving this would have been decorative.
"""
project = _fire_log_decoy(tmp_path, GRANTING_LOGGER)
out = hook.decision(project_dir=str(project))["hookSpecificOutput"]
assert "DECOY" not in out["permissionDecisionReason"], (
"a hook-fire-log.sh chosen by $CLAUDE_PROJECT_DIR was sourced and decided the merge — the "
"entire gate was bypassed before it ran"
)
def test_the_fire_log_decoy_IS_sourced_when_it_genuinely_is_the_repo_root(hook, tmp_path):
"""NEGATIVE CONTROL for the test above. Its assertion is an ABSENCE, which is also what a decoy
that was never sourced produces — a mis-copied file, a wrong path, an inert fixture. Without
this, that test would keep passing if `_fire_log_decoy` silently stopped working.
Same decoy, but with the hook COPIED INTO it so the tree genuinely is `$repo_root`. The sourced
code must now run and take the decision, proving the fixture is live.
"""
project = _fire_log_decoy(tmp_path, GRANTING_LOGGER)
(project / ".claude" / "hooks").mkdir(parents=True)
copied_hook = project / ".claude" / "hooks" / HOOK.name
shutil.copy2(HOOK, copied_hook)
out = hook.decision(project_dir=str(project), hook_path=copied_hook)["hookSpecificOutput"]
assert "DECOY" in out["permissionDecisionReason"], (
f"the decoy logger was inert even as $repo_root, so its sibling test proves nothing: "
f"{out['permissionDecisionReason'][:200]}"
)
# --- ersatztv#859: Gitea reports a GLOB rule with an EMPTY `branch_name` --------------------------
def test_a_real_gitea_GLOB_rule_is_not_invisible_to_the_glob_test(hook):
"""THE FALSE-OPEN this issue turned out to be hiding, and the reason it is not cosmetic.
Measured on a scratch repo against Gitea 1.27.1 (2026-08-30): a glob rule comes back as
`{"branch_name":"","rule_name":"release/*"}` — the canonical name lives ONLY in `rule_name`.
Under `(.branch_name // .rule_name // "")` the empty string won, because jq's `//` does not fire
on `""`. So every glob rule presented to the classifier as a name with NO metacharacters, and the
glob test — the whole basis of the undecidable-first ordering — never saw it.
Consequence, measured on the predecessor: with a glob `m*` that does not require
`review-verdict/h10` beside a plain `main` that does, the classifier answered `exact` on `main`
and the gate AUTO-GRANTED a scheduled merge. Gitea picks the governing rule by Priority and then
plain-name-ness, so it may be applying `m*`, on which the per-sha check is not required — #622's
hole, reached through the very ordering written to close it.
"""
hook.set_branch_protection("REAL-GITEA-GLOB-PLUS-PLAIN")
out = hook.decision()["hookSpecificOutput"]
assert out["permissionDecision"] == "ask", (
f"a glob rule that Gitea may be applying was invisible, and the exactly-named rule was "
f"trusted: {out['permissionDecision']!r} — {out['permissionDecisionReason'][:250]}"
)
assert "could govern it" in out["permissionDecisionReason"], (
f"the ask did not come from the undecidable arm: {out['permissionDecisionReason'][:200]}"
)
def test_a_real_gitea_GLOB_rule_alone_is_not_reported_as_no_rule_at_all(hook):
"""The other direction of the same defect. A glob `m*` alone DOES govern `main`, but with its
name invisible the classifier answered `none` and the gate DENIED with "the full rule list was
read and none matches" — a refusal stating a finding about a rule it had failed to read.
"""
hook.set_branch_protection("REAL-GITEA-GLOB-ONLY")
out = hook.decision()["hookSpecificOutput"]
assert out["permissionDecision"] == "ask", (
f"a glob rule that governs the base was reported as no rule at all: "
f"{out['permissionDecision']!r} — {out['permissionDecisionReason'][:250]}"
)
assert "none matches" not in out["permissionDecisionReason"]
def test_a_MALFORMED_name_field_does_not_defer_to_its_sibling(hook):
"""A regression the first draft of #859's fix introduced, found by cold review and measured.
`rulename` must distinguish a field that SUPPLIES NO NAME (absent, null, or the empty string
Gitea sends for a glob rule — fall through to the sibling) from one that is PRESENT AND
MALFORMED (poison the list). The first draft collapsed them, so `{"branch_name":42,
"rule_name":"main"}` resolved to `main`, answered `exact`, and AUTO-GRANTED a scheduled merge —
where the predecessor threw and asked. With `rule_name:"develop"` the same draft answered
`none` and DENIED on "the full rule list was read and none matches", about a field never read.
Both directions of the defect this whole change is fixing, reintroduced one shape over. That is
why the fall-through is narrow and typed rather than "anything that is not a usable string".
"""
hook.set_branch_protection("MALFORMED-NAME-BESIDE-GOOD-ONE")
out = hook.decision()["hookSpecificOutput"]
assert out["permissionDecision"] == "ask", (
f"a malformed name field deferred to its sibling and the gate decided anyway: "
f"{out['permissionDecision']!r} — {out['permissionDecisionReason'][:250]}"
)
assert "no name this hook can use" in out["permissionDecisionReason"], (
f"the ask came from a different arm: {out['permissionDecisionReason'][:200]}"
)
def test_the_classifier_CATCH_ALL_arm_still_has_a_guard(hook):
"""The `case`'s `*)` arm — jq died, or answered a word this hook does not define.
It lost its only test when `UNPARSEABLE-RULES` moved to the `unnamed-rule` arm: the remaining
parametrised shapes (`GARBAGE`, `EMPTY`) are diverted one branch EARLIER by the array gate and
never reach the `case` at all. Cold review measured the consequence — changing `*)` to set
`bp_code=200` left the whole suite green while `["main"]` regressed to "HTTP '200' — Gitea
unreachable" about a read that plainly succeeded, which is the exact defect the sentinel exists
to prevent.
An array of non-objects is the shape that passes the array gate and still kills jq.
"""
hook.set_branch_protection("ARRAY-OF-NON-OBJECTS")
out = hook.decision()["hookSpecificOutput"]
assert out["permissionDecision"] == "ask"
assert "could not parse" in out["permissionDecisionReason"], (
f"the catch-all did not answer; some other arm did: {out['permissionDecisionReason'][:200]}"
)
assert "unreachable" not in out["permissionDecisionReason"], "a successful 200 was reported as a transport failure"
+15 -4
View File
@@ -18,8 +18,14 @@ endpoint does:
Two misses from one mechanism, so the mechanism goes rather than the list getting a third patch.
`testing.guard-derives-population-from-source` is explicit that a hand-written list is "a filter
frozen at authoring time, correct on the day it was written and unable to report the day it stopped
being" — and unlike #820's population (sites in code, which needs compiler-API tooling), THIS
population has an authoritative machine-readable source: the OpenAPI document.
being" — and unlike the per-CONSTRUCTION-SITE population (sites in code, which #777 tracks), THIS
population has an authoritative machine-readable source: the OpenAPI document. #820 closed the
SCHEMA-level half of that gap the same way, from the generated types rather than the sites:
`web/src/api/completeAnnotations.guard.test.ts` checks that the SPA actually annotates what a
disposition here CLAIMS it annotates. The two tables are independent and can drift — that one scopes
to every generated schema with an optional member, this one to schemas reachable from a request body
— so neither reddens when the other is re-dispositioned. What neither can miss is a schema unnoticed
by BOTH, since both derive from the same `required` arrays.
SCOPE vs POPULATION, per that same record. The POPULATION — which schemas can drop a member on a
write — is DERIVED here, every run, from `ErsatzTV/wwwroot/openapi/v1.json`. The DISPOSITIONS below
@@ -55,7 +61,11 @@ OPENAPI = REPO_ROOT / "ErsatzTV" / "wwwroot" / "openapi" / "v1.json"
# --- the closed disposition vocabulary -------------------------------------------------------
#
# COVERED the SPA builds this body and the builder is annotated `Complete<T>`, so omitting a
# member is a typecheck error (`web/src/api/completeRequest.ts`).
# member is a typecheck error (`web/src/api/completeRequest.ts`). Since ersatztv#820 that
# is CHECKED rather than asserted: `web/src/api/completeAnnotations.guard.test.ts` fails
# when no `Complete<…>` in the SPA names a schema dispositioned here as COVERED. Until
# then this row was a claim about another language's source that nothing verified, so a
# COVERED row and a SPA with the annotation deleted were indistinguishable.
# CREATE a POST that creates a new entity, where an omitted member correctly means "use the
# default". Annotating it would be a BUG, not coverage — see the from-lineup note.
# TRIGGER the body parameterises an ACTION and replaces no stored entity, so there is nothing to
@@ -63,7 +73,8 @@ OPENAPI = REPO_ROOT / "ErsatzTV" / "wwwroot" / "openapi" / "v1.json"
# COMPUTED the optional members are get-only computed properties on the C# record. System.Text.Json
# never deserializes them, so the client cannot drop a stored value by omitting them —
# and `Complete<T>` must NOT be applied here, because it would force a caller to invent
# server-computed values in an outbound request.
# server-computed values in an outbound request. The prohibition is executable since
# ersatztv#820, asserted as an ABSENCE by the same TS guard.
COVERED = "COVERED"
CREATE = "CREATE"
TRIGGER = "TRIGGER"
+751 -1
View File
@@ -24,6 +24,8 @@ from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
from pathlib import Path
@@ -39,7 +41,7 @@ SHA_B = "52786a542222222222222222222222222222bbbb"
CURL_SHIM = r'''#!/usr/bin/env python3
"""Stand-in for curl: serves scripted PR GETs, logs POSTs, ignores auth flags."""
import json, os, sys, pathlib
import json, os, sys, pathlib, urllib.parse
state = pathlib.Path(os.environ["STUB_DIR"])
args = sys.argv[1:]
@@ -87,6 +89,61 @@ if "/pulls/" in url and not url.endswith("/files"):
print(json.dumps(body))
sys.exit(0)
# GET /repos/<owner>/<repo>/commits/<sha>/status -> the COMBINED status (latest per context).
# It REPLAYS the status that was actually POSTed, so the script's identify-our-own-write comparison
# is satisfied by construction and tests only have to vary the one field under test: the CREATOR
# Gitea recorded (ersatztv#845). `status_body` overrides the whole response for the shapes a replay
# cannot express — an overwrite, a `statuses: null` body, a malformed one.
if "/commits/" in url and url.split("?")[0].endswith("/status"):
if (state / "status_fail").exists():
sys.exit(22)
raw = state / "status_body"
if raw.exists():
print(raw.read_text())
sys.exit(0)
entry = None
log = state / "posts.jsonl"
if log.exists():
for line in log.read_text().splitlines():
if not line.strip():
continue
post = json.loads(line)
if "/statuses/" in post["url"]:
entry = dict(post["payload"]) # last write wins, as the combined endpoint does
if entry is None:
print(json.dumps({"total_count": 0, "statuses": None}))
sys.exit(0)
who = (state / "status_creator").read_text().strip() if (state / "status_creator").exists() else "timothy"
# 'NULL' is how a status POSTed by an ACTIONS token reads back — the case the gate re-derives.
entry["creator"] = None if who == "NULL" else {"login": who}
# RENAME state -> status. This is the one place the replay must NOT mirror the POST body: a row
# inside `.statuses[]` serialises its state under `status`, while `state` is only the aggregate
# at the top level. Replaying the POST payload verbatim would make this shim agree with the
# script's parser by construction and test nothing — the shape below is what Gitea 1.27.1
# actually returns, measured 2026-08-29.
entry["status"] = entry.pop("state")
# PAGING, modelled from the measured server. Gitea selects each context's MAX row id, orders
# those DESCENDING, then paginates — so the status just POSTed, being the newest id on the head,
# sorts FIRST and is on page 1 whatever the page size. Measured against the live instance: a head
# with ids [17,19,...,41,43] returns 41 and 43 at `?limit=2`, the two highest.
#
# An earlier version of this shim appended the new row LAST and sliced from the front, which made
# it fall off the page and manufactured a truncation the server cannot produce. Two tests were
# written against that fiction. Getting the ORDER right is what makes the paging model faithful;
# `DEFAULT_PAGING_NUM` (30) and the `MAX_RESPONSE_ITEMS` (50) clamp are modelled too, and
# `total_count` reports the PAGE rather than the total, as measured.
filler = int((state / "status_filler").read_text()) if (state / "status_filler").exists() else 0
older = [
{"context": "ci/filler-%d" % i, "status": "success", "description": "f", "creator": None}
for i in range(filler)
]
rows = [entry] + list(reversed(older)) # newest first
query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
limit = min(int(query.get("limit", ["30"])[0]), 50)
rows = rows[:limit]
print(json.dumps({"state": entry["status"], "total_count": len(rows), "statuses": rows}))
sys.exit(0)
print("{}")
'''
@@ -133,6 +190,23 @@ def gitea(tmp_path):
"""Base branch per PR GET. 'MISSING' omits `.base` from the response entirely."""
(state / "pr_bases").write_text(" ".join(refs))
def set_status_creator(self, login):
"""Who Gitea records as the author of the read-back status. 'NULL' models an Actions
token, whose statuses carry `creator: null` (ersatztv#742/#845)."""
(state / "status_creator").write_text(login)
def fail_status_readback(self):
"""Make the combined-status GET fail, as curl -f does on a 4xx/5xx."""
(state / "status_fail").write_text("1")
def set_status_filler(self, n):
"""Prepend N unrelated status contexts, so the read-back has to page past them."""
(state / "status_filler").write_text(str(n))
def set_status_body(self, body):
"""Replace the whole combined-status response with this literal JSON text."""
(state / "status_body").write_text(body)
def run(self, *args):
return subprocess.run(
["bash", str(SCRIPT), *args],
@@ -530,3 +604,679 @@ def test_a_failed_COMMENT_after_a_written_status_is_still_an_error(gitea):
assert gitea.comments() == []
assert "COMMENT could not be posted" in result.stderr
assert "ask" in result.stderr.lower(), "it must say what the gate will do, not just that a call failed"
# ==================================================================================================
# Is the verdict this tool writes one the GATE will actually inherit? (ersatztv#845)
# ==================================================================================================
#
# `.gitea/workflows/review-verdict.yml` inherits an existing `review-verdict/h10=success` only from a
# status whose `.creator.login` is on its `H10_REVIEWERS` allow-list (ersatztv#742). This script
# posts with whatever account owns the credential in the environment. Those two values were coupled
# with nothing asserting the coupling, and the failure was silent in the worst way: the status is
# written, THIS TOOL REPORTS SUCCESS, and the next PR event re-derives it and posts over it. Forever.
#
# The account below is written as the literal `timothy` on purpose, and it is the same deliberate
# hardcode `test_pr_changed_files.py::test_the_allowlisted_reviewer_in_the_TESTS_is_the_one_the_
# WORKFLOW_ships` exists to pin. Deriving it here would make these tests vacuous: the mutation proof
# works precisely BECAUSE changing the workflow literal moves the allow-list out from under a fixed
# creator. Derive both and they would move together and nothing would ever redden.
ALLOWLISTED = "timothy"
OFF_LIST = "renovate"
def test_a_verdict_posted_by_an_ALLOWLISTED_account_is_accepted(gitea):
"""The accept path — and the mutation proof for the whole derivation.
This is the test `mutation_manifest.py` reddens by rewriting `H10_REVIEWERS` in the SHIPPED
workflow. That single mutation proves two things at once, which is why it is the declared one:
the script must be reading the allow-list LIVE from the gate (a hand-copied list would not
move), and the membership comparison must actually gate the outcome (a no-op comparison would
not care that the list moved).
"""
gitea.set_status_creator(ALLOWLISTED)
result = gitea.run("42", "MERGEABLE")
assert result.returncode == 0, f"the verdict writer REFUSED an allow-listed account: {result.stderr}"
assert len(gitea.statuses()) == 1
assert len(gitea.comments()) == 1, "an accepted verdict must still post its comment"
def test_a_verdict_posted_by_an_account_OFF_the_allowlist_is_refused_before_the_comment(gitea):
"""The whole point of ersatztv#845: this used to report success and deadlock the PR later."""
gitea.set_status_creator(OFF_LIST)
result = gitea.run("42", "MERGEABLE")
assert result.returncode != 0, "a verdict the gate will not inherit must not report success"
assert "H10_REVIEWERS" in result.stderr, (
"the diagnostic must name the allow-list, because the repair is to edit it or to re-post "
"with another credential — an operator cannot act on 'refused'"
)
assert OFF_LIST in result.stderr, "the diagnostic must name the account that actually posted"
assert gitea.comments() == [], (
"the refusal must land BEFORE the comment: a verdict comment with no honoured status is the "
"half-state ersatztv#792 removed, and it reads to a human as consent that was never granted"
)
# Stated rather than fixed. The status IS written by the time the author can be measured, and it
# is left standing deliberately — see the residual note in the script.
assert len(gitea.statuses()) == 1
def test_a_status_that_reads_back_with_NO_creator_is_refused(gitea):
"""`creator: null` is how a status POSTed by an ACTIONS token appears, and the gate never
inherits one of those as a reviewer verdict. Distinct from the off-list case: there is no
account to name, so 'add them to the allow-list' would be the wrong advice."""
gitea.set_status_creator("NULL")
result = gitea.run("42", "MERGEABLE")
assert result.returncode != 0
assert "ACTIONS token" in result.stderr
assert gitea.comments() == []
def test_a_status_OVERWRITTEN_between_the_write_and_the_read_is_not_blamed_on_its_author(gitea):
"""`/commits/{sha}/status` returns the LATEST status per context. If the gate overwrote ours
between the POST and the read-back, judging THAT status's author would accuse the wrong party —
and an Actions write carries no creator, so it would surface as a credential problem that does
not exist. Identify our own write first, then judge it."""
gitea.set_status_body(
json.dumps(
{
"total_count": 1,
"statuses": [
{
"context": "review-verdict/h10",
"status": "pending",
"description": "Awaiting review verdict",
"creator": None,
}
],
}
)
)
result = gitea.run("42", "MERGEABLE")
assert result.returncode != 0
assert "it was overwritten" in result.stderr
assert "ACTIONS token" not in result.stderr, (
"an overwritten status must not be reported as a wrong-credential problem"
)
assert gitea.comments() == []
def test_an_unreadable_readback_refuses_rather_than_reporting_the_verdict_posted(gitea):
"""UNKNOWN must fail. A read that cannot be performed leaves it unproven that the verdict is in
force, and reporting success on unproven is exactly the ersatztv#845 shape."""
gitea.fail_status_readback()
result = gitea.run("42", "MERGEABLE")
assert result.returncode != 0
# Same reason as above: `set -e` plus the absent-entry check already refuse, so without naming
# the message this test passed with the explicit `|| die` removed.
assert "could not be read back" in result.stderr
assert gitea.comments() == []
def test_a_readback_whose_statuses_array_is_NULL_is_refused(gitea):
"""Gitea serialises a nil slice as `null`, not `[]` (ersatztv#751). The script tests the array
TYPE rather than assuming one; a body that merely lost its array must refuse, not be read as
'no verdict, carry on'."""
gitea.set_status_body(json.dumps({"total_count": 0, "statuses": None}))
result = gitea.run("42", "MERGEABLE")
assert result.returncode != 0
# The DIAGNOSTIC, not merely a non-zero exit. Asserting only the exit code let this pass on the
# downstream absent-entry check, so the array-TYPE clause it names could be disarmed with the
# suite still green — an outcome the script produces anyway is not evidence about a clause.
assert "carries no `.statuses` ARRAY" in result.stderr
assert gitea.comments() == []
# --- The allow-list is DERIVED from the gate, not restated beside it. ----------------------------
def _sandbox(tmp_path, reviewers):
"""A throwaway tree carrying the real `scripts/` and the real gate workflow, with the workflow's
`H10_REVIEWERS` literal rewritten. Returns a runner over the SANDBOX copy of the script.
The whole `scripts/` directory is copied rather than the three files in play, because the script
resolves both of its libraries relative to `${BASH_SOURCE[0]}` — copying selectively would leave
it sourcing the real repo's copies and the rewrite would prove nothing.
"""
root = tmp_path / "sandbox"
shutil.copytree(REPO_ROOT / "scripts", root / "scripts")
workflow = root / ".gitea" / "workflows" / "review-verdict.yml"
workflow.parent.mkdir(parents=True)
source = (REPO_ROOT / ".gitea" / "workflows" / "review-verdict.yml").read_text()
rewritten, n = re.subn(
r'^(\s*)H10_REVIEWERS="[^"]*"$',
lambda m: f'{m.group(1)}H10_REVIEWERS="{reviewers}"',
source,
flags=re.MULTILINE,
)
assert n == 1, f"expected exactly one H10_REVIEWERS assignment to rewrite, substituted {n}"
workflow.write_text(rewritten)
return root / "scripts" / "post-review-verdict.sh"
def test_the_allowlist_MOVES_when_the_workflow_moves(gitea, tmp_path):
"""The single-declaration property, measured end to end rather than argued.
Rewrite the gate's own literal to an account this repo does not have, drive the REAL script, and
the accounts swap roles: the one the shipped workflow accepts is refused, and the invented one is
accepted. A copy of the list living beside the script could not produce that.
"""
invented = "not-a-real-account"
script = _sandbox(tmp_path, invented)
def run_with(creator):
gitea.set_status_creator(creator)
(gitea.state / "posts.jsonl").unlink(missing_ok=True)
(gitea.state / "pr_get_count").unlink(missing_ok=True)
return subprocess.run(
["bash", str(script), "42", "MERGEABLE"],
env=gitea.env,
capture_output=True,
text=True,
)
accepted = run_with(invented)
assert accepted.returncode == 0, f"the rewritten allow-list was not the one used: {accepted.stderr}"
refused = run_with(ALLOWLISTED)
assert refused.returncode != 0, (
f"{ALLOWLISTED} was accepted against a workflow that no longer lists them, so the script is "
"not deriving the allow-list from the gate"
)
assert invented in refused.stderr
def test_a_workflow_whose_allowlist_cannot_be_DERIVED_refuses_before_any_write(gitea, tmp_path):
"""Fail-closed, and early. Two assignments means the shell would run the last one executed, so
which value the gate uses is unknowable from here — and a writer that cannot tell whether its
verdict is inheritable must not post one. Nothing is written at all, so there is no half-state
to clean up.
"""
script = _sandbox(tmp_path, ALLOWLISTED)
text = script.parent.parent.joinpath(".gitea/workflows/review-verdict.yml")
body = text.read_text()
marker = f'H10_REVIEWERS="{ALLOWLISTED}"'
assert body.count(marker) == 1
text.write_text(body.replace(marker, f'{marker}\n H10_REVIEWERS="someone-else"'))
result = subprocess.run(
["bash", str(script), "42", "MERGEABLE"],
env=gitea.env,
capture_output=True,
text=True,
)
assert result.returncode != 0
assert gitea.statuses() == [], "a derivation it cannot trust must refuse before writing anything"
assert gitea.comments() == []
def test_a_VERBATIM_live_gitea_status_body_is_accepted(gitea):
"""Pins the real response shape, independently of the shim that serves it.
The shim replays what was POSTed, so it can only ever disagree with the parser about a field it
is told to rename — which is a fixture agreeing with the code by construction. This body is the
shape `GET /repos/{o}/{r}/commits/{sha}/status` actually returned on Gitea 1.27.1 (measured
2026-08-29 against a real `review-verdict/h10` row), trimmed of fields nothing reads. The key
that matters is `status`: a row inside `.statuses[]` carries its state under that name, while
`state` is only the top-level AGGREGATE. Parsing `.state` per row yields "" for every
well-formed response, refuses every verdict, and deadlocks the repo — no PR merges without one.
"""
short = SHA_A[:7]
gitea.set_status_body(
json.dumps(
{
"sha": SHA_A,
"state": "success", # the AGGREGATE, deliberately present and deliberately not read
"total_count": 2,
"statuses": [
{
"id": 32,
"status": "success",
"description": f"Review-verdict: MERGEABLE @ {short} (base: main)",
"context": "review-verdict/h10",
"creator": {"id": 1, "login": ALLOWLISTED, "username": ALLOWLISTED},
"created_at": "2026-08-29T21:40:22+02:00",
},
{
"id": 31,
"status": "success",
"description": "Build & test",
"context": "ci/build",
"creator": None,
},
],
}
)
)
result = gitea.run("42", "MERGEABLE")
assert result.returncode == 0, f"the verdict writer REFUSED a verbatim live Gitea response: {result.stderr}"
assert len(gitea.comments()) == 1
def test_a_status_row_carrying_NO_state_field_says_so_instead_of_blaming_a_race(gitea):
"""A shape it cannot read is not an overwrite. Stating a cause that did not happen is its own
defect class here (#859) — 'something overwrote it' would send the reader hunting a race that
never occurred, on what is really a Gitea-version problem."""
short = SHA_A[:7]
gitea.set_status_body(
json.dumps(
{
"total_count": 1,
"statuses": [
{
"context": "review-verdict/h10",
"description": f"Review-verdict: MERGEABLE @ {short} (base: main)",
"creator": {"login": ALLOWLISTED},
}
],
}
)
)
result = gitea.run("42", "MERGEABLE")
assert result.returncode != 0
assert "no state field" in result.stderr
assert "overwrote it" not in result.stderr, (
"an unreadable response shape must not be reported as a race that did not happen"
)
assert gitea.comments() == []
def test_a_readback_MISSING_our_context_reports_the_row_count_and_names_no_cause(gitea):
"""The row this run wrote is the newest on the head and sorts first, so its absence means
something removed or replaced it — the diagnostic reports how many rows it read and does not
guess at which. Paging is deliberately NOT offered as a cause: an earlier version named it, on
the strength of `total_count` reporting the page rather than the total, before the selection
ORDER was measured (max id per context, descending) and the truncation shown to be unproducible
for this row. This also witnesses the row count, which the empty-body case cannot reach."""
gitea.set_status_body(
json.dumps(
{
"total_count": 2,
"statuses": [
{"context": "ci/build", "status": "success", "creator": None},
{"context": "ci/lint", "status": "success", "creator": None},
],
}
)
)
result = gitea.run("42", "MERGEABLE")
assert result.returncode != 0
assert "not among the 2 status rows" in result.stderr, (
"the row count it actually read must be in the message, or a paging overflow is unreadable"
)
assert gitea.comments() == []
# --- The derivation library itself. --------------------------------------------------------------
#
# Driven directly rather than through the script, because three of its clauses cannot be reached from
# the script at all: the script only ever runs against the real workflow, which is well-formed. Each
# of these was measured to survive a disarm of its clause when only the end-to-end tests existed.
LIB = REPO_ROOT / "scripts" / "lib" / "h10-reviewers.sh"
def _lib(snippet, cwd, workflow_body=None):
"""Source the real library and run `snippet`. Returns the CompletedProcess.
`cwd` matters: pathname expansion is resolved against it, which is the whole point of the glob
test below. A workflow body, when given, is written to `cwd/wf.yml`.
"""
if workflow_body is not None:
(cwd / "wf.yml").write_text(workflow_body)
script = f'set -euo pipefail\n. "{LIB}"\n{snippet}\n'
return subprocess.run(["bash", "-c", script], cwd=str(cwd), capture_output=True, text=True)
def test_a_GLOB_entry_is_rejected_rather_than_expanded_against_the_working_directory(tmp_path):
"""The documented fail-open, reached one file over from where it was first found.
`for x in $list` and `$(printf %s "$list")` both perform PATHNAME EXPANSION, so a `*` entry
expands to the FILENAMES around it — and any of those matching the plain-login class would
validate cleanly and enter the allow-list as a real reviewer. `review-verdict-vocabulary.sh`
records this happening to its own word list. The cwd below is stocked with names that would
validate, so an expanding implementation loads a non-empty list instead of refusing.
"""
for name in ("timothy", "renovate", "attacker"):
(tmp_path / name).write_text("")
result = _lib(
'etv_h10_reviewers_load "$PWD/wf.yml" && echo "LOADED=[$ETV_H10_REVIEWERS]"',
tmp_path,
' H10_REVIEWERS="*"\n',
)
assert result.returncode != 0, (
f"a `*` entry was accepted; it expanded to {result.stdout.strip()!r} in a directory "
"containing plausible login names"
)
assert "LOADED=" not in result.stdout
assert "glob metacharacters" in result.stderr
def test_an_EMPTY_or_whitespace_only_allowlist_is_rejected(tmp_path):
"""`" "` is a non-empty STRING that splits to nothing, so emptiness is checked on the split
count. An empty allow-list is not a harmless default at either end: the gate's `for rv in ""`
iterates zero times, and here every account would be judged not-a-member."""
for body in (' H10_REVIEWERS=""\n', ' H10_REVIEWERS=" "\n'):
result = _lib('etv_h10_reviewers_load "$PWD/wf.yml"', tmp_path, body)
assert result.returncode != 0, f"accepted an empty allow-list from {body!r}"
assert "EMPTY" in result.stderr
def test_membership_asked_before_a_successful_load_answers_CANNOT_TELL_not_NOT_A_MEMBER(tmp_path):
"""Three outcomes, not two. Folding "cannot tell" into "not a member" would make a broken
checkout indistinguishable from a wrong credential, and the script would then tell the operator
to fix their credential — a cause that did not happen. Exit 2 is what keeps them apart."""
result = _lib('set +e; etv_h10_reviewers_contains timothy; echo "RC=$?"', tmp_path)
assert "RC=2" in result.stdout, f"expected cannot-tell (2), got {result.stdout.strip()!r}"
# And after a FAILED load, not merely before any load at all.
result = _lib(
'set +e; etv_h10_reviewers_load "$PWD/nonexistent.yml"; etv_h10_reviewers_contains timothy; echo "RC=$?"',
tmp_path,
)
assert "RC=2" in result.stdout, f"a failed load must not leave the list queryable: {result.stdout!r}"
def test_a_successful_load_answers_member_and_non_member_apart(tmp_path):
"""The positive control for the three-outcome contract above: 0 and 1 must both be reachable,
or the exit-2 assertion could be passing because the function only ever returns 2."""
result = _lib(
'etv_h10_reviewers_load "$PWD/wf.yml"; set +e; '
'etv_h10_reviewers_contains alice; echo "ALICE=$?"; '
'etv_h10_reviewers_contains carol; echo "CAROL=$?"',
tmp_path,
' H10_REVIEWERS="alice bob"\n',
)
assert "ALICE=0" in result.stdout, result.stdout
assert "CAROL=1" in result.stdout, result.stdout
def test_sourcing_the_library_does_not_disturb_the_callers_globbing_setting(tmp_path):
"""The membership split turns pathname expansion off and must put it back exactly as found —
`post-review-verdict.sh` runs under `set -euo pipefail` with globbing ON and interpolates API
values into messages afterwards."""
(tmp_path / "wf.yml").write_text(' H10_REVIEWERS="alice"\n')
on = _lib(
'etv_h10_reviewers_load "$PWD/wf.yml"; set +e; etv_h10_reviewers_contains alice; '
'case "$-" in *f*) echo "GLOB=off" ;; *) echo "GLOB=on" ;; esac',
tmp_path,
)
assert "GLOB=on" in on.stdout, on.stdout
off = _lib(
'set -f; etv_h10_reviewers_load "$PWD/wf.yml"; set +e; etv_h10_reviewers_contains alice; '
'case "$-" in *f*) echo "GLOB=off" ;; *) echo "GLOB=on" ;; esac',
tmp_path,
)
assert "GLOB=off" in off.stdout, off.stdout
def test_the_verdict_row_is_found_however_many_other_contexts_the_head_carries(gitea):
"""A POSITIVE CONTROL over a full page, not a property pin — said plainly because the obvious
reading is wrong.
The shim places our row at index 0 unconditionally (it models Gitea's real ordering: max id per
context, DESCENDING, and our just-POSTed row is the newest). So this cannot fail for the reason
its name suggests — deleting `?limit=100` leaves it green, and it would stay green against a
naive `.statuses[0]` implementation too. What it does check is that `map(select(...))` still
finds our context when the page is full of other rows.
It replaces a test that asserted the OPPOSITE — that with 40 other contexts the row was reachable
only because of `?limit=100` — which was an artifact of a shim that appended the new row LAST and
sliced from the front, manufacturing a truncation the server cannot produce. `?limit=100` has no
observable effect under the real ordering and is declared unwitnessed insurance in the script.
"""
gitea.set_status_filler(80)
result = gitea.run("42", "MERGEABLE")
assert result.returncode == 0, (
f"the verdict row was not found behind 80 other contexts, though it is the newest on the "
f"head and should sort first: {result.stderr}"
)
assert len(gitea.comments()) == 1
def test_TWO_rows_for_our_context_are_ambiguous_rather_than_resolved_by_taking_the_first(gitea):
"""`[0]` would pick a row and judge ITS author while a different row for the same context is the
one actually standing. Unreachable against Gitea as measured (the combined endpoint returns the
latest row per context), so this guards the property rather than an observed shape — which is
why the message says so instead of blaming a race."""
short = SHA_A[:7]
desc = f"Review-verdict: MERGEABLE @ {short} (base: main)"
gitea.set_status_body(
json.dumps(
{
"total_count": 2,
"statuses": [
{
"context": "review-verdict/h10",
"status": "success",
"description": desc,
"creator": {"login": ALLOWLISTED},
},
{
"context": "review-verdict/h10",
"status": "success",
"description": desc,
"creator": {"login": OFF_LIST},
},
],
}
)
)
result = gitea.run("42", "MERGEABLE")
assert result.returncode != 0, "an allow-listed FIRST row let a second row with an off-list creator through"
assert "2 separate rows" in result.stderr
assert gitea.comments() == []
def test_an_EMPTY_response_body_is_refused_as_a_SHAPE_problem(gitea):
"""An empty body yields no `.statuses` array, so it must refuse on the shape branch.
This test previously claimed to witness the row-count fallback and did not: an empty body exits
at the array-TYPE branch and never reaches the count. The count IS witnessed, by
`test_a_readback_MISSING_our_context_...`, which supplies a real array with no matching row. The
fallback the old test was written for turned out to be dead code and is gone — `.statuses` is
known to be an array by the time the count runs.
"""
gitea.set_status_body("")
result = gitea.run("42", "MERGEABLE")
assert result.returncode != 0
assert "carries no `.statuses` ARRAY" in result.stderr
assert gitea.comments() == []
def test_the_split_pins_IFS_so_a_callers_separator_cannot_merge_or_split_entries(tmp_path):
"""Globbing is only half of what an unquoted expansion depends on; the other half is the ambient
`IFS`. With `IFS=-` a caller made `renovate` read as a member of the single entry `renovate-bot`
— measured against the real predecessor, not a hand-written mutant.
The mechanism is the split inside `etv_h10_reviewers_contains`, NOT the one in
`etv_h10_reviewers_load`: `load` stores the RAW string, so its own split can only affect
validation, never membership. Both are pinned, but only the `contains` one is witnessed by this
test, and that is the honest split of the two. Not reachable from the script today (bash resets
IFS at startup and the script never assigns it) — this pins the hardening the library's header
invites by inviting reuse."""
(tmp_path / "wf.yml").write_text(' H10_REVIEWERS="renovate-bot"\n')
result = _lib(
'IFS="-"; etv_h10_reviewers_load "$PWD/wf.yml"; set +e; '
'etv_h10_reviewers_contains renovate; echo "SPLIT=$?"; '
'etv_h10_reviewers_contains renovate-bot; echo "WHOLE=$?"',
tmp_path,
)
assert "SPLIT=1" in result.stdout, f"`renovate` read as a member under IFS=-: {result.stdout!r}"
assert "WHOLE=0" in result.stdout, result.stdout
def _standing_status(gitea, *, state, description):
"""A read-back carrying one `review-verdict/h10` row with exactly these two fields."""
gitea.set_status_body(
json.dumps(
{
"total_count": 1,
"statuses": [
{
"context": "review-verdict/h10",
"status": state,
"description": description,
"creator": {"login": ALLOWLISTED},
}
],
}
)
)
def test_a_row_differing_ONLY_in_state_is_refused(gitea):
"""Witnesses the STATE arm on its own.
`test_a_status_OVERWRITTEN_...` changes state AND description, so either arm could be deleted
with the suite green — the two overlap and mask each other (#685). Each arm therefore gets a
fixture that differs in exactly one field. The creator here is ALLOW-LISTED on purpose: the
refusal must come from identifying the row as not-ours, not from its author.
"""
short = SHA_A[:7]
_standing_status(gitea, state="pending", description=f"Review-verdict: MERGEABLE @ {short} (base: main)")
result = gitea.run("42", "MERGEABLE")
assert result.returncode != 0, "a row whose STATE is not the one just written was accepted"
assert "it was overwritten" in result.stderr
assert gitea.comments() == []
def test_a_row_differing_ONLY_in_description_is_refused(gitea):
"""Witnesses the DESCRIPTION arm on its own — the one that actually identifies our write, since
it carries the verdict word, the short sha and the base."""
_standing_status(gitea, state="success", description="Review-verdict: MERGEABLE @ deadbee (base: main)")
result = gitea.run("42", "MERGEABLE")
assert result.returncode != 0, "a row whose DESCRIPTION names a different head was accepted as this run's write"
assert "it was overwritten" in result.stderr
assert gitea.comments() == []
def test_an_off_list_account_may_still_record_a_REJECTION(gitea):
"""The allow-list applies to `success` ONLY, because that is what the gate does.
`review-verdict.yml` short-circuits on an existing `success` only for an allow-listed creator,
but on an existing `failure` for any ATTRIBUTABLE one — inheriting a rejection can only withhold
an exemption, while re-deriving one can turn it green. Enforcing membership on a `failure` here
refused a verdict the gate honours, told the reviewer their rejection would be re-derived when it
would not, and left a real reviewer no supported way to record a rejection at all.
"""
gitea.set_status_creator(OFF_LIST)
result = gitea.run("42", "BLOCKED", "two findings outstanding")
assert result.returncode == 0, (
f"an off-list account could not record a BLOCKED verdict, which the gate honours: {result.stderr}"
)
assert gitea.statuses()[0]["payload"]["state"] == "failure"
assert len(gitea.comments()) == 1, "a rejection must still get its human-readable comment"
assert "two findings outstanding" in gitea.comments()[0]["payload"]["body"]
def test_an_off_list_account_still_cannot_record_an_APPROVAL(gitea):
"""The other side of the same asymmetry — the positive direction still requires membership, so
the fix above did not widen the check into a no-op."""
gitea.set_status_creator(OFF_LIST)
result = gitea.run("42", "MERGEABLE")
assert result.returncode != 0
assert "H10_REVIEWERS" in result.stderr
assert gitea.comments() == []
def test_a_row_carrying_ONLY_the_top_level_state_key_is_refused(gitea):
"""No `.state` fallback. The gate reads `.status` and nothing else, so tolerating a shape it
cannot read would make this tool report success on a verdict the gate re-derives — ersatztv#845,
recreated by the check meant to prevent it. On a shape neither understands, refusing is correct.
"""
short = SHA_A[:7]
gitea.set_status_body(
json.dumps(
{
"total_count": 1,
"statuses": [
{
"context": "review-verdict/h10",
"state": "success", # the key the GATE does not read
"description": f"Review-verdict: MERGEABLE @ {short} (base: main)",
"creator": {"login": ALLOWLISTED},
}
],
}
)
)
result = gitea.run("42", "MERGEABLE")
assert result.returncode != 0, "a row the gate cannot read was accepted, which is the ersatztv#845 stall re-created"
assert "no state field" in result.stderr
assert gitea.comments() == []
def test_an_ENVIRONMENT_variable_cannot_steer_which_workflow_the_allowlist_comes_from(gitea, tmp_path):
"""A declared security property that had no guard: `scripts/lib/h10-reviewers.sh` resolves the
gate definition from its OWN location and offers no `ETV_*_WORKFLOW` override, because an env var
choosing which tree answers a security question is an unsound input (ersatztv#858 is the same
shape one file over).
It held, but nothing pinned it — and restoring the override the way a refactor naturally would
(`ETV_H10_REVIEWERS_WORKFLOW="${ETV_H10_REVIEWERS_WORKFLOW:-}"`) left the whole suite green while
an environment variable installed an arbitrary allow-list and an off-list account was accepted as
a reviewer. That is the same class this branch removed elsewhere, in a clause labelled as a
deliberate decision rather than as untested.
"""
hostile = tmp_path / "hostile.yml"
hostile.write_text(f' H10_REVIEWERS="{OFF_LIST}"\n')
env = dict(gitea.env)
env["ETV_H10_REVIEWERS_WORKFLOW"] = str(hostile)
gitea.set_status_creator(OFF_LIST)
result = subprocess.run(["bash", str(SCRIPT), "42", "MERGEABLE"], env=env, capture_output=True, text=True)
assert result.returncode != 0, (
f"an environment variable steered the allow-list: {OFF_LIST} was accepted as a reviewer"
)
assert OFF_LIST in result.stderr and "H10_REVIEWERS" in result.stderr
assert gitea.comments() == []
def test_a_REJECTION_survives_a_workflow_whose_allowlist_cannot_be_derived(gitea, tmp_path):
"""Membership never applies to a `failure`, so a broken `H10_REVIEWERS` declaration must not
block one. It used to: the derivation was loaded unconditionally, so a workflow with two
assignments refused REJECTIONS as well — the exact outcome the success-only rule exists to avoid
("no supported way to record a rejection"), one condition earlier, and on the branch most likely
to have broken that declaration: the one editing it.
"""
script = _sandbox(tmp_path, ALLOWLISTED)
workflow = script.parent.parent / ".gitea" / "workflows" / "review-verdict.yml"
body = workflow.read_text()
marker = f'H10_REVIEWERS="{ALLOWLISTED}"'
assert body.count(marker) == 1
workflow.write_text(body.replace(marker, f'{marker}\n H10_REVIEWERS="someone-else"'))
gitea.set_status_creator(OFF_LIST)
result = subprocess.run(
["bash", str(script), "42", "BLOCKED", "findings outstanding"],
env=gitea.env,
capture_output=True,
text=True,
)
assert result.returncode == 0, f"a rejection was blocked by an allow-list it does not depend on: {result.stderr}"
assert len(gitea.comments()) == 1
# And the positive direction still refuses on the same tree, so the guard was narrowed, not removed.
(gitea.state / "posts.jsonl").unlink(missing_ok=True)
(gitea.state / "pr_get_count").unlink(missing_ok=True)
approval = subprocess.run(["bash", str(script), "42", "MERGEABLE"], env=gitea.env, capture_output=True, text=True)
assert approval.returncode != 0
assert gitea.statuses() == [], "an underivable allow-list must refuse an APPROVAL before writing"
File diff suppressed because it is too large Load Diff
@@ -52,6 +52,14 @@ class Sandbox:
def __init__(self, root: Path):
self.scripts = root / "scripts"
shutil.copytree(REPO_ROOT / "scripts", self.scripts)
# The gate workflow comes too. `post-review-verdict.sh` derives the H10 reviewer allow-list
# from it (ersatztv#845), resolving it relative to its own location — so a sandbox holding
# only `scripts/` would leave the writer unable to answer whether its verdict is inheritable,
# and every write-side assertion below would fail for a reason that has nothing to do with
# the vocabulary under test.
workflow = root / ".gitea" / "workflows" / "review-verdict.yml"
workflow.parent.mkdir(parents=True)
shutil.copy2(REPO_ROOT / ".gitea" / "workflows" / "review-verdict.yml", workflow)
self.vocabulary = self.scripts / "lib" / "review-verdict-vocabulary.sh"
self.reader = self.scripts / "check-review-verdict.sh"
self.writer = self.scripts / "post-review-verdict.sh"
+205
View File
@@ -0,0 +1,205 @@
import { describe, expect, it } from 'vitest';
import {
declaresLocalComplete,
scanCompleteAnnotations,
scanOptionalSchemaMembers,
scanSchemaAliases
} from './completeAnnotationScan';
/**
* Fixture proofs for `completeAnnotationScan.ts` (ersatztv#820), over SYNTHETIC source strings.
*
* Split from `completeAnnotations.guard.test.ts` for the reason `pageSizeScan.test.ts` is split
* from its guard: the guard asserts over today's repo, so it can only witness an input class that
* happens to be committed right now. A scanner defect for a shape the repo does not currently
* contain — a `Complete<…>` inside a template literal, say — would ship green and start mattering
* the day someone writes one. These pin the scanner's behaviour per INPUT CLASS instead.
*/
describe('scanCompleteAnnotations', () => {
it('finds a Complete<…> in a parameter type position', () => {
const source = 'export function f(body: Complete<ReplaceBlockRequest>): void {}';
expect(scanCompleteAnnotations(source, 'a.ts')).toEqual([{ line: 1, column: 25, typeName: 'ReplaceBlockRequest' }]);
});
it('finds a Complete<…> in a return type and in a variable annotation', () => {
const source = ['const x: Complete<A> = y;', 'function g(): Complete<B> { return z; }'].join('\n');
expect(scanCompleteAnnotations(source, 'a.ts').map((a) => `${a.line}:${a.typeName}`)).toEqual(['1:A', '2:B']);
});
it('finds the annotation on a .map callback return type, the form §4b says gets skipped', () => {
const source = 'const r = items.map((i): Complete<BlockItemRequest> => ({ ...i }));';
expect(scanCompleteAnnotations(source, 'a.ts').map((a) => a.typeName)).toEqual(['BlockItemRequest']);
});
// The three exclusions below are why this is a compiler-API scan and not a regex. Each is a
// DIFFERENT node kind, so none of them is a `TypeReferenceNode` and none needs special-casing.
it('does NOT match Complete<…> written inside a comment', () => {
const source = ['/** Build the body as Complete<ReplaceBlockRequest> before sending. */', 'const a = 1;'].join('\n');
expect(scanCompleteAnnotations(source, 'a.ts')).toEqual([]);
});
it('does NOT match Complete<…> inside a string or template literal', () => {
const source = ['const s = "Complete<A>";', 'const t = `Complete<B>`;'].join('\n');
expect(scanCompleteAnnotations(source, 'a.ts')).toEqual([]);
});
it('does NOT match the import specifier that brings Complete into scope', () => {
const source = "import type { Complete } from './completeRequest';";
expect(scanCompleteAnnotations(source, 'a.ts')).toEqual([]);
});
it('does NOT record a Complete reference with no type argument or with two', () => {
// Neither compiles — `Complete` takes exactly one parameter — so recording them would put a
// shape the compiler already rejects into a set the guard asserts set-equality over.
const source = ['let a: Complete;', 'let b: Complete<X, Y>;'].join('\n');
expect(scanCompleteAnnotations(source, 'a.ts')).toEqual([]);
});
it('records the type argument VERBATIM rather than flattening it to an identifier', () => {
const source = "let a: Complete<components['schemas']['X']>;";
expect(scanCompleteAnnotations(source, 'a.ts').map((a) => a.typeName)).toEqual(["components['schemas']['X']"]);
});
it('parses a .tsx file with JSX rather than choking on the angle brackets', () => {
const source = ['const el = <div className="x" />;', 'export const f = (b: Complete<A>) => b;'].join('\n');
expect(scanCompleteAnnotations(source, 'a.tsx').map((a) => a.typeName)).toEqual(['A']);
});
it('reports sites in source order', () => {
const source = ['let b: Complete<B>;', 'let a: Complete<A>;'].join('\n');
expect(scanCompleteAnnotations(source, 'a.ts').map((a) => a.typeName)).toEqual(['B', 'A']);
});
});
describe('scanSchemaAliases', () => {
it('resolves an exported components schema alias', () => {
const source = "export type ReplaceBlockRequest = components['schemas']['ReplaceBlockRequest'];";
expect(scanSchemaAliases(source, 'a.ts')).toEqual([
{ localName: 'ReplaceBlockRequest', schemaName: 'ReplaceBlockRequest' }
]);
});
it('resolves an alias whose LOCAL name differs from the schema name', () => {
// The case that makes resolution worth deriving rather than assuming: `Block` is not
// `BlockResponseModel`, and a guard that matched on name equality would silently miss it.
const source = "export type Block = components['schemas']['BlockResponseModel'];";
expect(scanSchemaAliases(source, 'a.ts')).toEqual([{ localName: 'Block', schemaName: 'BlockResponseModel' }]);
});
it('ignores an indexed-access alias that is not into components.schemas', () => {
const source = "type A = Foo['bar'];\ntype B = components['responses']['X'];";
expect(scanSchemaAliases(source, 'a.ts')).toEqual([]);
});
it('ignores a HAND-WRITTEN object type, which is the shape the guard rejects', () => {
const source = 'export type ReplacePlayoutTemplatesRequest = { items: PlayoutTemplateItemRequest[] };';
expect(scanSchemaAliases(source, 'a.ts')).toEqual([]);
});
});
describe('scanOptionalSchemaMembers', () => {
const dts = [
'export interface components {',
' schemas: {',
' "A": {',
' "req": number;',
' "opt"?: string;',
' };',
' "B": {',
' "one": boolean;',
' };',
' };',
'}'
].join('\n');
it('records only the OPTIONAL members, per schema', () => {
const parsed = scanOptionalSchemaMembers(dts);
expect(parsed.get('A')).toEqual(['opt']);
expect(parsed.get('B')).toEqual([]);
});
it('records every schema, so a schema with no optional member is present-and-empty rather than absent', () => {
// The guard filters on `optionals.length > 0`. If a no-optionals schema were simply missing,
// that filter would be indistinguishable from a parse that dropped it.
expect([...scanOptionalSchemaMembers(dts).keys()].sort()).toEqual(['A', 'B']);
});
it('returns nothing for a d.ts with no schemas block rather than throwing', () => {
// The guard's own anti-vacuity floor is what turns an empty parse into a failure; the scanner
// returning empty is the input to that check, not a silent pass on its own.
expect(scanOptionalSchemaMembers('export interface components { paths: {} }').size).toBe(0);
});
});
describe('scanOptionalSchemaMembers — unreadable shapes FAIL rather than silently shrinking the population', () => {
const wrap = (schemas: string) => `export interface components {\n schemas: {\n${schemas}\n };\n}`;
it('throws on a duplicate schema declaration instead of letting the later one win', () => {
expect(() => scanOptionalSchemaMembers(wrap(' "A": { "x"?: string };\n "A": { "y": number };'))).toThrow(/declared twice/);
});
it('throws on an optional member with a computed key rather than dropping it', () => {
expect(() => scanOptionalSchemaMembers(wrap(' "A": { ["computed"]?: string };'))).toThrow(/computed key/);
});
// Every shape below was MEASURED hiding members by cold review, against the real scanner, after an
// earlier fix threw on intersections alone and claimed in a comment that `allOf` was "the one
// non-object shape that can hide members". `generateTypes` emits a union for `oneOf`/`anyOf` and
// for a plain nullable object (`type: ["object","null"]`), so none of these is hypothetical.
it.each([
['intersection (allOf)', ' "A": ComponentA & ComponentB;'],
['union of refs (oneOf)', ' "A": ComponentA | ComponentB;'],
['union of object literals', ' "A": { "x"?: string } | { "y"?: number };'],
['a $ref alias', ' "A": components["schemas"]["B"];'],
['an array shorthand', ' "A": { "x"?: string }[];'],
// `typeFromSchema` emits `Array<T>` (a TypeReference), not `T[]`; pin the shape the generator
// actually produces rather than only the one that reads like an array.
['an Array<T> as the generator emits it', ' "A": Array<ComponentB>;']
])('throws on %s rather than dropping its members', (_label, schema) => {
expect(() => scanOptionalSchemaMembers(wrap(schema))).toThrow(/cannot enumerate/);
});
it.each([
['a string enum', ' "A": "x" | "y";'],
['a single literal', ' "A": "only";'],
['a bare keyword', ' "A": string;'],
['a nullable enum', ' "A": null | "x";']
])('still skips %s quietly, which has no members to drop', (_label, schema) => {
expect(scanOptionalSchemaMembers(wrap(schema)).size).toBe(0);
});
it('ignores a `schemas` member on an interface that is not `components`', () => {
// The unconstrained form let a `declare module` block overwrite a real schema with an empty
// optional list — a droppable schema removed from the population with nothing reddening.
const noise = 'declare module "n" { interface Other { schemas: { "A": { "y": number } } } }';
const parsed = scanOptionalSchemaMembers(`${wrap(' "A": { "x"?: string };')}\n${noise}`);
expect(parsed.get('A')).toEqual(['x']);
});
});
describe('declaresLocalComplete', () => {
it('detects a local type alias, an interface, and an aliased import binding the name', () => {
expect(declaresLocalComplete('type Complete<T> = T;', 'a.ts')).toBe(true);
expect(declaresLocalComplete('interface Complete<T> { x?: keyof T }', 'a.ts')).toBe(true);
expect(declaresLocalComplete("import { Other as Complete } from './x';", 'a.ts')).toBe(true);
});
it('does NOT fire on the real import, which binds the mapped type itself', () => {
expect(declaresLocalComplete("import type { Complete } from './completeRequest';", 'a.ts')).toBe(false);
});
it('does NOT fire on the words appearing inside a comment or a string', () => {
// The regression that moved this off a regex: this repo's own fixture file contains
// `Build the body as Complete<…>` inside a quoted synthetic source, and a text predicate
// reported the whole file as shadowing.
expect(declaresLocalComplete('/** the body as Complete<X> before sending */\nconst a = 1;', 'a.ts')).toBe(false);
expect(declaresLocalComplete("const s = 'type Complete<T> = T;';", 'a.ts')).toBe(false);
});
});
describe('declaresLocalComplete — the default-import form', () => {
it('detects a default import binding the name Complete', () => {
expect(declaresLocalComplete("import Complete from './x';", 'a.ts')).toBe(true);
});
});
+328
View File
@@ -0,0 +1,328 @@
import * as ts from 'typescript';
/**
* ersatztv#820 — AST scanners that let a guard check where `Complete<T>` is APPLIED, not only what
* it means.
*
* ## The gap this exists to close
*
* `completeRequest.guard.test.ts` proves the TYPE's semantics: `Complete<T>` rejects a missing
* member and a phantom one. It proves nothing about application. Separately,
* `scripts/tests/test_optional_request_members.py` derives — from `ErsatzTV/wwwroot/openapi/v1.json`,
* every run — the set of schemas that can silently drop a member on a write, and files each under a
* disposition. One of those dispositions, `COVERED`, is worded "the SPA builds this body and the
* builder is annotated `Complete<T>`".
*
* That sentence is an ASSERTION, not a check. Nothing verified it. A `COVERED` row and a SPA with
* the annotation deleted are indistinguishable to every test in the repo, which is the
* `testing.guard-derives-population-from-source` shape one level up: the population of schemas is
* derived and the population of ANNOTATIONS is not, so the registry can claim coverage it does not
* have. `docs/spa-conventions.md` §4b's opposite rule — do NOT annotate a schema whose optional
* members are computed server-side — was prose only for the same reason.
*
* ## Why the compiler API and not a string match
*
* `Complete<Foo>` is a narrow token, so a regex looks adequate here in a way it did not for
* `check-doc-narrative`. It is not: the text appears in doc comments (this file's own comment
* block says `Complete<T>` a dozen times), in string literals, and in `import type { Complete }`.
* A `TypeReferenceNode` is a structurally different AST node from all three, so they are excluded
* by NODE KIND rather than by a preceding-character heuristic — the same argument
* `pageSizeScan.ts` makes, and this repo has a withdrawn guard (six review rounds, then deleted)
* recording what happens when a source-shaped predicate is built out of string matching instead.
*
* ## The alias indirection, and why it is resolved rather than assumed
*
* The SPA never writes `Complete<components['schemas']['X']>`. Each `web/src/api/*.ts` module
* re-exports the schema under a local alias — `export type ReplaceBlockRequest =
* components['schemas']['ReplaceBlockRequest']` — and screens import that. The alias name usually
* equals the schema name, and assuming that would work today; it is derived anyway, because the
* assumption is exactly the kind that holds until someone renames one alias and the guard silently
* stops matching a schema it believes it is covering.
*
* ## What this resolution does NOT follow, and which way each one fails
*
* Written down because "resolved" would otherwise read as complete. None of these is silent: each
* yields an annotation the guard cannot resolve, and an unresolved annotation FAILS.
*
* - A renamed import or re-export (`import type { X as Y }`, `export type { X as Y }`) is not
* followed, so `Complete<Y>` is unresolved — a false RED, which is noisy but safe.
* - A nested generic (`Complete<Box<S>>`) is recorded verbatim and matches no alias — also a false
* RED.
* - The alias map is built across the whole population and would otherwise be last-write-wins, so
* two modules mapping the same local name to different schemas could credit the wrong one. That
* is a false GREEN, so it is not tolerated: the guard rejects a colliding local name outright
* rather than resolving it.
* - `Complete` is matched by NAME. A file declaring its own `type Complete<T> = T` would make every
* annotation in it an identity type while still reading as covered — the one genuinely dangerous
* gap, closed by asserting no such declaration exists rather than by resolving the symbol through
* a full `Program` + type checker, which this scanner deliberately does not build.
*/
/** A `Complete<X>` type reference, as written. */
export interface CompleteAnnotation {
/** 1-based source line of the `Complete` reference, matching editor line numbers. */
line: number;
/** 1-based source column. */
column: number;
/**
* The type argument as WRITTEN — the local alias (`ReplaceBlockRequest`), not the schema it
* resolves to. Resolution is the caller's job, so that an UNRESOLVABLE alias stays visible as
* itself rather than being silently dropped here.
*/
typeName: string;
}
/** A `export type X = components['schemas']['Y']` alias declaration. */
export interface SchemaAlias {
/** The local name the SPA writes, e.g. `ReplaceBlockRequest`. */
localName: string;
/** The schema key it indexes, e.g. `ReplaceBlockRequest`. */
schemaName: string;
}
function scriptKindFor(fileName: string): ts.ScriptKind {
// `.mts`/`.cts` parse as plain TS; only `.tsx` needs the JSX grammar. Same rule as
// `pageSizeScan.ts` — stated here rather than shared, because a helper on both sides of a
// comparison cancels out of it (the #819 round-5 finding recorded in the pageSize guard).
return fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
}
function parse(sourceText: string, fileName: string): ts.SourceFile {
return ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, scriptKindFor(fileName));
}
/**
* Every `Complete<X>` TYPE REFERENCE in one source file.
*
* A reference with no type argument, or with more than one, is NOT recorded: `Complete` takes
* exactly one parameter, so those forms do not compile, and recording them would put a shape the
* compiler already rejects into a set the guard asserts over.
*/
export function scanCompleteAnnotations(sourceText: string, fileName: string): CompleteAnnotation[] {
const sourceFile = parse(sourceText, fileName);
const found: CompleteAnnotation[] = [];
function visit(node: ts.Node): void {
if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) && node.typeName.text === 'Complete') {
if (node.typeArguments?.length === 1) {
const argument = node.typeArguments[0];
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
found.push({
line: line + 1,
column: character + 1,
// `getText()` on a type node returns the argument verbatim, so a nested or qualified form
// is preserved as written instead of being flattened into an identifier that was never
// there.
typeName: argument.getText(sourceFile).trim()
});
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return found.sort((a, b) => (a.line === b.line ? a.column - b.column : a.line - b.line));
}
/**
* Every `type X = components['schemas']['Y']` alias in one source file.
*
* Both the exported and the local form are recorded. Only a STRING-literal index is resolvable; a
* computed one (`components['schemas'][someKey]`) names no fixed schema and is correctly skipped,
* which is why the guard asserts that every annotation resolves rather than assuming this returns
* everything.
*/
export function scanSchemaAliases(sourceText: string, fileName: string): SchemaAlias[] {
const sourceFile = parse(sourceText, fileName);
const found: SchemaAlias[] = [];
function visit(node: ts.Node): void {
if (ts.isTypeAliasDeclaration(node) && ts.isIndexedAccessTypeNode(node.type)) {
const outer = node.type;
const schemaKey = outer.indexType;
const container = outer.objectType;
// Shape: components['schemas']['Name'] — an indexed access whose OBJECT is itself an indexed
// access keyed 'schemas'. Checking the inner 'schemas' key is what keeps an unrelated
// `Foo['bar']` alias out of the map.
if (
ts.isLiteralTypeNode(schemaKey) &&
ts.isStringLiteral(schemaKey.literal) &&
ts.isIndexedAccessTypeNode(container) &&
ts.isLiteralTypeNode(container.indexType) &&
ts.isStringLiteral(container.indexType.literal) &&
container.indexType.literal.text === 'schemas'
) {
found.push({ localName: node.name.text, schemaName: schemaKey.literal.text });
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return found;
}
/**
* Schema name → the names of its OPTIONAL members, read off the generated `v1.d.ts`.
*
* The generated file is the artifact the SPA actually compiles against, and
* `web/scripts/generate-openapi-types.mjs` is a pure pass-through of the OpenAPI `required` array
* (`const optional = required.has(name) ? '' : '?'`). So "has an optional member here" and "sits
* outside `required` in `v1.json`" are the same fact read at different points, NOT a second
* derivation that could disagree with the Python guard's.
*/
/**
* A schema type with no members to drop: a string/number enum, a single literal, or a bare keyword.
*
* The generated file holds 54 of these today (52 unions of string literals, one literal, one
* `IFormFile`-shaped keyword). They are skipped rather than thrown on because there is nothing in
* them a builder could omit — which is a property this predicate CHECKS, not one the caller asserts.
*/
function isEnumShaped(type: ts.TypeNode): boolean {
if (ts.isLiteralTypeNode(type)) {
return true;
}
if (ts.isUnionTypeNode(type)) {
// One level deep in practice: TS flattens `A | B | C` into a single node, and a parenthesised
// group parses as a `ParenthesizedTypeNode`, which the keyword check below rejects — so nesting
// is refused rather than mis-admitted. The recursion is what lets each MEMBER be judged on its
// own kind instead of the union being waved through as a whole.
return type.types.every((member) => isEnumShaped(member));
}
return (
type.kind === ts.SyntaxKind.StringKeyword ||
type.kind === ts.SyntaxKind.NumberKeyword ||
type.kind === ts.SyntaxKind.BooleanKeyword ||
type.kind === ts.SyntaxKind.UnknownKeyword ||
type.kind === ts.SyntaxKind.AnyKeyword
);
}
export function scanOptionalSchemaMembers(dtsText: string): Map<string, string[]> {
const sourceFile = parse(dtsText, 'v1.d.ts');
const optionalsBySchema = new Map<string, string[]>();
function memberName(member: ts.TypeElement): string | undefined {
if (!member.name) {
return undefined;
}
if (ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)) {
return member.name.text;
}
return undefined;
}
function visit(node: ts.Node): void {
// Constrained to `interface components { schemas: … }` at the TOP LEVEL of the file, not "any
// property named `schemas` anywhere". Cold review demonstrated the unconstrained form: a
// `declare module` block elsewhere carrying its own `schemas` member silently OVERWROTE a real
// schema's optional list with an empty one, which removes a droppable schema from the
// population without reddening anything.
if (!ts.isInterfaceDeclaration(node) || node.name.text !== 'components') {
ts.forEachChild(node, visit);
return;
}
for (const member of node.members) {
if (!ts.isPropertySignature(member) || memberName(member) !== 'schemas') {
continue;
}
if (!member.type || !ts.isTypeLiteralNode(member.type)) {
throw new Error('completeAnnotationScan: `components.schemas` is not a type literal — the generated format changed.');
}
for (const schema of member.type.members) {
if (!ts.isPropertySignature(schema)) {
continue;
}
const name = memberName(schema);
if (name === undefined) {
// A computed key names a schema this scanner cannot read. Skipping it would drop a
// droppable schema from the population silently; UNKNOWN must fail rather than pass.
throw new Error(
`completeAnnotationScan: a schema key at position ${schema.getStart(sourceFile)} is not a plain or quoted name.`
);
}
if (schema.type && !ts.isTypeLiteralNode(schema.type) && !isEnumShaped(schema.type)) {
// Anything that is neither an object literal nor enum-shaped may CARRY members this
// scanner cannot enumerate, and skipping it drops them from the droppable population with
// nothing reddening. An earlier version threw on intersections only, on the stated ground
// that `allOf` was "the one non-object shape that can hide members". Review measured that
// false by execution: a union of refs, a union of object literals, a `$ref` alias and an
// array each hid members too, and `generateTypes` emits a union for `oneOf`/`anyOf` AND for
// a plain nullable object (`type: ["object","null"]`). So the test is now positive — is it
// a shape we can enumerate, or one we have shown carries nothing — rather than a list of
// shapes to reject, which is the enumeration that was already wrong once.
throw new Error(
`completeAnnotationScan: schema \`${name}\` has a type this scanner cannot enumerate ` +
`(kind ${ts.SyntaxKind[schema.type.kind]}). Teach it the shape rather than letting the ` +
`schema leave the droppable population silently.`
);
}
if (!schema.type || !ts.isTypeLiteralNode(schema.type)) {
continue;
}
if (optionalsBySchema.has(name)) {
throw new Error(`completeAnnotationScan: schema \`${name}\` is declared twice; the later one would silently win.`);
}
const optionals: string[] = [];
for (const m of schema.type.members) {
if (!ts.isPropertySignature(m) || m.questionToken === undefined) {
continue;
}
const memberKey = memberName(m);
if (memberKey === undefined) {
throw new Error(
`completeAnnotationScan: schema \`${name}\` has an OPTIONAL member with a computed key, which this scanner cannot read.`
);
}
optionals.push(memberKey);
}
optionalsBySchema.set(name, optionals);
}
}
}
visit(sourceFile);
return optionalsBySchema;
}
/**
* Whether this file BINDS the name `Complete` to something of its own.
*
* `scanCompleteAnnotations` matches `Complete` by name, so a file declaring `type Complete<T> = T`
* (or an `interface`, or importing something else under that alias) would read as fully covered
* while every annotation in it was an identity type — the one genuinely dangerous gap in a
* name-based match.
*
* Done with the AST rather than a regex over the text, and not for symmetry: the regex version
* false-positived on this repo's own fixture file, which contains `type Complete<T> = T` inside a
* synthetic source STRING. A string predicate cannot tell a declaration from a quoted example,
* which is the argument this whole module is built on.
*/
export function declaresLocalComplete(sourceText: string, fileName: string): boolean {
const sourceFile = parse(sourceText, fileName);
let found = false;
function visit(node: ts.Node): void {
if (found) {
return;
}
if ((ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) && node.name.text === 'Complete') {
found = true;
return;
}
// `import { Something as Complete }` — the local binding is `Complete`, the imported name is not.
if (ts.isImportSpecifier(node) && node.name.text === 'Complete' && node.propertyName !== undefined) {
found = true;
return;
}
// `import Complete from './x'` — a default import binding the same name.
if (ts.isImportClause(node) && node.name?.text === 'Complete') {
found = true;
return;
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return found;
}
@@ -0,0 +1,469 @@
import { describe, expect, it } from 'vitest';
import trackedSources from 'virtual:etv-tracked-source-files';
import {
declaresLocalComplete,
scanCompleteAnnotations,
scanOptionalSchemaMembers,
scanSchemaAliases,
type CompleteAnnotation
} from './completeAnnotationScan';
/**
* ersatztv#820 — `Complete<T>` is APPLIED where the contract says it must be, and nowhere it must
* not be.
*
* ## What was missing
*
* Two guards existed and neither covered this. `completeRequest.guard.test.ts` proves the TYPE's
* semantics — `Complete<T>` rejects a missing member and a phantom one — and would stay green with
* every annotation in the SPA deleted. `scripts/tests/test_optional_request_members.py` derives,
* from `ErsatzTV/wwwroot/openapi/v1.json` on every run, which schemas can silently drop a member on
* a write, and files each under a disposition; one of those, `COVERED`, is worded "the SPA builds
* this body and the builder is annotated `Complete<T>`".
*
* That wording is a CLAIM ABOUT ANOTHER LANGUAGE'S SOURCE that nothing checked. A `COVERED` row
* and a SPA with the annotation deleted were indistinguishable to the whole suite. So was the
* opposite error: `docs/spa-conventions.md` §4b's "do NOT apply `Complete<T>` to a schema whose
* optional members are computed server-side" was prose with no executable form.
*
* ## The population is derived; the dispositions are the reviewed scope
*
* The POPULATION — which schemas can drop a member — is read every run off
* `src/api/generated/v1.d.ts` with the compiler API. That file is a pure pass-through of the
* OpenAPI `required` array (`web/scripts/generate-openapi-types.mjs` does
* `const optional = required.has(name) ? '' : '?'`), so "optional here" and "outside `required` in
* `v1.json`" are ONE fact read at two points, not two derivations that could disagree.
* `DISPOSITIONS` below is the SCOPE: a reviewed policy choice per schema, hand-written on purpose,
* and FORCED to exist by the set-equality assertion — a new optional member fails this guard until
* someone writes down what should happen about it.
*
* RELATIONSHIP TO THE PYTHON GUARD, stated because "they check the same thing" would be wrong.
* That guard scopes to schemas REACHABLE FROM A REQUEST BODY (9 rows today); this one scopes to
* every generated schema with an optional member (13), so it additionally sees response models. The
* two tables therefore overlap rather than coincide, and they can drift: re-dispositioning a schema
* on one side does not redden the other. What is NOT possible is a schema going unnoticed by both,
* because both derive their population from the same `required` arrays.
*
* ## Residual, stated rather than left to be discovered
*
* This guard proves an annotation EXISTS for a schema; it does not prove that every construction
* site of that schema carries one. The missing-member direction does not need it — the wrapper
* parameter's annotation reaches every caller — but the PHANTOM direction does, because
* TypeScript's excess-property check fires only on a fresh object literal in a contextually typed
* position, which a generic `.map` callback's return is not (§4b). That per-site population is
* "sites in code", the case `testing.guard-derives-population-from-source` explicitly scopes out
* and #777 tracks. A deep `Complete<T>` would close the missing direction into nested types but
* NOT the phantom one, and it cannot be applied blanket-wise anyway — see `MUST-NOT-ANNOTATE`.
*/
// --- the reviewed scope ---------------------------------------------------------------------
//
// ANNOTATED a write body that can drop a member, so the SPA MUST annotate it `Complete<T>`.
// Equivalent to the Python guard's COVERED, and this is the row that turns that
// registry's claim into a check.
// MUST-NOT-ANNOTATE the optional members are computed get-only properties on the C# record. Nothing
// deserializes them, so omitting them drops nothing — and annotating WOULD BE A
// BUG, forcing a caller to fabricate server-computed values (§4b). Asserted as an
// absence, so the prohibition is executable rather than advisory.
// CREATE a POST that creates an entity, where an omitted member correctly means "use the
// default".
// TRIGGER the body parameterises an action and replaces no stored entity.
// RESPONSE-ONLY never sent as a request body, so there is nothing for a builder to drop.
//
// The last three are PERMISSIVE by design: they require no annotation and forbid none. That is
// deliberate rather than an omission — annotating one is harmless (it costs a caller nothing to name
// a field it was going to set anyway), while FORBIDDING it would make a future full-replace endpoint
// over the same schema harder to protect. Where annotating is actively wrong, the row is
// MUST-NOT-ANNOTATE and the prohibition is asserted; `CreateChannelFromLineupAdvancedOptionsRequest`
// is such a row despite being a create.
type Disposition = 'ANNOTATED' | 'MUST-NOT-ANNOTATE' | 'CREATE' | 'TRIGGER' | 'RESPONSE-ONLY';
const DISPOSITIONS: Record<string, { disposition: Disposition; note: string }> = {
CreateFFmpegProfileRequest: {
disposition: 'ANNOTATED',
note: "FFmpegProfilesScreen's `Draft` feeds BOTH the POST and the full-replace PUT, so the draft type itself is `Complete<…>`. `qsvPreferNativeDecoder` was a live silent drop before #807."
},
UpdateFFmpegProfileRequest: {
disposition: 'ANNOTATED',
note: 'PUT /ffmpeg/profiles/{id} is a full replace; `qsvPreferNativeDecoder` is a defaulted ctor param so ASP.NET drops it from `required`.'
},
MultiCollectionItemRequest: {
disposition: 'ANNOTATED',
note: 'Nested in the full-replace PUT /multi-collections/{id}. Dropping `weight` resets every weight to 1 — a prose comment warned about it, and a comment is not a check.'
},
ArtworkContentTypeModel: {
disposition: 'MUST-NOT-ANNOTATE',
note: 'Reachable from the full-replace PUT /channels/{id} via `UpdateChannelRequest.logo`, so an endpoint-shaped rule would demand an annotation. `isExternalUrl`/`hasContentType`/`urlWithContentType` are computed get-only properties, never deserialized.'
},
AutoTunedChannelRequest: {
disposition: 'CREATE',
note: 'POST /channels/auto-tune. The handler adds a channel and rejects a duplicate number; it never overwrites an existing one.'
},
AutoTuneSourceWeightRequest: {
disposition: 'CREATE',
note: 'Nested in the auto-tune POST body; same create-time semantics as its parent.'
},
CreateChannelFromLineupAdvancedOptionsRequest: {
// MUST-NOT-ANNOTATE rather than CREATE, so the prohibition is asserted rather than merely
// described: annotating this one is a BUG for the same reason as the server-computed case, just
// arrived at differently. Cold review pointed out that CREATE was permissive here while the note
// said annotating would break the semantics.
disposition: 'MUST-NOT-ANNOTATE',
note: 'POST /channels/from-lineup. Omission is LOAD-BEARING: an omitted override means INHERIT the template value, with a separate `clear` list to force NONE. `Complete<T>` would collapse that third state into explicit-null.'
},
ScanShowRequest: {
disposition: 'TRIGGER',
note: 'POST /libraries/{id}/scan-show starts a scan; `deepScan` parameterises the action and replaces no entity.'
},
ChannelLogoResponseModel: {
disposition: 'RESPONSE-ONLY',
note: 'Read model. Same computed get-only trio as ArtworkContentTypeModel.'
},
LibraryBrowseItemResponseModel: {
disposition: 'RESPONSE-ONLY',
note: 'Read model for the library browser; `subtitle`/`seasonId` are present only for the item kinds that have them. The SPA never sends it.'
},
MultiCollectionItemResponseModel: {
disposition: 'RESPONSE-ONLY',
note: 'Read model. The REQUEST twin of this schema is dispositioned ANNOTATED above.'
},
ProblemDetails: { disposition: 'RESPONSE-ONLY', note: 'RFC 7807 error payload; never sent by the SPA.' },
ValidationProblemDetails: {
disposition: 'RESPONSE-ONLY',
note: 'RFC 7807 validation error payload returned by the API on a 400; never constructed or sent by the SPA.'
}
};
// --- discovery ------------------------------------------------------------------------------
const rawSourceModules = import.meta.glob('/src/**/*.{ts,tsx,mts,cts}', {
query: '?raw',
import: 'default',
eager: true
}) as Record<string, string>;
const GENERATED_DTS_PATH = 'src/api/generated/v1.d.ts';
/** Glob keys are root-absolute (`/src/x.ts`); the git index spells them root-relative. */
function globKeyToRootRelativePath(key: string): string {
return key.startsWith('/') ? key.slice(1) : key;
}
/**
* In scope for the ANNOTATION scan: tracked SPA source, excluding generated output and excluding
* this guard itself (whose `DISPOSITIONS` notes and doc comment mention `Complete<…>` in prose —
* harmless, since the scanner matches type-reference NODES, but scanning the file that defines the
* expectation is the "check walks the same tree as its subject" shape and is avoided by construction).
*
* Test files are deliberately IN scope, unlike the pageSize guard's population: a `Complete<…>` in a
* test fixture is a real application of the type, and #807's own regression fixture lives in
* `ffmpegProfiles.test.ts`. They are scanned but cannot DISCHARGE a production obligation — see
* `isProductionSource`.
*
* The `*.guard.test.ts` exclusion is broader than this file and has a stated cost:
* `completeRequest.guard.test.ts` annotates synthetic inline types (`Complete<{ a: number }>`) to
* prove the type's semantics, which resolve to no schema and would fail the unresolved assertion for
* no benefit. The consequence to accept: a MUST-NOT-ANNOTATE violation written inside a
* `*.guard.test.ts` would not be caught. That is a test-only file annotating a schema it must not,
* which changes no production behaviour — recorded rather than fixed.
*/
function isInScopeForAnnotations(rootRelativePath: string): boolean {
if (!/\.(ts|tsx|mts|cts)$/.test(rootRelativePath)) {
return false;
}
return !rootRelativePath.includes('/generated/') && !rootRelativePath.endsWith('.guard.test.ts');
}
interface DiscoveredAnnotation extends CompleteAnnotation {
file: string;
/** The generated schema the written alias resolves to, or `undefined` when nothing maps it. */
schemaName: string | undefined;
}
// Each of these parses the whole tracked population with the compiler API, and several tests need
// the same result, so they are computed ONCE. Not a micro-optimisation: unmemoised, the annotation
// scan walked the population twice per call (directly, and again through the alias map) across four
// tests, and the guard passed in isolation while timing out at vitest's 5s default under full-suite
// load — green alone, red in `npm test`, which is the worst way for a guard to fail. The inputs are
// module-level immutable strings, so caching cannot mask a mutation: each `vitest run` is a fresh
// process that re-reads the tree.
let cachedSources: { file: string; text: string }[] | undefined;
let cachedAliases: Map<string, string> | undefined;
let cachedAnnotations: DiscoveredAnnotation[] | undefined;
let cachedOptionalMembers: Map<string, string[]> | undefined;
function trackedInScopeSources(): { file: string; text: string }[] {
if (cachedSources === undefined) {
const tracked = new Set(trackedSources.tracked);
cachedSources = Object.entries(rawSourceModules)
.map(([key, text]) => ({ file: globKeyToRootRelativePath(key), text }))
.filter(({ file }) => tracked.has(file) && isInScopeForAnnotations(file))
.sort((a, b) => a.file.localeCompare(b.file));
}
return cachedSources;
}
/** localName -> generated schema key, built across the WHOLE population (aliases cross files). */
function schemaAliasMap(): Map<string, string> {
if (cachedAliases === undefined) {
const map = new Map<string, string>();
for (const { file, text } of trackedInScopeSources()) {
for (const alias of scanSchemaAliases(text, file)) {
map.set(alias.localName, alias.schemaName);
}
}
cachedAliases = map;
}
return cachedAliases;
}
function discoverAnnotations(): DiscoveredAnnotation[] {
if (cachedAnnotations === undefined) {
const aliases = schemaAliasMap();
const found: DiscoveredAnnotation[] = [];
for (const { file, text } of trackedInScopeSources()) {
for (const annotation of scanCompleteAnnotations(text, file)) {
found.push({ ...annotation, file, schemaName: aliases.get(annotation.typeName) });
}
}
cachedAnnotations = found;
}
return cachedAnnotations;
}
/**
* A source file whose annotations can DISCHARGE a production obligation.
*
* Test files are scanned (a `Complete<…>` there is still an application, and #807's own regression
* fixture lives in one) but they cannot satisfy the requirement that a schema be annotated in
* production. Cold review demonstrated why with a working bypass: strip `Complete<>` off the
* `updateFFmpegProfile` WRAPPER and retype `ffmpegProfiles.test.ts`'s fixture to name
* `UpdateFFmpegProfileRequest`, and a per-schema "annotated somewhere" rule stays green while the
* wrapper stops protecting every future caller.
*/
function isProductionSource(rootRelativePath: string): boolean {
// `.spec.` and `setupTests.ts` are here because cold review measured the `.test.`-only form false:
// vitest's own default include is `**/*.{test,spec}.?(c|m)[jt]s?(x)` (measured on 4.1.9), so a `*.spec.ts` fixture was
// a TEST to the runner and PRODUCTION to this predicate, and could discharge the obligation while
// the real builder lost its annotation. Same for the setup file, which vitest loads for every run.
//
// WHAT THIS COVERS, bounded rather than universal — an earlier version of this comment said it had
// to be true "in all of the spellings vitest accepts", and review measured THAT false in turn:
// `*.test.*`, `*.spec.*`, and the ONE setup file named in `vite.config.ts`, whose path is pinned by
// the scope-pin test so a rename reddens. NOT covered: a SECOND `setupFiles` entry. vitest would
// load it every run, so an annotation there discharges the obligation — measured, with the real
// wrapper unprotected and this guard green. Deriving `setupFiles` from the vite config needs
// another virtual-module plugin and is not proportionate to a three-site coordinated edit; the
// residual is stated instead, which is the same tolerance `pageSizeCallSites.guard.test.ts` takes.
return !/\.(test|spec)\.(ts|tsx|mts|cts)$/.test(rootRelativePath) && rootRelativePath !== 'src/setupTests.ts';
}
function generatedDtsText(): string {
const text = rawSourceModules[`/${GENERATED_DTS_PATH}`];
if (text === undefined) {
throw new Error(
`completeAnnotations guard: the glob supplied no content for ${GENERATED_DTS_PATH}. The ` +
`population of droppable schemas is read from that file, so an empty read must fail rather ` +
`than report every schema as having no optional member.`
);
}
return text;
}
function allSchemaOptionalMembers(): Map<string, string[]> {
if (cachedOptionalMembers === undefined) {
cachedOptionalMembers = scanOptionalSchemaMembers(generatedDtsText());
}
return cachedOptionalMembers;
}
function schemasWithOptionalMembers(): Map<string, string[]> {
return new Map([...allSchemaOptionalMembers().entries()].filter(([, optionals]) => optionals.length > 0));
}
// --- the guard ------------------------------------------------------------------------------
describe('Complete<T> application guard (#820)', () => {
it('scans a healthy number of TRACKED source files (anti-vacuity: a broken glob or index read must not pass on zero input)', () => {
expect(trackedInScopeSources().length).toBeGreaterThan(200);
}, 20000);
it('parses a healthy number of schemas out of the generated types (anti-vacuity: a broken d.ts parse must not report zero droppable schemas)', () => {
expect(allSchemaOptionalMembers().size).toBeGreaterThan(100);
}, 20000);
it('discovers a healthy number of Complete<…> annotations (anti-vacuity: a broken scan must not pass on zero matches)', () => {
expect(discoverAnnotations().length).toBeGreaterThan(25);
}, 20000);
it('matches the schemas that can DROP A MEMBER exactly against the reviewed dispositions (not a non-empty check)', () => {
const discovered = new Set(schemasWithOptionalMembers().keys());
const registered = new Set(Object.keys(DISPOSITIONS));
const undispositioned = [...discovered].filter((s) => !registered.has(s)).sort();
const stale = [...registered].filter((s) => !discovered.has(s)).sort();
if (undispositioned.length > 0 || stale.length > 0) {
const optionals = schemasWithOptionalMembers();
throw new Error(
[
`UNDISPOSITIONED (${undispositioned.length}) — schema(s) with an optional member and no row in DISPOSITIONS.`,
'A member outside the OpenAPI `required` array can be silently dropped by a SPA builder; say what should happen about it.',
...undispositioned.map((s) => ` + ${s} (optional: ${optionals.get(s)?.join(', ')})`),
`STALE (${stale.length}) — DISPOSITIONS row(s) whose schema no longer has any optional member:`,
...stale.map((s) => ` - ${s}`)
].join('\n')
);
}
}, 20000);
it('every schema dispositioned ANNOTATED actually carries a Complete<…> annotation in the SPA', () => {
// THE point of this file: it converts the Python registry's `COVERED` wording — "the builder is
// annotated `Complete<T>`" — from a claim into a check. Reverting a covered site to its
// pre-#807 form reddens here.
const annotatedSchemas = new Set(
discoverAnnotations()
.filter((a) => isProductionSource(a.file))
.map((a) => a.schemaName)
.filter((s): s is string => s !== undefined)
);
const required = Object.entries(DISPOSITIONS)
.filter(([, v]) => v.disposition === 'ANNOTATED')
.map(([schema]) => schema)
.sort();
const unannotated = required.filter((s) => !annotatedSchemas.has(s));
expect(
unannotated,
`Dispositioned ANNOTATED but no \`Complete<…>\` names them anywhere in the SPA: ${unannotated.join(', ')}. ` +
`Either the annotation was removed (the member can now be silently dropped on a full replace) ` +
`or the disposition is wrong.`
).toEqual([]);
// Anti-vacuity for THIS assertion specifically: an empty `required` would satisfy it trivially.
expect(required.length).toBeGreaterThan(0);
}, 20000);
it('no schema dispositioned MUST-NOT-ANNOTATE is annotated (§4b: it would force a caller to invent server-computed values)', () => {
const forbidden = new Set(
Object.entries(DISPOSITIONS)
.filter(([, v]) => v.disposition === 'MUST-NOT-ANNOTATE')
.map(([schema]) => schema)
);
const violations = discoverAnnotations()
.filter((a) => a.schemaName !== undefined && forbidden.has(a.schemaName))
.map((a) => `${a.file}:${a.line}:${a.column} Complete<${a.typeName}>`);
expect(violations, `Annotated a schema whose optional members are computed server-side: ${violations.join(', ')}`).toEqual(
[]
);
expect(forbidden.size).toBeGreaterThan(0);
}, 20000);
it('every Complete<…> resolves to a GENERATED schema, so it checks the contract and not a local mirror', () => {
// The finding that produced this assertion: `playouts.ts` declared
// `ReplacePlayoutTemplatesRequest` and `ReplacePlayoutAlternateSchedulesRequest` as hand-written
// object types that SHADOWED generated schemas of the same name. They dropped no field — they
// narrowed `null | Array<T>` to `T[]` — but `Complete<T>` over a hand-maintained mirror proves
// only that the caller filled in the MIRROR, which is the #754 mechanism wearing the annotation
// that is supposed to prevent it.
const unresolved = discoverAnnotations()
.filter((a) => a.schemaName === undefined)
.map((a) => `${a.file}:${a.line}:${a.column} Complete<${a.typeName}>`);
expect(
unresolved,
`\`Complete<…>\` over a type that is not an alias of \`components['schemas'][…]\`: ${unresolved.join(', ')}. ` +
`Alias the generated schema instead of re-declaring its shape.`
).toEqual([]);
}, 20000);
it('scans nearly ALL tracked TypeScript, so a scope edit cannot quietly drop a quarter of it', () => {
// Named pins catch a narrowing that happens to hit one of five files; cold review dropped 65 of
// 257 files with every pin and floor still green. This bounds the whole population instead.
//
// The denominator is deliberately NOT computed through `isInScopeForAnnotations`: a helper on
// both sides of a comparison cancels out of it, which is the #819 finding recorded at length in
// the pageSize guard. It is a different and cruder expression — every tracked TypeScript file —
// so the two can actually disagree. The legitimate gap between them is only `/generated/` and
// `*.guard.test.ts`, about 1.5% today, hence the 95% floor.
const trackedTypeScript = trackedSources.tracked.filter((p) => /\.(ts|tsx|mts|cts)$/.test(p));
const scanned = trackedInScopeSources().length;
expect(trackedTypeScript.length).toBeGreaterThan(200);
expect(
scanned / trackedTypeScript.length,
`only ${scanned} of ${trackedTypeScript.length} tracked TypeScript files reached the scanner. ` +
`The scope predicate excludes \`/generated/\` and \`*.guard.test.ts\` and nothing else, so a ` +
`larger shortfall means it was narrowed.`
).toBeGreaterThan(0.95);
}, 20000);
it('keeps the modules carrying the motivating defects inside the scanned population (scope pin)', () => {
// A floor cannot see SELECTIVE narrowing: cold review showed that excluding one path from
// `isInScopeForAnnotations` stays comfortably above every count while erasing this guard's reach
// over exactly the file whose defect motivated it. These pin the paths rather than the count.
// `isProductionSource` hardcodes 'src/setupTests.ts', mirroring `vite.config.ts`'s `setupFiles`
// with nothing coupling them. Renaming the setup file would leave the exclusion stale, vitest
// would still load the new one for every run, and the round-5 `.spec.ts` bypass would reopen
// green. Pinning it turns that silent staleness into a red — the same remedy as the paths below.
expect(
trackedSources.tracked,
"src/setupTests.ts moved or was renamed; isProductionSource still excludes the old path"
).toContain('src/setupTests.ts');
const files = new Set(trackedInScopeSources().map((s) => s.file));
for (const pinned of [
'src/api/playouts.ts',
'src/api/ffmpegProfiles.ts',
'src/api/multiCollections.ts',
'src/screens/MultiCollectionsScreen.tsx',
'src/screens/ffmpegProfileDraft.ts'
]) {
expect(files.has(pinned), `${pinned} dropped out of the scanned population`).toBe(true);
}
}, 20000);
it('supplies content for every TRACKED in-scope path (a shortfall is a hole, not a skip)', () => {
// The glob/index operation is an inner join, so a tracked in-scope path the glob never yielded
// would vanish from the population silently.
const globbed = new Set(Object.keys(rawSourceModules).map(globKeyToRootRelativePath));
const missing = trackedSources.tracked
.filter((p) => isInScopeForAnnotations(p) && !globbed.has(p))
.filter((p) => !trackedSources.absentFromDisk.includes(p));
expect(missing, `Tracked, on-disk, in-scope path(s) the scanner never saw: ${missing.join(', ')}`).toEqual([]);
}, 20000);
it('no file declares its OWN Complete, which would make an annotation an identity type', () => {
// `Complete<X>` is matched by NAME. A local `type Complete<T> = T` would satisfy every
// annotation assertion here while providing none of the checking — a false green cold review
// demonstrated. Nothing declares one today; this keeps it that way.
const shadows = trackedInScopeSources()
.filter(({ file }) => file !== 'src/api/completeRequest.ts')
.filter(({ file, text }) => declaresLocalComplete(text, file))
.map(({ file }) => file);
expect(shadows, `Local \`Complete\` declaration(s) shadowing the mapped type: ${shadows.join(', ')}`).toEqual([]);
}, 20000);
it('resolves every alias unambiguously — two files may not map the same local name to different schemas', () => {
// The alias map is global and last-write-wins, so a collision would silently credit the wrong
// schema. Detected rather than tolerated.
const byLocal = new Map<string, Set<string>>();
for (const { file, text } of trackedInScopeSources()) {
for (const alias of scanSchemaAliases(text, file)) {
const set = byLocal.get(alias.localName) ?? new Set<string>();
set.add(alias.schemaName);
byLocal.set(alias.localName, set);
}
}
const collisions = [...byLocal.entries()]
.filter(([, schemas]) => schemas.size > 1)
.map(([local, schemas]) => `${local} -> {${[...schemas].join(', ')}}`);
expect(collisions, `Ambiguous local alias name(s): ${collisions.join('; ')}`).toEqual([]);
}, 20000);
it('every disposition row states a reason', () => {
const unexplained = Object.entries(DISPOSITIONS)
.filter(([, v]) => v.note.trim().length < 40)
.map(([schema]) => schema);
expect(unexplained, `DISPOSITIONS rows with no usable note: ${unexplained.join(', ')}`).toEqual([]);
}, 20000);
});
+8 -1
View File
@@ -44,7 +44,14 @@ export function getMultiCollectionWithMeta(id: number): Promise<ResponseWithMeta
return requestWithMeta<MultiCollection>(`/api/v1/multi-collections/${id}`);
}
export function createMultiCollection(body: CreateMultiCollectionRequest): Promise<MultiCollection> {
// `Complete<…>` matches the full-replace update wrapper below, so both entry points to this screen's
// body are the same shape. Be precise about what it buys, because an earlier comment here claimed
// more: `Complete<T>` is SHALLOW, so this requires `name` and `items` to be NAMED and requires
// nothing of each item — `{ items: [{}] }` typechecks. What protects `MultiCollectionItemRequest.weight`
// is the `Complete<MultiCollectionItemRequest>` on `toItemRequest` in `MultiCollectionsScreen`, not
// this. Today the schema has no optional members of its own, so this annotation is a no-op that
// exists to be already in place the day one appears (ersatztv#820).
export function createMultiCollection(body: Complete<CreateMultiCollectionRequest>): Promise<MultiCollection> {
return request<MultiCollection>('/api/v1/multi-collections', { body, method: 'POST' });
}
+14 -8
View File
@@ -45,15 +45,21 @@ export type PlayoutAlternateScheduleItemRequest = components['schemas']['Playout
export type PlayoutTemplate = components['schemas']['PlayoutTemplateResponseModel'];
export type PlayoutTemplateItemRequest = components['schemas']['PlayoutTemplateItemRequest'];
// Declared as `type` (not `interface`) so they satisfy the client's `Record<string, unknown>`
// RequestBody bound, matching the generated request DTOs.
export type ReplacePlayoutAlternateSchedulesRequest = {
items: PlayoutAlternateScheduleItemRequest[];
};
// Aliased from the generated schema rather than re-declared, so `Complete<…>` on the wrapper below
// checks against the API contract instead of a local copy of it (ersatztv#820). These were
// hand-written object types until then; they dropped no field, but a hand-maintained mirror of a
// DTO is the #754 mechanism, and a `Complete<T>` over a mirror proves only that the caller filled
// in the MIRROR. Declared as `type` (not `interface`) so they satisfy the client's
// `Record<string, unknown>` RequestBody bound — an alias of the generated type is already a `type`,
// so that constraint never required re-declaring the shape. Note what the alias ADMITS that the
// mirror did not: `items` is `null | Array<T>`, and the server reads `null` as an empty list —
// clearing every template, or being rejected outright for alternate schedules, which require at
// least one item. That is the real contract; the mirror was narrower than the API, not safer
// than it, and locally re-narrowing a generated type hides the case rather than preventing it.
export type ReplacePlayoutAlternateSchedulesRequest =
components['schemas']['ReplacePlayoutAlternateSchedulesRequest'];
export type ReplacePlayoutTemplatesRequest = {
items: PlayoutTemplateItemRequest[];
};
export type ReplacePlayoutTemplatesRequest = components['schemas']['ReplacePlayoutTemplatesRequest'];
export interface PlayoutsScreenData {
channelStates: PlayoutChannelState[];