Commit Graph
3694 Commits
Author SHA1 Message Date
timothy fe3d29276a fix(706): count a raced SENTINEL, not only a raced human verdict
PR Gates / decisions lifecycle (pull_request) Successful in 19s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 20s
PR Gates / Docs update reminder (pull_request) Successful in 21s
Review verdict / Set review-verdict status (pull_request_target) Successful in 7s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m29s
review-verdict/h10 Review-verdict: MERGEABLE @ fe3d292 (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m54s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 15m49s
Round-5 cold review: the post-write check counted only human `Review-verdict:`
rows above the high-water mark, which is not sufficient under the run overlap
this branch measured.

Sequence, all inside that regime, runs A and B on the same exempt-classified
sha: the human BLOCKED lands BELOW A's mark (so A cannot see it), B masks it
with an exemption `success`, and only afterwards writes the sentinel. A then
finds nothing human above its mark, does not repair, and posts its own `success`
on top of the sentinel. The human rejection is permanently green and every later
run re-derives it — the repair race failing toward SUCCESS, while the record
states it fails toward `pending`.

The filter now counts two row shapes above the mark: a human verdict (non-null
creator, `Review-verdict:` description) OR a machine sentinel (null creator,
description exactly $REPAIR_DESC). A then repairs and both runs converge on the
fixed point.

It cannot false-fire: a pre-existing sentinel would have been seen at the FIRST
read and forced the pending path, and this block only runs after a `success`, so
a sentinel above the mark can only have been written mid-flight by another run.

Mutation-verified on both halves independently — dropping the sentinel
alternation reddens the new test; dropping the human half reddens the original
race-2 test — so neither can be removed without a test noticing.

Refs #706
2026-08-03 22:22:09 +02:00
timothy a8bbd74a64 fix(706): never replace a sentinel with a non-sentinel
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
review-verdict/h10 Awaiting review verdict for a8bbd74
Review verdict / Set review-verdict status (pull_request_target) Successful in 8s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m31s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m52s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m39s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m22s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Round-4 cold review: the mid-run sentinel guard tested `state = success`, which
is one branch too narrow. A run can reach the POST on `state=pending` carrying
the GENERIC description — most realistically after a transient enumeration
failure (`complete != yes`) — and such a run passed the success-only guard,
passed the fence, and overwrote the sentinel with ordinary text. The next run
then saw no sentinel, re-derived, and posted `success`: the same buried human
rejection as the round-2 defect, reached in two steps instead of one.

The guard now compares the DESCRIPTION rather than the state:

    if [ "$ex_repair" = yes ] && [ "$desc" != "$REPAIR_DESC" ]

"Never replace a sentinel with a non-sentinel." This is strictly more general
and exactly as precise, because the carry-forward branch guarantees that a
sentinel seen at the FIRST read already sets `desc` to the sentinel — so the
guard cannot fire on the ordinary repaired-head path and the fixed point stays
intact.

It also makes the code match the decision record, which already stated the
general property ("a run whose last-moment re-read finds a sentinel it did not
see at its FIRST read ABSTAINS instead of posting") while the code implemented
only the success case. Of the two, the code was the one that had to move.

Mutation-verified on both clauses independently: reverting to the success-only
condition reddens the new pending-path test; dropping the description check
reddens the fixed-point test and the sentinel-from-the-start control.

Refs #706
2026-08-03 22:13:36 +02:00
timothy 5077408528 fix(706): abstain when a repair sentinel appears mid-run
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 18s
review-verdict/h10 Awaiting review verdict for 5077408
Review verdict / Set review-verdict status (pull_request_target) Successful in 10s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m46s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m5s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m47s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 23m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Round-3 cold review: `ex_repair` was recomputed by the last-moment re-read but
never consulted after it, so the POST wrote the `$state` frozen at
classification time. A stale overlapping run therefore posted its `success`
straight over a sentinel another run had just written — burying a human
rejection with no repair (the human row sits below the stale run's own
high-water mark) and no log entry.

This is the one path in the design that failed toward SUCCESS rather than
`pending`, so it was not covered by the recorded residual, and it is reachable
through exactly the run overlap this branch measured live (probe PR #722: the
older run finished 20s after the newer one started).

The guard is exact rather than conservative: a sentinel present at the FIRST
read forces `state=pending`, so `success` together with `ex_repair=yes` at
re-read time can only mean the sentinel arrived mid-run. Abstaining is then
strictly correct and, unlike the retarget fence, needs no successor run — the
sentinel row is already `pending` and already carries the re-post instruction.

Mutation-verified three ways: removing the guard reddens the new mid-run test
while its positive control stays green; making it unconditional on `ex_repair`
reddens the fixed-point test and the positive control, proving the condition is
precisely scoped and not merely present.

Also strengthens the mark-ordering test to pin the status-history FETCH as well
as its initialisation, closing the refactor evasion review flagged; sliding the
fetch past the re-read now reddens it.

Refs #706
2026-08-03 22:06:42 +02:00
timothy 63040296f4 fix(706): make the repair sentinel a fixed point, not a two-event delay
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 17s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
review-verdict/h10 Awaiting review verdict for 6304029
Review verdict / Set review-verdict status (pull_request_target) Successful in 35s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m44s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 18s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m0s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Round-2 cold review found the round-1 sentinel self-clobbering: the branch
refused the exemption but fell through to the shared else, which posts the
GENERIC "Awaiting review verdict" description — erasing the very marker the
refusal depends on. The next run saw an ordinary machine `pending`, re-derived
it, and posted `success`, burying the human rejection two events after the
repair instead of one.

The single-hop test passed throughout, and the positive control asserting that
an ordinary machine `pending` DOES re-derive was itself the proof of the second
hop. Durability is a fixed point, and only a chain can assert a fixed point, so
the new test runs the job twice and feeds run N's own posted description in as
run N+1's existing status.

Keyed on `ex_repair` alone rather than on the exempt path: the fact recorded is
"a human verdict was lost on this sha", a property of the sha rather than of
this run's classification.

Verified by mutation — restoring the defect turns the chained test RED while the
single-hop test stays GREEN, which is exactly why the chain was needed.

Refs #706
2026-08-03 21:58:06 +02:00
timothy 8f6d4f4432 fix(706,707,711): fence the review-verdict write on the timeline retarget count
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 23s
review-verdict/h10 Awaiting review verdict for 8f6d4f4
Review verdict / Set review-verdict status (pull_request_target) Successful in 20s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m26s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m56s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m59s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Three related defects in the `review-verdict/h10` gate, all surfaced by the
cross-family review of PR #705.

#706 race 1 — a stale run could overwrite a fresher verdict, permanently. The
race was reproduced live rather than reasoned about (Gitea 1.25.4): with every
other workflow stripped, probe PR #722 showed run 7520 (`opened`) finishing 20s
AFTER run 7521 (`synchronize`) started. `pull_request_target` runs for one PR
genuinely overlap, older finishing last.

The issue proposed serializing with a non-cancelling concurrency group. That is
REFUTED by measurement: with the group active, runs 7528/7529 still overlapped
and 7528 ended 36s after 7529 began. A first probe appeared to show the group
working — a negative control with no `concurrency:` key at all showed the same
cancellations, revealing Gitea auto-cancels superseded `push` runs on its own
and the probe had measured that, not the group. The auto-cancel does not extend
to `pull_request_target`.

The fix leaves the runs unserialized and instead makes an overtaken run decline
to write: count `change_target_branch` events on the PR timeline at start and
again just before the POST, and post nothing if the count moved. The COUNT is
the key because the branch NAME is ABA-vulnerable (`main -> S -> main` reads
`main` at both ends — how #698 route 1 forged its exemption). Abstaining is a
handoff, not a stall: every retarget fires `edited`, so the event that makes a
run abstain has already queued its successor. `updated_at` was rejected as the
key precisely because it moves for comments/labels, which queue nothing.

#706 race 2 — a human BLOCKED landing in the unclosable window between the
pre-POST re-read and the POST was silently turned green. After an exemption
`success` the job now re-reads the per-POST history and repairs its own status
to `pending` if a human verdict appeared above a high-water mark taken just
before the write. The repair is `pending`, never a copy of the human's state.
The id comparison is load-bearing: a presence test would fire forever on a
base-mismatched verdict and deadlock that PR's exemption.

#707 — `pr-changed-files.sh` bound `.base.ref` and `.head.sha` across the
enumeration but never `.base.sha`, so an ordinary advance of `main` mid-paging
could drop a code path from an offset-paged diff and leave a complete-looking
docs-only list. Now bound from the JSON already fetched (no new round trips).

#711 — `.codex/` added to PROTECTED. It mirrors `.claude/hooks/` byte for byte,
including the merge-consent hook, so the "a PR that can weaken the gate cannot
exempt itself" rule had an incomplete path list. Latent today (untracked), live
the moment anyone tracks it.

Residuals are stated, not implied: a retarget inside the final round-trip, and
the repair being itself a read-then-write. Gitea's status API has no
compare-and-set, so neither reaches zero; both now fail toward `pending`.

Tests: 398 pass in scripts/tests. Each new guard was mutation-checked — the
fence's motion comparison, the untrusted-count gate, the repair POST and the id
high-water mark were each neutered in turn and the intended test went red while
its positive control stayed green.

fixes #706
fixes #707
fixes #711

Decisions-Edit: yes
2026-08-03 21:41:16 +02:00
timothy 7cd71327b0 Merge pull request 'chore(deps): update dependency cliwrap to 3.10.4' (#717) from renovate/cliwrap-3.x into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 18s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 31s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 11m9s
Reviewed-on: #717
2026-08-03 19:28:23 +00:00
timothy 53e6be8390 Merge branch 'main' into renovate/cliwrap-3.x
PR Gates / Docs update reminder (pull_request) Successful in 22s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 19s
review-verdict/h10 Exempt: authored by the 'renovate' bot account, touches no protected path, and changes only dependency manifests
Review verdict / Set review-verdict status (pull_request_target) Successful in 24s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m14s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 58s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m3s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m56s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
PR Gates / decisions lifecycle (pull_request) Successful in 16s
2026-08-03 18:50:54 +00:00
timothy b99812eb8b Merge pull request 'chore(deps): update dependency jetbrains.resharper.globaltools to 2025.3.5' (#718) from renovate/jetbrains.resharper.globaltools-2025.x into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 45s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 45s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 13m2s
Reviewed-on: #718
2026-08-03 18:50:39 +00:00
timothy 03662dcdfd Merge branch 'main' into renovate/cliwrap-3.x
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 17s
PR Gates / decisions lifecycle (pull_request) Successful in 51s
PR Gates / Docs update reminder (pull_request) Successful in 47s
review-verdict/h10 Exempt: authored by the 'renovate' bot account, touches no protected path, and changes only dependency manifests
PR Gates / Script tests (pytest) (pull_request) Successful in 1m15s
Review verdict / Set review-verdict status (pull_request_target) Successful in 24s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m37s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-08-03 18:13:53 +00:00
timothy aaa4e869b8 Merge branch 'main' into renovate/jetbrains.resharper.globaltools-2025.x
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 19s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 24s
PR Gates / Docs update reminder (pull_request) Successful in 20s
PR Gates / decisions lifecycle (pull_request) Successful in 32s
review-verdict/h10 Exempt: authored by the 'renovate' bot account, touches no protected path, and changes only dependency manifests
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m39s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m4s
Review verdict / Set review-verdict status (pull_request_target) Successful in 2m7s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 18m27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 23m8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 24m23s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-08-03 18:12:59 +00:00
timothy 9dc360c9fa Merge pull request 'docs(release): record the v26.13.0 release notes' (#716) from release/v26.13.0 into main
Build CI Toolchain Image / Build & push CI image (push) Successful in 28s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m34s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m40s
v26.13.0
2026-08-03 17:02:23 +00:00
renovate 95f36d1c45 chore(deps): update dependency jetbrains.resharper.globaltools to 2025.3.5
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 15s
review-verdict/h10 Exempt: authored by the 'renovate' bot account, touches no protected path, and changes only dependency manifests
PR Gates / decisions lifecycle (pull_request) Successful in 24s
Review verdict / Set review-verdict status (pull_request_target) Successful in 24s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m42s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 20m8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m48s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-08-03 17:02:21 +00:00
renovate ff8b0bf984 chore(deps): update dependency cliwrap to 3.10.4
PR Gates / decisions lifecycle (pull_request) Successful in 30s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 42s
PR Gates / Docs update reminder (pull_request) Successful in 49s
review-verdict/h10 Exempt: authored by the 'renovate' bot account, touches no protected path, and changes only dependency manifests
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m0s
Review verdict / Set review-verdict status (pull_request_target) Successful in 30s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m1s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 32s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 21m52s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 25m25s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 28m5s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-08-03 17:02:11 +00:00
TimothyandClaude Opus 5 016a05ced8 docs(release): record the v26.13.0 release notes
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 25s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 31s
PR Gates / decisions lifecycle (pull_request) Successful in 45s
PR Gates / Docs update reminder (pull_request) Successful in 44s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 56s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m4s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 1m4s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m18s
review-verdict/h10 Exempt: docs-only change (no code, no protected path)
Review verdict / Set review-verdict status (pull_request_target) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 27s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
73 PRs merged since v26.12.0. Adds the release-table row in docs/ci-cd.md
covering the RuleBuilder maturation, the On Now/Next overlay, searchable
library pickers, the watermark/QSV correctness fixes, and the H10
review-verdict gate hardening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:40:58 +02:00
timothy 9928be805f Merge pull request 'chore(deps): batch three Renovate patch bumps (supersedes #679, #680, #681)' (#714) from chore/renovate-batch-2026-07-30 into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 16s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 28s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 10m54s
Build CI Toolchain Image / Build & push CI image (push) Successful in 9m5s
Dependency vulnerability scan / NuGet vulnerable packages (push) Successful in 1m10s
Renovate / Renovate (push) Successful in 2m20s
2026-07-30 21:31:32 +00:00
timothy fbbdaeca3c chore(deps): batch three Renovate patch bumps
PR Gates / Docs update reminder (pull_request) Successful in 22s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 25s
PR Gates / decisions lifecycle (pull_request) Successful in 32s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 58s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 57s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m8s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
review-verdict/h10 Review-verdict: MERGEABLE @ fbbdaec (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 5s
Consolidates the three open Renovate PRs into one change so they land on a
single CI pipeline instead of three. They all edit Directory.Packages.props,
so merging them individually would force a rebase + full re-run for each
remaining PR.

  CliWrap                        3.10.2  -> 3.10.3
  Meziantou.Analyzer             3.0.115 -> 3.0.129
  SQLitePCLRaw.bundle_e_sqlite3  3.0.3   -> 3.0.4

Also refreshes the #8 security-pin comment, which named 3.0.3 explicitly.
Renovate only rewrites the version attribute, so its own PR would have left
that comment contradicting the line directly beneath it. The pin's intent is
unchanged: stay on the 3.x line that ships the patched native SQLite
(GHSA-2m69-gcr7-jv3q), and 3.0.4 still satisfies Microsoft.Data.Sqlite's
`>= 2.1.10`.

All three original PRs went red on 2026-07-27, but none of the failures
reached any code. The runner host had exhausted its disk at ~03:20 UTC:
tar cache-restore failing with "No space left on device", SQLite Error 13
"database or disk is full", and ErsatzTV refusing to boot for want of 128 MB
of free space. #681's migration job in particular died during cache restore,
before either the SQLite or MySQL half ran, so the native-bundle bump was
never actually exercised there.

Verified locally on this combined change:
  - dotnet build -c Release: 0 errors; zero MA/S/CA analyzer diagnostics, so
    the Meziantou 3.0.115 -> 3.0.129 jump introduces no new rules that bite
  - full test suite: 4440 passed, 0 failed across all 7 test projects
  - SQLite model drift clean + all migrations applied to a fresh DB, which is
    the exact job that was red on #681

Supersedes #679, #680, #681.
2026-07-30 22:58:01 +02:00
timothy f9cbd152bc Merge pull request 'chore: ignore .codex/, and stop shipping a plaintext credential in docs' (#712) from chore/codex-ignore-and-credential-redaction into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 16m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 20m5s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 22m4s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m18s
2026-07-30 20:27:33 +00:00
timothyandClaude Opus 5 980da6db00 chore: ignore .codex/, and stop shipping a plaintext credential in docs
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
PR Gates / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
Review verdict / Set review-verdict status (pull_request_target) Successful in 21s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m3s
review-verdict/h10 Review-verdict: MERGEABLE @ 980da6d (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m44s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
.codex/ is generated by `codex exec` as a machine-local mirror of the .claude hooks.
It is deliberately NOT tracked even though .claude/ is (17 files): its config.toml
embeds a plaintext Gitea credential and absolute /Users paths, so committing it would
leak the credential and would not be portable anyway. Ignoring it also unblocks
scripts/refresh-shared-checkout.sh, which refuses on a dirty tree.

Separately, docs/handoffs/rest-api.md carried the same credential inline; it now
references $ETV_GITEA_BASICAUTH like every other doc. NOTE this does not purge git
history — the literal appears in 12 earlier commits and is still recoverable there.

Refs: #698
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:59:27 +02:00
timothy 81be685df9 Merge pull request 'docs(698): correct the Renovate auto-pass rule in CLAUDE.md' (#710) from docs/698-claudemd-renovate-rule into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 38s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 38s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 39s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 10s
2026-07-30 19:58:03 +00:00
timothyandClaude Opus 5 1eca9b0c11 docs(698): correct the Renovate auto-pass rule in CLAUDE.md
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 16s
PR Gates / Docs update reminder (pull_request) Successful in 18s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 39s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 38s
review-verdict/h10 Exempt: docs-only change (no code, no protected path)
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 38s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 38s
Review verdict / Set review-verdict status (pull_request_target) Successful in 24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
PR Gates / Script tests (pytest) (pull_request) Successful in 1m0s
CLAUDE.md still described the bot exemption as identity-only (auto-passed unless a
protected path is touched). Since #698 it also requires EVERY changed path to be a
dependency manifest — a bot account does not attribute the code at a head. CLAUDE.md is
loaded every session, so a stale rule here is worse than a stale doc.

Refs: #698
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:56:15 +02:00
timothy a3458e6e2c Merge pull request 'fix(698): bind the base, constrain the bot exemption by content, re-derive unattributable successes' (#705) from fix/698-exemption-provenance into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 37s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 38s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 16m20s
2026-07-30 19:22:01 +00:00
timothyandClaude Opus 5 57e33f9937 chore(698): drop a trailing blank line at EOF
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 15s
PR Gates / Docs update reminder (pull_request) Successful in 18s
PR Gates / decisions lifecycle (pull_request) Successful in 24s
Review verdict / Set review-verdict status (pull_request_target) Successful in 12s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m31s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
review-verdict/h10 Review-verdict: MERGEABLE @ 57e33f9 (base: main)
Nit from review round 6 (git diff --check). No behaviour change.

Refs: #698
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:02:41 +02:00
timothyandClaude Opus 5 fe00e0d71f fix(698): compare the recorded base exactly, never parse it out
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 40s
PR Gates / decisions lifecycle (pull_request) Successful in 45s
review-verdict/h10 Awaiting review verdict for fe00e0d
Review verdict / Set review-verdict status (pull_request_target) Successful in 14s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m23s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m34s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m12s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 21m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 24m7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Review round 5 returned BLOCKED with one High, and it needed no forgery and no #697 —
just a branch name.

`main)evil` IS A VALID GIT BRANCH NAME (`git check-ref-format --branch 'main)evil'`
succeeds). A genuine human verdict earned while head H targeted it is written
`(base: main)evil)`. Truncating at the first `)` yields exactly `main`, which matches a
PR that has since been retargeted onto `main`, so the verdict is inherited over a
completely different diff.

I had asserted the opposite in a code comment one commit earlier — that a `)` in a
branch name "mismatches — safe direction". That was generalised from `feat/foo)bar`,
which does mismatch, and is false for EVERY branch whose name starts with the target
base. Two attempts at extracting this value have now been defeated (`##` last-marker by
an appended marker, `#` first-marker by this), so the lesson is the shape, not the
off-by-one: do not parse a value out of user- or attacker-influenced text when you can
compare against the exact expected literal instead.

The description must now END with the literal `(base: <this PR's base>)` AND contain
exactly ONE marker — the marker count kills the append trick without having to decide
which occurrence is authoritative. Pure shell (`${#}` arithmetic), no truncation to
abuse. Verified across all six shapes, including a PR that legitimately targets
`main)evil` (accepted) and `(base: )` (rejected). Absent markers remain accepted, since
verdicts predating #632 carry none.

Mutation-verified: restoring the truncating parse reddens only the new paren test, while
the appended-marker, matching-base and legacy tests stay green.

385 tests pass. Note for the record: pytest has never executed inside the review sandbox
in any of the five rounds, so the suite has only ever been run here.

Refs: #698
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:51:57 +02:00
timothyandClaude Opus 5 ef92b46dd2 fix(698): parse the recorded base at its FIRST occurrence, not its last
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 17s
PR Gates / Docs update reminder (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Successful in 28s
review-verdict/h10 Awaiting review verdict for ef92b46
Review verdict / Set review-verdict status (pull_request_target) Successful in 23s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m31s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m31s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m40s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m35s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Self-found while writing the round-5 review brief, by asking what an attacker who can
influence the status description (#697) could do to the parse I had just added.

`${ex_desc##*"(base: "}` is greedy, so it reads the LAST occurrence. A description of
`Review-verdict: MERGEABLE @ abc1234 (base: probe/scratch) (base: main)` therefore parsed
as `main`, matched the PR's base, and the verdict was inherited — reopening the exact hole
the base check was added to close, one commit earlier. Measured both forms before choosing:
first-match yields `probe/scratch`, mismatches, and fails closed.

Two adjacent cases confirmed to fail in the safe direction: a `)` inside a branch name
truncates the value (mismatch), and an empty `(base: )` is present-but-different (mismatch),
so neither is waved through by the legacy-absent-base allowance.

Tests for both, and the appended-base test is mutation-verified: restoring `##` reddens it.

384 tests pass.

Refs: #698
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:11:08 +02:00
timothyandClaude Opus 5 e7bae06385 fix(698): review round 5 — a human verdict formed against ANOTHER base is no longer inherited
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 23s
review-verdict/h10 Awaiting review verdict for e7bae06
PR Gates / decisions lifecycle (pull_request) Successful in 31s
Review verdict / Set review-verdict status (pull_request_target) Successful in 21s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m5s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m13s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Round-4 cross-family review returned BLOCKED with a single Medium; the three round-3
items were confirmed actually fixed.

THE SHA-BINDING WAS ESCAPABLE THROUGH THE HUMAN PATH, not the exemption path. The
short-circuit identified a human verdict by creator + `Review-verdict:` prefix and then
exited before looking at the base. So: earn a GENUINE `success` on head H while it
targets a scratch base with a benign diff, then retarget H onto `main`, where its diff
carries unreviewed code. Creator real, prefix real, status inherited — a green required
check over code nobody reviewed. `post-review-verdict.sh` has recorded the reviewed base
in the description since #632; this gate simply never read it. The merge-consent hook
did compare it, but that is advisory and covers only its own path: a merge through the
Gitea UI or API sees nothing but the status.

The gate now rejects a verdict whose recorded base differs from the PR's. An ABSENT base
is deliberately NOT a mismatch — verdicts predating #632 carry none, and re-deriving over
one would un-approve a genuinely reviewed head. Only present-and-different is rejected,
which is exactly the escape.

Tests: the mismatch case, plus two positive controls (matching base still short-circuits;
a legacy no-base verdict still short-circuits) so the check cannot pass by blanket
rejection. Mutation-verified: removing the check reddens only the mismatch test.

Also from round 4: sharpened the docstring of test_the_classify_step_runs_without_SHELL_ERRORS.
It catches guards that die NOISILY; it is not a general liveness check, since a clean
mutation like hardcoding n_protected=0 emits nothing. The branch-discriminator test is the
actual liveness guard. Claiming otherwise would have made a cheap net look like a strong one.

And fixed a dangling decision key I had just introduced: the base-in-description convention
belongs to `release.verdict-status-check`, not the `ci.verdict-records-base` I invented —
the breadcrumb hazard our own retrieval rules warn about.

382 tests pass.

Refs: #698
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:44:44 +02:00
timothyandClaude Opus 5 d4c600149d fix(698): review round 4 — the PROTECTED guard was DEAD; define before use, fail closed, fix prescriptive docs
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 16s
PR Gates / Docs update reminder (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
review-verdict/h10 Awaiting review verdict for d4c6001
Review verdict / Set review-verdict status (pull_request_target) Successful in 13s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m38s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Round-3 cross-family review returned BLOCKED with 3 Mediums. The first was serious
and self-inflicted.

THE PROTECTED GUARD WAS A NO-OP. Round 3's `count_matching` / `count_not_matching`
helpers were defined AFTER the classification chain that calls them, so
`count_matching` was `command not found` on every run, `$( )` yielded an empty string,
`[ "" -gt 0 ]` errored, and the `elif` was simply skipped — the protected-path check
never executed at all. Confirmed by direct execution before fixing.

Three "protected path" tests stayed GREEN throughout, because a protected path is also
not a manifest and not docs-only, so the job still reached `pending` down a different
route. Asserting the STATE could not distinguish a working guard from a dead one. The
mutation battery missed it too: I had mutated the predicates, not their reachability.

Fixed three ways:
  * helpers are defined immediately after `gh()`, before any use;
  * the three counts are evaluated ONCE at TOP LEVEL and validated numeric, because
    `exit 1` inside `$( )` leaves only the subshell and, with the substitution sitting
    in a conditional, `set -e` never fires either — so a grep error had been silently
    reading as "no match". A non-numeric result now aborts with nothing posted, and an
    absent required check blocks the merge;
  * the helpers return a non-numeric sentinel instead of trying to `exit`.

Verified: an invalid regex now exits 2 and posts NOTHING (previously it classified and
posted). Renaming the helper at its definition turns six tests red.

TESTS, aimed at the failure mode rather than the symptom:
  * assert the DISCRIMINATOR (the job's `Decision:` reason line), not the outcome —
    when several branches yield the same verdict, the verdict cannot tell you which ran.
    A first draft of this test asserted the status description and failed against a
    WORKING guard, because for `pending` the description is constant;
  * a cheap stderr sweep for `command not found` / `integer expression expected` /
    `unbound variable` across four representative PR shapes. Each of those makes an `if`
    condition merely false while the job exits 0 and posts a plausible status, so this
    catches a whole family of silently-skipped guards.

DOCS. The record and ci-cd.md still PRESCRIBED the here-string that round 3 removed —
following them would have reintroduced the temp-storage failure. Both now prescribe
counting, define-before-use, top-level evaluation and numeric validation. The workflow's
measurement paragraph still said the npm manifests "are included" three lines above the
note saying they are excluded; corrected.

379 tests pass.

Refs: #698
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:13:56 +02:00
timothyandClaude Opus 5 d8bd1dcba9 fix(698): review round 3 — count instead of matching, re-read before the POST, fix stale docs
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 18s
PR Gates / Docs update reminder (pull_request) Successful in 23s
PR Gates / decisions lifecycle (pull_request) Successful in 31s
review-verdict/h10 Awaiting review verdict for d8bd1dc
Review verdict / Set review-verdict status (pull_request_target) Successful in 36s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 19s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m59s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Round-2 cross-family review returned BLOCKED: 2 High + 3 Medium.

HIGH — here-strings traded one fail-open for another. `grep -q… <<< "$data"` fixes
the SIGPIPE inversion, but bash materialises a large here-string via temporary
storage, so it fails when temp space is full or unwritable — and since these sit
inside `if`/`!`, that failure flips the predicate exactly as SIGPIPE did. It did NOT
reproduce on my bash 3.2, DID on the reviewer's Linux bash 5.x, and CI is Linux; the
disagreement is itself the argument for a construct that cannot fail either way.

Path predicates now COUNT with `grep -c`, which drains stdin (no early exit, no
SIGPIPE) over an ordinary pipe (no temp file), and grep's status is read honestly:
exit 1 means "zero matches", a legitimate answer, while >1 is a real error that FAILS
THE JOB rather than silently reading as "no match". `set -e` does not catch these on
its own — they sit in command substitution inside a conditional. Verified correct
under 171KB input AND an unwritable TMPDIR. The description test became a `case`
prefix match, removing another pipeline from a security predicate. New record
`ci.grep-q-pipefail-inversion` covers the whole class.

HIGH — a human verdict landing mid-run was still overwritten, and the code claimed
otherwise. The job read statuses once, classified over several round-trips, then
posted: a reviewer posting BLOCKED in between had it replaced by an exemption
`success`, turning an explicit rejection into a merge. Added a re-read immediately
before the POST which refuses to write over a human verdict found then. The heading
no longer says "never overwrite" — it cannot promise that, since there is no
compare-and-set on Gitea's status API. Remainder tracked as #706.

MEDIUM — documentation was stale in three places, all mine. The record's frontmatter
`rule:` still listed the npm manifests (I fixed the body and forgot the frontmatter,
so the canonical rule AND the generated catalog were wrong); docs/ci-cd.md still said
`edited` was absent from `types:`, contradicting a section I had just updated; and the
workflow header still implied the `edited` re-run settles the ABA race. All corrected
to say detection, not atomicity.

TESTS. 373 pass. New: a mid-run human verdict via a status stub that returns nothing
on the first read and BLOCKED on the re-read, and large-input regression tests for the
ADVISORY hook, which had none — the copy with less authority is the one that quietly
keeps a bug (#649's whole point). Mutation-verified: reverting the hook predicate,
the workflow predicates, or the pre-POST re-read turns exactly the intended tests red
while every positive control stays green.

Refs: #698
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 20:53:36 +02:00
timothyandClaude Opus 5 bafb487eaa fix(698): review round 2 — close a PRE-EXISTING SIGPIPE inversion, narrow the bot allow-list, correct two overclaims
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 15s
PR Gates / Docs update reminder (pull_request) Successful in 24s
PR Gates / decisions lifecycle (pull_request) Successful in 33s
review-verdict/h10 Awaiting review verdict for bafb487
PR Gates / Script tests (pytest) (pull_request) Successful in 59s
Review verdict / Set review-verdict status (pull_request_target) Successful in 34s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m30s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m36s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Cross-family adversarial review returned BLOCKED with 3 Blockers + 2 Highs. Two
were verified independently before acting on them.

BLOCKER 1 (pre-existing on main, not introduced here). `producer | grep -q…`
inverts under `set -o pipefail`: grep exits at its first match, the producer takes
SIGPIPE and exits 141 once the list exceeds the pipe buffer, so a MATCH is reported
as a FAILED pipeline. Reproduced at 1901 paths / ~171KB, inside the enumerator's
2000-file cap:
  * `A.cs` + 1900 docs paths  -> docs_only=yes, status 141 (a C# PR exempted)
  * `.gitea/…` + 1900 docs    -> the PROTECTED guard MISSES entirely
The second reaches a green required status on a PR rewriting the gate, with no
retarget, no bot account and no forged status. This construct shipped with the
original docs-only check (review-verdict.yml:257 on main), so it was live. Fixed in
both the enforced gate and the advisory hook by using here-strings, which have no
writer to signal. New record `ci.grep-q-pipefail-inversion`.

BLOCKER 2 (mine). `web/package.json` / `web/package-lock.json` are removed from
BOT_MANIFESTS. renovate.json enables only nuget/github-actions/dockerfile, so npm is
unmanaged here and the entry bought nothing — while package.json `scripts` are
EXECUTED by CI (npm ci, npm run build). It widened an exemption onto a
code-execution path for no benefit.

BLOCKER 3 + HIGH (documentation was wrong, code unchanged). The claim that `edited`
made the retarget residual "non-durable" is retracted: runs are not serialized, so a
stale run can post `success` after the reclassifying run posts `pending`. The ABA
transition is narrowed and observable, NOT closed. Likewise the provenance check
asks "posted by a user credential", not "posted by a reviewer" — ETV_STATUS_AUTH is
basic auth, so a #697 forgery gets a non-null creator AND an attacker-chosen
description and is preserved as human. Both now stated at full strength.

TESTS. 4 large-input cases crossing the pipe buffer, each paired with a large-input
POSITIVE control so "large lists now fail closed" (a deadlock) cannot pass as a fix.
Verified by mutation: reverting the here-strings turns all three negatives red while
the control stays green. Two of my own weak tests fixed — the "base advances" case
called head_moves_to(SHA) with the already-current sha (a duplicate positive control,
now a structural assertion that the comparator is .base.ref and never .base.sha), and
the arity test counted five arguments without checking the fifth was the base.

The whole class was invisible because every previous test used a handful of short
paths: a guard whose behaviour depends on a buffer threshold needs a test that
crosses it.

Refs: #698
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 20:35:29 +02:00
timothyandClaude Opus 5 f523fc535d fix(698): bind the base, constrain the bot exemption by content, re-derive unattributable successes
PR Gates / decisions lifecycle (pull_request) Successful in 24s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 27s
PR Gates / Docs update reminder (pull_request) Successful in 28s
Review verdict / Set review-verdict status (pull_request_target) Successful in 17s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m1s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m8s
review-verdict/h10 Review-verdict: BLOCKED @ f523fc5 (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 23m34s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The `review-verdict/h10` exemption path decided from mutable or unattributed PR
state, and a machine-written `success` was never revalidated. Three routes, one
root cause, so one change.

Route 1 (reproduced live as probe PR #703, closed unmerged): `/pulls/{n}/files`
diffs against the PR's LIVE base, so retargeting moves the answer without moving
the head sha. A PR opened into `main` and retargeted mid-run enumerated docs-only
and was granted `h10=success` while its diff against `main` carried a C# file;
retargeting back reclassified nothing. `scripts/pr-changed-files.sh` now takes the
expected base branch as a REQUIRED 5th argument (optional would be a silent
opt-out) and checks it before and after paging; the workflow passes it from the
`pull_request_target` payload, which a retarget cannot rewrite, and `edited` is in
`types:` so a retarget reclassifies.

A pinned two-sha diff would close route 1 outright but Gitea 1.25.4 cannot serve
one: `compare/{base}...{head}` returns no `files`, and a `--depth=1` fetch of the
two shas has no merge base. Measured, not assumed. The residual window is stated
in the code and the record rather than papered over.

Route 2: `pull_request.user.login` is the PR's immutable CREATOR while its head is
not, so pushing code onto an open Renovate branch kept the exemption. The bot
exemption now also requires EVERY path to be a dependency manifest — a set measured
across all 11 Renovate PRs this repo has had, not guessed.

Route 3: the never-overwrite short-circuit exited on ANY `success`, so a forgery
obtained once was inherited forever. It now fires only for a status positively
identified as a human verdict (non-null `.creator.login` AND a `Review-verdict:`
description — measured: user-posted statuses carry a creator, Actions-posted ones
carry null). Written in the positive direction so an unrecognised shape is
re-derived rather than trusted.

The two exemptions are composed, not chained: as an `elif` chain a Renovate
docs-only PR lost the docs-only exemption. Caught before commit and pinned by a
test.

Tests: 17 new cases in scripts/tests/test_pr_changed_files.py, each verified by
mutating the clause it covers (8 mutations, 8 kills). Both records trimmed under
the 60-line prose ceiling so the corpus tail-boundary check stays calibrated.

Does NOT close the class: anyone who can POST a status directly can still
impersonate a verdict — that is #697, deliberately left open.

Refs: #698
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:52:00 +02:00
timothy 0c492defac Merge pull request 'fix(672): trigger the verdict gate on pull_request_target scoped to main' (#699) from fix/672-review-verdict-head-resolution into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 16m19s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 20m21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 23m5s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 9m39s
2026-07-28 21:44:19 +00:00
timothy 4e2ea61674 Merge pull request 'fix(691): guard the nullable SongMetadata.Artists/AlbumArtists at their read sites' (#700) from fix/691-song-artists-null-guard into main
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 17s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 31s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 33s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-28 21:43:29 +00:00
timothy ceef16081d docs(672): make the self-test gap discoverable (signals + section pointer)
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Script tests (pytest) (pull_request) Successful in 51s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 20s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m50s
review-verdict/h10 Review-verdict: MERGEABLE @ ceef160 (base: main)
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m57s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Re-review of the round-2 head returned MERGEABLE with one LOW: the record's
`signals:` field did not mention the self-test gap. That field is the semantic
discovery surface -- it is what the MemPalace mirror matches on -- so the most
operationally dangerous property of this change ("a gate edit goes live only on
merge, having never run") was unreachable by anyone searching for it. Someone
asking "how do I test a change to review-verdict.yml" would have found nothing.

Adds three signal phrases and points the record's one-line reference at a
section rather than at a ~1050-line file.

No behaviour change; frontmatter and prose only. Verified the frontmatter still
parses under PyYAML rather than the validator's hand parser, per #674 -- an
apostrophe in a single-quoted scalar is exactly what that hand parser cannot see.

Refs: #672
Decisions-Edit: yes
2026-07-28 23:17:14 +02:00
timothy 20b7171fba fix(672): review round 2 -- correct a stale rule: field, document the self-test gap
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Successful in 26s
PR Gates / Script tests (pytest) (pull_request) Successful in 52s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 20s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m22s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m49s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Re-review of the fix commits returned MERGEABLE (nine trigger mutations all
caught, every prior finding verified against independent sources) with four
low-severity findings. All four are addressed here.

F1: `release.verdict-status-check`'s `rule:` frontmatter still said "A
`pull_request` workflow auto-passes the two exempt classes". Round 1 past-tensed
that record's BODY and left its `rule:` stale -- which is the exact failure mode
the previous commit cites as the reason to put limitations in `rule:` in the
first place. The catalog row mirrors this field verbatim and it mirrors again
per-`key:` into MemPalace, so a stale `rule:` propagates further than a stale
paragraph.

F2: same record, "is what makes the rollout self-hosting" -> past tense. It
described #630 and now reads as a live property.

F3, the one that matters operationally: base resolution cuts BOTH ways. A change
to `review-verdict.yml` is no longer exercised by its own PR -- the PR runs the
version already on `main` -- so an edit goes live only ON MERGE, repo-wide,
having never run. A broken edit merges green and then breaks the gate for every
subsequent PR, and the PR that would repair it is gated by the same broken
workflow. The recipe for verifying one safely (scratch base + throwaway PR +
probe-named context) now lives in docs/ci-cd.md, which is where an operator
looks, rather than in the record.

F4: the sibling-workflow guard globbed `*.yml`, so a workflow added as `.yaml`
would be silently unscanned. Latent today, which is when it is cheap.

The record lost its meta-justification paragraph to the 60-line prose ceiling.
Fifth trim this session; the operational recipe moving to ci-cd.md is better
placement anyway, but it was forced rather than chosen. ersatztv#688.

Refs: #672
Decisions-Edit: yes
2026-07-28 23:03:15 +02:00
timothy b2a5c72bfe docs(672): widen the residual to the real inventory (#697, #698)
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 19s
PR Gates / Docs update reminder (pull_request) Successful in 21s
PR Gates / decisions lifecycle (pull_request) Successful in 26s
PR Gates / Script tests (pytest) (pull_request) Successful in 43s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m52s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m59s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 24m4s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Cross-family review established the residual is materially larger than the
previous commit said, and that saying "docker-build.yml / ETV_STATUS_AUTH"
understates it.

Gitea injects `GITEA_TOKEN` into EVERY job automatically, defaulting to
read/write. So the set of workflows that can POST `review-verdict/h10` is not a
short list to audit -- it is all of them, plus `workflow_dispatch` (1.24+ loads
the definition from the selected branch) and `push`-triggered ones. A
collaborator's own write-scoped API token is a route with no workflow at all,
because branch protection binds the required CONTEXT, not its issuer. Recorded
in #697.

The same review found three defects in the exemption path itself, none of them
introduced here and none closed here: a retarget race that enumerates a
docs-only diff against a scratch base while the enumerator revalidates only
head.sha, a Renovate-PR hijack (the exemption reads the immutable PR creator,
not who pushed the head), and an inherited `success` that short-circuits before
any PR/base/author/file check. Filed together as #698, since they share one root
cause -- the gate trusts state it cannot attribute.

Also drops the claim that the three properties are "pinned by tests". The tests
pin the workflow's SHAPE; no in-repository test can establish status-authority
isolation, and the sibling-workflow guard added in the previous commit catches
only a workflow that names the context in plain text.

Trimmed to the 60-line prose ceiling for the third time in this session. That is
ersatztv#688 -- the ceiling is now deleting rationale two reviewers asked for.

Refs: #672
Refs: #697
Refs: #698
Decisions-Edit: yes
2026-07-28 22:41:48 +02:00
timothyandClaude Opus 5 dd7b58232c fix(691): revert entity-level null guard, guard read sites instead
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 16s
PR Gates / Docs update reminder (pull_request) Successful in 59s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m54s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
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 8s
review-verdict/h10 Review-verdict: MERGEABLE @ dd7b582 (base: main)
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 22m12s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Review verdict / Set review-verdict status (pull_request) Successful in 5s
PR Gates / decisions lifecycle (pull_request) Successful in 13s
PR Gates / Script tests (pytest) (pull_request) Successful in 46s
The prior commit (a4700185b) made SongMetadata.Artists/AlbumArtists
coalesce null to [] via backing-field getters, reasoning that EF
Core's PreferField access mode never observes the getter. Adversarial
review disproved this on real TvContext/SQLite: a single read of
.Artists on a TRACKED entity mutates the backing field through the
getter, flips the entity to Modified, and the next SaveChanges writes
[] over what was a NULL column -- silent data loss waiting on the
first tracked reader (today all readers happen to be AsNoTracking).

This also reversed docs/decisions/records/api/selection-projection-include-chain.md
(#671) without the doc update CLAUDE.md requires; #691 is that
record's own "sweep by FIELD" follow-up, so it should follow the
record, not contradict it.

Revert SongMetadata.cs to plain auto-properties (byte-identical to
origin/main, BOM still stripped per the #311 gate). Guard the read
sites instead, per the #671 convention (Optional(...).Flatten(),
matching Playouts/Mapper.cs and MediaItems/Mapper.cs):

- SongVideoGenerator.cs: hoist `artists`/`albumArtists` locals once
  near the top of the metadata loop instead of repeating the guard at
  each of the six former call sites.
- MediaCollectionRepository.cs (GroupIntoFakeCollections): guard the
  two AlbumArtists reads at lines ~1147/~1160 that #691 never named --
  dropping the entity-level fix without these would trade one bug for
  two.

Verified RED per guard by removing only the Optional(...).Flatten()
clause (not the whole file): the artists local throws
ArgumentNullException at SongVideoGenerator.cs:88, the albumArtists
local at :89 (List.ToList() on a null IList<string> source -- same
loaded-gun shape the review demonstrated, precise exception type is
ArgumentNullException rather than NullReferenceException since the
throw site is Enumerable.ToList's null-source check). Restored both;
existing SongVideoGeneratorTests still pass. Full ErsatzTV.Core.Tests:
685 passed (1 pre-existing skip), ErsatzTV.Tests: 1996 passed (4
pre-existing skips), 0 failures in each. No EF model drift
(`dotnet ef migrations has-pending-model-changes` reports none).
`dotnet format --verify-no-changes` on the three touched files exits
0.

Refs #691

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 22:39:52 +02:00
timothy 35a8ea8aef fix(672): review round 1 -- pin the trigger set exactly, sweep the stale claims
Cold review found the first cut of the test satisfiable by a still-vulnerable
config, and two prose claims that outran the evidence.

The test asserted "pull_request_target present, pull_request absent". Adding
`workflow_dispatch:` or `push:` ALONGSIDE it kept that green, and both are
ref-resolved with secrets, so either one restores an equivalent
self-supplied-definition path. Enumerating those two would have the same hole one
trigger later, so the assertion now pins the whole set: exactly
{pull_request_target}, nothing else. Verified by mutation -- adding
`workflow_dispatch` now reds.

Adds the guard that would have caught the residual below rather than only the
instance: no workflow OTHER than review-verdict.yml may reference
`review-verdict/h10` in executable lines. Scoped honestly in its docstring as a
drift guard, not a security boundary -- a workflow can still write the status
through an indirection a text scan cannot see.

`release.verdict-status-check` item 4 still asserted, in the present tense, that
a PR editing review-verdict.yml is judged by its own edited copy. That is now
false for this workflow, and it is the record a reader resolving the gate from
the catalog actually lands on. Past-tensed, with the surviving residual named.

The probe count said three. There were four; the omitted one is the only one with
a negative result, which is what turns an honest partial into an overclaim.
Corrected in both the record and ci-cd.md, along with what was NOT measured
(`reopened`/`ready_for_review` firing under the new trigger).

Also records two operational consequences a maintainer will otherwise hit cold:
retargeting a PR onto `main` leaves it statusless until its next push (`edited`
is not in `types:`), and the required contexts carry a literal `(pull_request)`
suffix, so repeating this fix on docker-build.yml would rename them and deadlock
merges unless branch protection is edited in the same operation.

Trimmed the record back under the 60-line prose ceiling -- for the second time
this session, which is ersatztv#688 reproducing, not a defect here.

Refs: #672
Refs: #697
Decisions-Edit: yes
2026-07-28 21:49:22 +02:00
timothy 8b73234d78 docs(672): record that the fix closes the route, not the class (#697)
Probing rather than reasoning turned up a second instance of the same
vulnerability class while this fix was in review. `docker-build.yml` also
triggers on `pull_request`, so it is head-resolved too, and it carries
`ETV_STATUS_AUTH` (`REGISTRY_USER:REGISTRY_PASSWORD`) for the #420 revalidation
read. Basic auth is not scoped: an account that can read commit statuses can
write them. Confirmed with a scratch PR that POSTed a probe-named context using
those credentials and succeeded — so a PR rewriting `docker-build.yml` can still
post `review-verdict/h10=success` for its own head.

That workflow cannot take the same fix. It builds and tests the PR's code, so it
must resolve from the head; `pull_request_target` there would be the real
footgun. It needs a read-only status identity instead. Filed as #697.

The `rule:` field carries the limitation, not just the body, because the
predecessor record's documented failure was exactly a reassuring sentence in the
position a catalog reader stops at.

Also trims the record to the 60-line prose ceiling. Adding it at 62 lines pushed
p90 past the ceiling and reddened the blocking `script-tests` job — which is
ersatztv#688 reproducing live, not a defect in this change.

Refs: #672
Refs: #697
Decisions-Edit: yes
2026-07-28 21:41:26 +02:00
timothyandClaude Opus 5 a4700185b2 fix(691): guard SongMetadata.Artists/AlbumArtists at the domain boundary
SongMetadata.Artists and .AlbumArtists are nullable EF primitive
collections that FallbackMetadataProvider.GetSongMetadata never
assigns, so untagged songs persist them as NULL. SongVideoGenerator
dereferenced both unguarded (metadata.Artists.Count, string.Join,
AlbumArtists.Filter(...Artists.Contains...)), throwing NRE/ANE during
song-video generation on the playback path.

Rather than enumerating and guarding each read site (the same mistake
that left these unswept after #671), add backing fields to the two
properties whose getters coalesce null to an empty list. EF Core's
default PreferField access mode reads/writes the raw backing field
during materialization and change-tracking (confirmed by running the
full ErsatzTV.Tests suite, including SongMetadata round-trip tests,
unchanged), while every other caller -- SongVideoGenerator,
MediaCollectionRepository's rerun-collection artist grouping, and any
future reader -- goes through the property getter and always sees a
non-null list. This subsumes the ad hoc `metadata.Artists ??= []`
guards already hand-applied in LuceneSearchIndex/ElasticSearchIndex
and the `?? []` in LibraryBrowseItemMapper, which remain but are now
redundant.

Adds SongVideoGeneratorTests covering an untagged song (null Artists/
AlbumArtists) through GenerateSongVideo; verified RED (NRE at
SongMetadata.cs's Artists getter) by reverting only the `??= []`
clause, not the file.

Strips the pre-existing UTF-8 BOM from SongMetadata.cs per the #311
formatting gate (touching a legacy-BOM file makes stripping it ours
to do).

Refs #691

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:30:06 +02:00
timothy cf907f0988 fix(672): trigger the verdict gate on pull_request_target scoped to main
`review-verdict.yml` writes `review-verdict/h10`, the status branch protection
requires on `main`. It triggered on `pull_request`, and Gitea resolves a
`pull_request` workflow DEFINITION from the PR's own head commit — so a PR
editing this file ran its own rewritten copy and could post `h10=success` for
itself. `PROTECTED` did not help (the rewrite defines it too), and neither did
branch protection, which requires the context rather than an author and carries
`required_approvals: 0`.

The #649 base-ref checkout binds the SCRIPTS this job runs to the base. It
cannot bind the job definition. Only the trigger can.

Measured on this instance (Gitea 1.25.4) with three throwaway scratch PRs
rather than inferred from GitHub, since the premise is a claim about someone
else's software:

  A. A PR rewriting the workflow posted its own probe context and the real
     `review-verdict/h10` was never written — the base definition never ran.
  B. Under `pull_request_target` the identical rewrite was ignored: the BASE
     definition ran and posted `h10=pending`, on `opened` and `synchronize`
     alike, with `secrets` still available.
  C. With `branches: [main]`, a PR into a non-main base produced no run and no
     status at all.

The probes only ever posted probe-named contexts, never a forged `h10`.

`branches: [main]` is half the fix, not a refinement: base resolution means the
BASE branch supplies the definition, so without it the rewrite simply moves to
an attacker-pushed base — and a status forged there is inherited by any later
real PR with the same head sha (#663).

`pull_request_target` is safe here only because this job never checks out or
executes head-supplied code; the base-ref checkout is what makes the trigger
usable, so the two are one decision.

Rejected `required_approvals: 1` as the cheaper fix: Gitea forbids approving
your own PR and this is effectively a single-maintainer repo, so it would
deadlock every PR rather than gate the dangerous ones.

Three mutations confirm the new test discriminates rather than merely passing:
reverting to `pull_request`, dropping the `branches` filter, and re-adding
`pull_request` alongside the safe trigger each go red with a distinct message.
It parses the YAML instead of substring-matching because `pull_request` is a
prefix of `pull_request_target`.

Refs: #672
Decisions-Edit: yes
2026-07-28 21:28:38 +02:00
timothy 036bcfc5a0 Merge pull request 'fix(671): resolve rerun-collection selections through one shared include chain' (#692) from fix/671-rerun-collection-selection into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 16s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 28s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m13s
2026-07-28 18:56:13 +00:00
timothyandClaude Opus 5 2249a806c9 fix(671): review round 4 -- fix the chapter-title entity interpolation
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 22s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 29s
PR Gates / Docs update reminder (pull_request) Successful in 41s
PR Gates / decisions lifecycle (pull_request) Successful in 41s
Review verdict / Set review-verdict status (pull_request) Successful in 15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m17s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m11s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 20m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 22m49s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 25m35s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
review-verdict/h10 Review-verdict: MERGEABLE @ 2249a80 (base: main)
Final cold review of 34eee753b: the commit's own changes were confirmed correct,
but it flagged a real pre-existing bug in the exact block I had just edited, and
I was adding the first-ever tests for that method without covering it.

`Playouts/Mapper.GetDisplayTitle`'s Song arm interpolated `{s}` -- the
`case Song s` ENTITY -- into its chapter-title branch instead of `{t}`, the
composed title. Song has no ToString() override, so a chaptered song rendered as
the literal "ErsatzTV.Core.Domain.Song (Chapter 3)" in the playout guide,
troubleshooting, media-item info and channel states. The sibling MusicVideo and
OtherVideo arms are correct only because they happen to name their lambda `s`.
Pre-existing on main; fixed here because it is one token inside the block this
branch already touches. Two tests pin it; reverting renders the type name.

Also: completed the guard on that arm (`Optional(s.SongMetadata).Flatten()`, the
other half of the sibling pattern I claimed to have copied), added the new
mechanism to the record's `mechanics:`, added the symptom tokens a future session
would actually search for (ArgumentNullException, Artists, primitive collection,
chaptered song) to `signals:`, restored the remedy sentence an earlier trim
dropped, and trimmed to 59 prose lines for margin under the 60-line ceiling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:27:08 +02:00
timothyandClaude Opus 5 34eee753b2 fix(671): review round 3 -- sweep Artists by FIELD, correct the archaeology
Cold review of 572737a29. Findings taken; two are corrections to my own claims.

CORRECTION: "a regression this branch INTRODUCED" was wrong, and I verified the
reviewer's counter-claim against origin/main before accepting it. That handler
already included SongMetadata AND already routed Song there, so
GET /api/v1/playlists/{id}/items was ALREADY a live 500 for a null-Artists song.
This branch only made the same throw reachable on a second path. The record said
so twice; both are fixed, because a wrong explanation outlives a wrong line.

SWEEP: fixing one site left the mirror standing -- Playouts/Mapper.GetDisplayTitle
had the identical unguarded join on a path that also eager-loads SongMetadata, so
it too was live, feeding the playout guide, troubleshooting, media-item info and
channel states. Guarded, with a unit test; reverting it reproduces
ArgumentNullException. LibraryBrowseItemMapper already wrote `Artists ?? []`, so
the nullability was known in-tree and these sites were simply unswept. Filed #691
for the remaining SongVideoGenerator dereferences on the playback path.

Also: documented that the shared matrix is the RERUN predicate used as a superset
for playlists (the playlist write path rejects RemoteStream today); noted the
third, inert consumer ReplacePlaylistItemsHandler; added the new mechanisms to the
record's `mechanics:` field; and trimmed the record under the 60-line prose
ceiling -- it was tipping the corpus p90 above the ceiling and reddening the
calibration test in scripts/tests, which passes again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:09:07 +02:00
timothyandClaude Opus 5 572737a29e fix(671): review round 2 -- guard Song.Artists, cover the second consumer
Cold independent review of 017ef988d. Three findings taken, one filed out.

The important one is a regression this branch INTRODUCED. `SongMetadata.Artists`
is a nullable EF primitive collection (JSON in one column, not a navigation)
that FallbackMetadataProvider leaves unassigned when a song's tags fail to read,
and `string.Join` throws ArgumentNullException on a null sequence. The rerun
list previously did not load SongMetadata at all, so the throw was unreachable
there; adding the include promoted it to a live 500 that would have failed the
whole page. Confirmed by reverting the guard: ArgumentNullException, parameter
'values'. The file header claiming every member was guarded was false.
The empty case is filtered too, so an artist-less song loses its bare " - ".

Second: `GetPlaylistItemsHandler` had no handler-level test at all (its
controller tests stub the mediator), so the RemoteStream include added last
round was discharged by inspection -- the same method that produced #671. It
now runs the same 13-type matrix via a shared SelectionSeedData; removing the
include fails that matrix.

Third: dropped the dead `(i as Season).SeasonMetadata` include leg -- the Season
projection reads Show.ShowMetadata and the scalar SeasonNumber, never
SeasonMetadata.

Filed #690 for the pre-existing, out-of-scope finding: the paged TotalCount
ignores the search query, so the SPA renders empty pages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:33:52 +02:00
timothyandClaude Opus 5 017ef988d0 fix(671): review round 1 -- pin exact names, complete the playlist include
Cross-family (Codex) adversarial review of 8523088ce. All three findings taken:

- The name assertion only checked "not a placeholder", so it could not see a
  missing NESTED include leg: dropping Episode -> Season -> Show still renders
  "s00e04 - Selected episode", which contains no placeholder marker and passed.
  Now every type pins its whole expected string; re-removing that leg fails, as
  verified before restoring it.
- Widening the shared switch with a RemoteStream arm put `GetPlaylistItemsHandler`
  one include short -- it loaded metadata for the other nine types, so playlist
  RemoteStream names alone would have degraded to "???".
- `?? 0` rendered an unloaded Season as "s00", which conventionally means
  Specials and so fabricated plausible-looking real data; it now renders "s??".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:00:08 +02:00
timothyandClaude Opus 5 8523088ceb fix(671): resolve rerun-collection selections through one shared include chain
The paged list handler eager-loaded nothing, so `ProjectToViewModel` read four
unloaded navigations and every row of every collection type projected a null
selection. Because the selected id and the display name are read off the SAME
navigation, this dropped the id too -- the harm is not an unlabelled badge but
an editor that round-trips a null and clears the user's stored selection.

The by-id handler loaded metadata for only four of the ten selectable media
types: Song/OtherVideo/Image/RemoteStream returned a null-ish selection and
Episode/MusicVideo threw an NRE that surfaced as a 500.

Fixed at the boundary rather than per call site:

- `RerunCollectionQueryExtensions.IncludeSelectionDetails()` is now the single
  include chain, called by both handlers, joining the existing
  `ProgramScheduleItemQueryExtensions.IncludeScheduleItemDetails()` precedent
  (#229). Artwork legs are deliberately omitted -- this projection reads only
  ids and titles.
- The media-item switch was duplicated verbatim for RerunCollection and
  PlaylistItem; both now call one `ProjectMediaItemToViewModel`, which handles
  `RemoteStream` (via a new `ProjectToNamedViewModel`, since the existing
  `ProjectToViewModel(RemoteStream)` returns an unrelated type) and never falls
  through to null -- an unknown subtype keeps its id and takes a conspicuous
  name, because throwing would fail a whole paged GET over one bad row.
- Every metadata navigation in `MediaItems.Mapper` is now read through
  `Optional(...).Flatten()`, so an un-included nav degrades to "???" instead of
  being a latent 500 for whichever caller loads least.

Tests enumerate all 13 supported CollectionTypes for both handlers, with the
matrix derived from `IsSupportedSelectionType` so a newly-supported type joins
it automatically, plus a completeness guard on the set. Each mechanism was
removed in turn and confirmed red first.

fixes #671

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:48:24 +02:00
timothy d4ea1584c0 Merge pull request 'fix(668): reach accented facet values via a registered Unicode fold on SQLite' (#687) from fix/668-accented-facet-values into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 33s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 34s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m17s
2026-07-27 21:08:28 +00:00
timothy f2d9c0dc8e fix(668): review round 5 -- three prose nits, including an off-by-one I filed
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 14s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Review verdict / Set review-verdict status (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 26s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
PR Gates / Script tests (pytest) (pull_request) Successful in 53s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m0s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 14m33s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
review-verdict/h10 Review-verdict: MERGEABLE @ f2d9c0d (base: main)
Final sweep confirmed the retracted MySQL over-match claim survives in no file
on the branch (only in two immutable commit messages, which stay -- rewriting
history would invalidate every sha-bound review verdict). Three nits remained.

- The fixture's class docstring said the on-MySQL claim "rests on the server's
  collation", which is the one thing the decision record says it does NOT rest
  on. It rests on Unicode-aware LOWER(); the executed comparison bypasses the
  collation entirely. Reworded.
- The record's `rule:` enumerated the covered fields but omitted show_genre,
  which GetSource and the fold both handle ("genre" or "show_genre"). Added.
- My own #688 write-up was wrong twice: the 60-line ceiling warning is
  NON-blocking by design, and the calibration pytest reds at >=61, not >=60 --
  main's p90 is 59, so a 60-line record makes p90 == ceiling and PASSES. The
  bullet even contradicted itself, since the next sentence relies on 60 passing.
  Corrected in the PR body and in the issue.

Decisions-Edit: yes
2026-07-27 22:21:55 +02:00
timothy 07723e418b fix(668): review round 4 -- sweep the retracted claim by SUBJECT, not by memory
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 21s
PR Gates / decisions lifecycle (pull_request) Successful in 25s
PR Gates / Script tests (pytest) (pull_request) Successful in 44s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 34s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 19s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m55s
Review verdict / Set review-verdict status (pull_request) Failing after 10m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m54s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m38s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Round 3 said "correct the claim everywhere" and missed two places, which is
the same mistake in a new coat: I fixed the spots I remembered instead of
grepping for the subject.

- The class-level summary of the very file round 3 edited still said MySQL's
  "ci collation OVER-matches instead", contradicting the method docstring
  forty lines below it. Reworded.
- The PR body still carried the retracted over-match story -- and round 3's
  commit message claimed it had been corrected. It had not. Now corrected,
  with the measurement table and the retraction stated openly.

This time the sweep was `grep -i over-match` across every file the branch
touches; the remaining hits are the SQLite-fold invariant and #578 history,
which are correct and stay.

Also softened two overclaims the reviewer flagged. Round 3 deleted the
predecessor's "configuration-incidental, not designed" hedge and replaced it
with a firmer statement than the evidence supports: that MySQL cannot
over-match is contingent on MySqlConnector fixing the connection collation to
utf8mb4_bin, not a property of MySQL. A driver, protocol or prepared-statement
change could restore it. The record and the fixture docstring now say
"driver-contingent, not a law"; the code is safe either way because the ordinal
filter stays regardless.

Filed #689 for the source of the copied falsehood -- LibraryFolderDedupeMigrationTests
on main carries the same "CI sets ETV_REQUIRE_MYSQL_TESTS=1" sentence.

Decisions-Edit: yes
2026-07-27 22:09:26 +02:00
timothy dda98efcc4 fix(668): review round 3 -- MySQL does NOT over-match; correct the claim everywhere
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 17s
review-verdict/h10 Awaiting review verdict for dda98ef
PR Gates / decisions lifecycle (pull_request) Successful in 25s
Review verdict / Set review-verdict status (pull_request) Successful in 13s
PR Gates / Script tests (pytest) (pull_request) Successful in 55s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m32s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m18s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m5s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Review BLOCKED on a false CI claim I copied from the sibling fixture ("CI sets
ETV_REQUIRE_MYSQL_TESTS=1"). Nothing sets it; the doc now says plainly that CI
does not arm this lane and points at ersatztv#627. That was the blocker.

Chasing the reviewer's second finding then overturned something bigger. It
predicted that seeding an unaccented "Edith" would make the in-memory ordinal
filter load-bearing on MySQL, since utf8mb4_0900_ai_ci treats é as e. Mutation
test says otherwise: with the filter deleted the MySQL test stays GREEN.

Measured against a live 8.4 to find out why:

    LOWER(Name) LIKE 'é%'          (literal)          -> Édith AND Edith
    LOWER(Name) LIKE @v            (ai_ci variable)   -> Édith AND Edith
    LOWER(Name) LIKE @v COLLATE _bin                  -> Édith only
    the EF query, executed                            -> Édith only

The driver binds the pattern with a BINARY collation, so the executed
comparison is accent-SENSITIVE and MySQL does not over-match at all. MySQL's
correctness rests on its Unicode-aware LOWER(), not on the collation.

My earlier probe used a LITERAL pattern -- a different query from the one the
code runs -- and I wrote its result into the handler comment, the decision
record and the PR body. All three now say what actually happens, and the record
carries the lesson: measure the query the CODE runs, not one you type.

The "Edith" row stays as a near-miss control, with a docstring that says what it
does and does not prove rather than the over-match story it was added for.

Also moved EnsureCreatedAsync out of [SetUp]: NUnit skips [TearDown] when
[SetUp] throws, so a mid-create failure would strand the database.

Decisions-Edit: yes
2026-07-27 21:52:16 +02:00
timothy 1f6802bb62 test(668): execute the accented-value claim on a REAL MySQL server
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 16s
review-verdict/h10 Awaiting review verdict for 1f6802b
PR Gates / decisions lifecycle (pull_request) Successful in 27s
PR Gates / Script tests (pytest) (pull_request) Successful in 45s
Review verdict / Set review-verdict status (pull_request) Successful in 17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m28s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m30s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m43s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 15m48s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m23s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The PR's central claim is "reachable on BOTH providers", and on MySQL that
rests on the server's collation rather than on any code this repo owns --
exactly the kind of assumption worth executing rather than asserting.

Adds SearchFieldValuesProviderTests, parameterized over Sqlite and MySql on
the LibraryFolderDedupeMigrationTests contract: opt-in via
ETV_TEST_MYSQL_CONNECTION, a VISIBLE skip without it, and a hard failure
instead of a skip when ETV_REQUIRE_MYSQL_TESTS is set, so the lane cannot
degrade into "connected to nothing and passed". Fresh never-reused database
per test, dropped with its pool cleared.

Verified for real, not just written: run against a live mysql:8.4 it reports
4 passed / 0 skipped and the stored 'Édith' is returned for both q=é and q=É.
Without the connection string it skips (2 skipped); with REQUIRE set and no
connection it FAILS. All three paths exercised.

The MySql half wires RegisterUnicodeCaseFunctions to an explicit no-op, so the
test proves MySQL reaches the value through its own Unicode-aware LOWER() and
not through SQLite's custom fold.

Known and deliberate: CI does NOT arm this lane, so it will skip there. Per the
note in docker-build.yml, running MySQL fixtures against the live service was
implemented and removed as non-deterministic (ersatztv#627) on the grounds that
an intermittently-red gate is worse than none. Re-arming it is that issue's job,
not this PR's -- so this fixture is opt-in exactly like its sibling.
2026-07-27 21:31:36 +02:00