Commit Graph
99 Commits
Author SHA1 Message Date
timothy 5fa672e2e5 docs(release): record the v26.14.0 release notes
Adds the v26.14.0 row to the release table. This is the commit the v26.14.0 tag
will be pushed onto, matching how v26.13.0 was cut (#716).

Contents since v26.13.0: the #726 bitmap-subtitle -readrate starvation fix
(headline), the #674/#688 decisions-validator PyYAML cross-check, the
#706/#707/#711 review-verdict raced-sentinel fix, and two Renovate bumps.

Replaces the branch behind PR #738, which never received a pull_request CI run
across six trigger attempts (two pushes, a force-push after rebase, a
close/reopen, a body edit, and a spaced push on an idle queue). A
workflow_dispatch run did complete green but writes no commit statuses, so it
cannot satisfy the required checks. Opening a fresh PR produces an `opened`
event rather than a synchronize, which is a different path.

No [skip ci] token -- this branch's merge commit is the v26.14.0 tag target.
2026-08-05 00:44:00 +02:00
timothyandClaude Opus 5 0ff9671393 fix(688): pin the minority band's constants and inclusivity; two prose corrections
Review round 7. Its adjudication of the round-6 dispute went the branch's way — measured
at `f394d6ce`, the sha the record actually cites, the #620-era distribution is n=167,
min 2, median 26, p90 52. Round 6 had measured `fefd11dff` (p90 57), a different tree.
The number stays as written.

BLOCKING FINDING: the 2%/25% constants and their inclusive boundaries were not pinned at
all. Mutating 0.02 -> 0.03, 0.25 -> 0.30, or either `<=` to `<` passed all eight
calibration tests. Those are not free parameters — they ARE the documented CI-red
thresholds, so a silent shift would quietly falsify the 38/718 figures in
docs.corpus-size-signal and docs/ci-cd.md (a strict cap reds after 37 long additions, a
strict floor after 717 short ones).

test_the_minority_band_BOUNDARIES_are_exactly_where_documented pins all four. It uses
100-record fixtures so k over the ceiling IS k%, and both 2/100 and 25/100 are exactly
representable and compare equal to the constants — true boundary cases, not near-misses.
Verified by mutation: all four now fail it.

PROSE
- corpus-size-signal said what stays blocking is "what routine growth cannot break",
  immediately before explaining that 38 routine additions break it. Now "what no SINGLE
  ordinary addition can break", which is what is actually true.
- docs/ci-cd.md said the fine claim is "never asserted"; it is never asserted AGAINST THE
  LIVE CORPUS, and IS asserted on synthetic distributions the tests own. Corrected — the
  distinction is the whole design.

Correction to an earlier commit message in this branch (9d2b30dc3, already pushed, so
recorded here rather than rewritten): it said the cross-check was clean on "all 183 real
records". 183 is the active keyed-record count; the cross-check scans the record WINGS —
195 files at that commit, 190 of them carrying frontmatter. The check was clean; the
figure named the wrong population.

Verification: 432 scripts/tests pass; ruff at baseline parity (47); validator exits 0 with
no drift notice; corpus at p90=60, 18/183, calibrated; new record still 60 lines.

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 22:52:03 +02:00
timothyandClaude Opus 5 b24c51ab51 fix(688): stop enumerating multiplicities, pin n and the keyless filter, de-couple the vacuity floors
Review round 5. One reviewer returned MERGEABLE with prose findings; the other found four
more, two of them real test gaps. Both are addressed here.

THE MULTIPLICITIES WERE WRONG AGAIN — fourth commit running. The "measured" sequence
59, 59, 60, 60 -> 81 is measured nowhere: origin/main has 59, 59, 59, 60, 60 and HEAD has
four 59s and three 60s. I had even tagged it `(measured)` in a canonical decision record.

So this stops enumerating them. All four sites now state only the load-bearing, stable
fact: the lengths climb to the ceiling and then jump STRAIGHT to 81 with nothing in
between, so one record moves p90 by 21 lines. The multiplicities change with every record
added; the gap is the point. This is the same "fix the boundary, not the site" move the
tests got three rounds ago, applied to prose that had failed four times.

TEST GAPS
- Deleting the over-tight test removed the only pin on CeilingCalibration.n: a mutation
  returning n=1 passed all 19 relevant tests while printing a wrong denominator in the
  drift notice. Pinned.
- The `if r.key` filter was load-bearing in production and unpinned: main() passes the
  UNFILTERED list (194 entries, 11 keyless, one a 106-line "Records formerly in this file"
  scaffolding block), while every test handed the function a pre-filtered list — oracle and
  production agreed only by accident. Pinned.
- The --record-ceiling 0 arm's claim that it "cannot go vacuous for any non-empty corpus"
  was FALSE: an empty record body is validator-valid and record_prose_lines returns 0, so a
  corpus of empty-bodied records has no offender at 0. Now -1, which makes the claim true.
- The three `len(recs) > 100` vacuity floors were themselves growth-coupled — 83 legitimate
  retirements would red them even with the ceiling still calibrated, which is the #688 class
  in the guard rather than the assertion. Lowered to >20 where a floor is meaningful, and to
  plain non-empty on the derived-ceiling test, whose derivations need nothing more.
- test_main_FEEDS_the_crosscheck now compares against `set(record_wing_files())` instead of
  a hardcoded basename, killing the same mutation with zero corpus dependence.

PROSE
- "ordinary growth cannot cross it — NOT immune" contradicted itself in four places. Now:
  no SINGLE ordinary addition can cross it; this is measured headroom, not immunity.
- "trimming or archiving 15" blurred two different denominators. Trimming leaves 3/183 =
  1.64%; archiving leaves 3/168 = 1.79% because the denominator moves too. Both verified,
  both under the floor, now stated separately.

Verification: 431 scripts/tests pass; ruff at baseline parity (47 — a 121-char docstring
line briefly took it to 48 and is rewrapped); validator exits 0 with no drift notice;
corpus at p90=60, 18/183, calibrated; record still 60 lines.

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 22:11:25 +02:00
timothyandClaude Opus 5 c56dfdd539 fix(688): delete the last over-tight test, pin the crosscheck's INPUT, fix 4 prose defects
Review round 4, both reviewers. Both report the code path SOUND and the #688 coupling
class analytically gone (rows proved, not merely observed green); one caught 28 of 30
mutations. What blocked was one over-tight test, two unpinned mutations, and prose —
including two defects the PREVIOUS commit introduced while claiming to fix numbers.

TESTS
- Deleted test_adding_ordinary_records_cannot_RED_the_blocking_property. It appended two
  long records to the LIVE corpus and asserted flags_minority on the RESULT, so it crossed
  the cap two records before production does (56/221 vs 54/219) — a test named "cannot RED
  the blocking property" being a tighter tripwire than the property. Fourth instance of the
  #688 defect in this change. Deleted rather than tuned: both its jobs are already covered
  off live data (the synthetic v4/v5 contrast, and the deliberate live guard at the
  production threshold).
- test_main_FEEDS_the_crosscheck_the_REAL_wing_files closes a mutation hole found by
  review: replacing `pyyaml_frontmatter_faults(record_wing_files())` with `...([])` in
  main() left the ENTIRE suite green. Both existing wiring tests monkeypatch the function,
  so they pinned that its RETURN reaches errs, never that its ARGUMENT is the corpus —
  the '#609 marker that printed OK while doing nothing' defect one level up, which is the
  exact thing the new record indicts. Verified: the mutation now fails this test.
- test_main_actually_REPORTS_... went vacuous whenever the ceiling legitimately goes green
  (`False is False` passes with the whole warning branch deleted). Added an arm at
  --record-ceiling 0, which no non-empty corpus can make vacuous.
- Pinned two surviving mutations: ceiling_calibration's n_over boundary (it recomputes the
  count, so oversized_records' exclusivity test does not cover it — `>` vs `>=` differs by
  the 3 records sitting exactly on the ceiling) and p95's quantile (the 95/5 fixture cannot
  tell 0.95 from 0.99).

PROSE — two of these were introduced by the previous commit, whose stated job was fixing
numbers. That is the pattern worth naming, not the individual typos.
- "so ONE new record could move p90 lines" — the previous commit deleted the magnitude and
  left the sentence ungrammatical. Now "by 21 lines".
- It also introduced a THIRD variant of the sequence it was correcting ("60, 60, 60") and
  missed a FOURTH site in ci-cd.md still saying "twenty lines". All four sites now read the
  measured 59, 59, 60, 60 -> 81, and 21 lines.
- 59- and 60-line records were described as "above the ceiling"; they are at or below it.
- "routine growth cannot cross it" overstated the bound: it is deliberately less sensitive,
  not immune. Reworded, and the THIRD and tightest arm is now documented wherever the other
  two appear: consolidating 15 of the 18 offenders drops below the 2% floor (verified:
  3/183 = 1.64%). That is in real tension with test_oversized_records_can_go_green and is
  stated as accepted — at 3/183 the constant genuinely is mis-calibrated — with the remedy
  named: a consolidation PR that large should re-derive the ceiling in the same change.
- Corrected a docstring that called the 999-ceiling failure "silently deleting the
  assertion"; it would go red, not silent.

Verification: 430 scripts/tests pass; ruff at baseline parity (47); validator exits 0 with
no drift notice; corpus at p90=60, 18/183, calibrated. The two new claims were measured,
not assumed: the empty-list mutation fails the new test, and 15 consolidations reaches
1.64%.

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:47:24 +02:00
timothyandClaude Opus 5 b3a8826281 fix(688): stop asserting live-corpus order statistics anywhere in the suite
Review round 2 (both reviewers, independently) found the round-1 fix incomplete: the
live-corpus coupling survived in two more tests. This is the THIRD instance of one
defect class in this change, so the fix is to remove the coupling rather than patch
another site.

BLOCKER — test_adding_ordinary_records still asserted live order statistics. The
`if before.marks_tail:` guard made the PRECONDITION conditional but left the
CONCLUSION (`assert not after.marks_tail`) an assertion about the live p90. Verified:
appending 16 ordinary 30-line records — nothing long, nothing unusual — makes both
sides true and fires it, reddening the blocking job for an unrelated author. Exactly
what #688 exists to abolish.

The v4-vs-v5 contrast moved to test_v4_would_have_reddened_where_v5_holds, built on a
distribution the test OWNS, reproducing the shape that matters (a sparse gap just above
the ceiling). The real-corpus test now asserts only the robust claims: the additions
were counted, v5 holds, and the measured headroom.

Same treatment for the "bad ceiling" teeth test, which hard-coded that 200/229/230 stay
rejected on the live corpus — three new 200+ line records flip it. Teeth now demonstrated
synthetically; the only live-corpus assertion left is that today's ceiling is accepted,
which needs 38 over-ceiling or 718 short additions to break.

The IFF drift test could lose its quiet branch: one 61-line record makes BOTH the 60 and
999 ceilings drift, at which point an UNCONDITIONAL notice would pass. Both ceilings are
now DERIVED — p90 itself (always calibrated, since p90 <= p90 <= p95) and max+1 (always
off the tail) — so each branch is guaranteed by construction, and the test asserts it
exercised both.

Added the missing regression test for the typed-mapping-key TypeError: removing `key=str`
now fails a test instead of only a manual probe.

Corrected against measurement: the v5 row of the record's own version table still stated
the REJECTED first-draft bound (`0 < f < 1/3`) — the canonical artefact contradicting both
the code and its own next paragraph; accepted range is 43..180, not "roughly 45..150";
breaching the cap takes 38 additions, not 37 (37 lands exactly on 0.25, which passes under
`<=`); "5x headroom below the floor" was inverted; ci-cd.md said four versions "all
ratcheted" when v1 was vacuous and v2 accepted an absurd ceiling; and the crosscheck record
overstated protection — script-tests is NOT a required check, so "no broken record has
reached main" is procedural, not structural.

Evidence the coupling is actually gone: mid-fix the corpus sat at marks_tail=False (an
edit pushed this branch's own record to 61 lines, moving p90) and the suite stayed fully
green. Under the old assertions that state reddened CI. The record is trimmed back to 60
so main ships calibrated and no drift notice nags.

Verification: 428 scripts/tests pass; ruff at baseline parity (47); validator exits 0 with
no drift notice; corpus at p90=60, 18/183 over the ceiling.

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:02:01 +02:00
timothyandClaude Opus 5 80818aa294 fix(674,688): address independent review — restore the coarse bound's teeth
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 33s
PR Gates / Docs update reminder (pull_request) Successful in 31s
PR Gates / decisions lifecycle (pull_request) Successful in 41s
Review verdict / Set review-verdict status (pull_request_target) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m14s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m32s
PR Gates / Script tests (pytest) (pull_request) Successful in 2m8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m53s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 20m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 23m19s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Two independent cold reviews (one cross-family) agreed on the top two findings.

1. The #688 fix was defeated by its own complement test. test_main_is_QUIET_about_
   drift asserted the drift notice was ABSENT while running main() over the LIVE
   corpus — whose failure condition is bit-for-bit v4's assertion, in the same
   blocking job, three functions down. p90 sat exactly on 60, so one over-ceiling
   record would have reddened it. Replaced with an IFF test that uses
   ceiling_calibration as its oracle, so it asserts the WIRING rather than the
   corpus's current state, plus a guard that at least one branch fires.

2. The coarse bound was nearly unfalsifiable. `0 < fraction_over < 1/3` accepted
   EVERY ceiling from 39 to 229 on the real corpus — including the ceiling of 200
   my own docstring offered as the case it catches, because one 230-line record
   keeps the count nonzero. That claim was simply false and is corrected. The floor
   is now a FRACTION (2%) and the cap 25%, which rejects 200/229/230 and 20, and
   accepts roughly 45..150. Headroom measured, not estimated: 37 consecutive
   over-ceiling additions, against ONE record to break v4.

3. yaml.safe_load raises a bare ValueError, not a YAMLError, on a well-shaped but
   impossible date (stale-after: 2026-06-31), which escaped as a traceback and
   killed the validator on any machine with PyYAML. The except is now deliberately
   broad, with a test.

4. PyYAML returns TYPED mapping keys, so a stray `1: x` made sorted(set|set) raise
   TypeError. Sorted with key=str.

5. The headroom prose was arithmetically wrong (~42/~40 where the real values are
   63/64; each addition moves numerator AND denominator) and the record counts were
   stale. Corrected against measurement.

6. test_adding_ordinary_records passed identically with its two additions removed.
   It now asserts the additions were counted, and that they break the v4 property
   while leaving v5 satisfied — guarded by `if`, never asserted, since whether v4
   currently holds is a fact about the live distribution and asserting it would
   rebuild the ratchet.

Also recorded honestly in docs.frontmatter-pyyaml-crosscheck: decisions-guard
installs no PyYAML, so in CI the cross-check always skips and script-tests already
caught both hazards — the CI delta is close to zero and the real fix is the local
loop plus the tool/suite agreement. And the new record was trimmed 64 -> 58 prose
lines: at 64 it moved p90 to 64 by itself, i.e. this PR would have reddened the old
blocking job. That is now cited in the test as the live demonstration.

Verification: 426 scripts/tests pass; ruff at baseline parity (47); validator exits
0; corpus back to p90=60, 18/183 over the ceiling, marks_tail and flags_minority
both true.

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 20:37:27 +02:00
timothyandClaude Opus 5 9d2b30dc3b fix(674,688): cross-check frontmatter against PyYAML; split the ceiling calibration claim
Two defects in scripts/decisions_validate.py, fixed together because they share the
validator and its pytest suite.

#674 — the validator reported OK on frontmatter PyYAML rejects. The hand-rolled reader
is deliberately dependency-free (decisions-guard and the Husky hooks install nothing),
so it cannot see a bare apostrophe closing a single-quoted scalar. Hit twice in one
session by two independent agents. `pyyaml_frontmatter_faults()` now cross-checks the
parse against PyYAML whenever PyYAML is importable, and is SKIPPED with a ::notice::
when it is not — the read path stays dependency-free.

The two known hazards fail differently and the fix covers both: the apostrophe makes
PyYAML reject the document, while an unquoted ` #` parses fine and silently TRUNCATES
the value. So the check compares parsed results key by key rather than try/except-ing
the load, which is also what makes it generalize past the two known characters. PyYAML
wrote these files, so on disagreement it is authoritative and the file is the defect.
The comparison has one implementation, called by the validator and by the existing
test_decisions_lib agreement test, so the tool and the suite cannot drift.

#688 — test_real_corpus_ceiling_sits_at_the_TAIL_BOUNDARY asserted p90 <= 60 <= p95 in
the BLOCKING script-tests job. p90 sat exactly on the ceiling and the distribution above
it is sparse, so one ordinary record moved p90 by twenty lines and reddened CI for
whoever wrote it; it reproduced twice live (#672, #706) and both times the only in-scope
remedy was trimming the new record to fit the constant.

v5 splits the claim by robustness instead of hunting for a better single assertion. The
blocking test now asserts only the coarse, non-ratcheting property (the ceiling flags a
nonempty proper minority, 0 < fraction_over < 1/3); the fine tail-boundary claim is
measured every run and REPORTED as a ::notice::, on the same reasoning stale_records
already uses — a constant going out of date is the passage of corpus growth, not a
defect in the commit under test. The fine property is still asserted, against synthetic
distributions the test owns. The ceiling stays 60.

Verification: 424 scripts/tests pass; ruff at baseline parity (47 before and after);
the cross-check is clean on all 183 real records; a positive control pins that
record_wing_faults alone still reports both hazard files as clean, so the new red cannot
pass for the wrong reason; and a test demonstrates that appending #672's 62-line and
#687's 107-line records to the real corpus does not red the blocking property.

Docs: new record docs.frontmatter-pyyaml-crosscheck, docs.corpus-size-signal updated for
the v5 split, catalog regenerated, docs/ci-cd.md updated for both.

fixes #674
fixes #688

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 20:15:16 +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
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
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 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
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
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 8de02d5bde Merge pull request 'fix(649): point the ENFORCED review-verdict gate at the shared PR-file enumeration' (#666) from fix/649-enforced-verdict-guard 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 / EF migration integrity (SQLite + MySql) (push) Successful in 16m56s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 17m36s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 22m9s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 14m40s
Renovate / Renovate (push) Successful in 5m16s
2026-07-26 22:04:06 +00:00
timothy 00e623c066 fix(632): bind a review verdict to its BASE branch, not only to its head sha
#622 made `review-verdict/h10` a per-sha required status, so a new commit cannot
inherit an old verdict — the required context is simply absent on the new head.
Retargeting a PR's base reaches the same end from the opposite direction: the head sha
and the status both hold still while the merge-base, and therefore the effective diff
the verdict was formed against, changes underneath them. #622's record claimed the
invariant holds "by construction"; this was the documented exception, and an unrecorded
exception is how a guarantee degrades into a habit.

`post-review-verdict.sh` now records the base branch in the status description as a
trailing `(base: <ref>)`, and refuses to write a status at all if the base moved between
reading the PR and posting — the same TOCTOU window the head check already covers, which
the head check cannot see because retargeting does not move the head.
`pretooluse-merge-consent.sh` reads the field back and denies when it no longer matches
the PR's live `base.ref`.

Two choices are load-bearing, and each is pinned by a test rather than left to a comment:

- The comparator is `base.ref`, NOT `base.sha`. `base.sha` tracks the base branch's tip,
  which moves whenever anything merges to `main` — comparing it would invalidate every
  open verdict on every unrelated merge, converting a rare-event guard into a permanent
  merge deadlock. A base that merely advances is out of scope by design: rebasing onto
  it moves the head sha, which the per-sha binding already covers.
- The field goes in the status DESCRIPTION, not the verdict comment. The comment body is
  parsed by `scripts/check-review-verdict.sh`, whose grammar had three false-opens in its
  history (#629); nothing parses the description, so this adds a field without reopening
  that surface.

Scope is stated honestly rather than overclaimed: this is DETECTION on the hook path
only. A commit status carries no base of its own, so the server-side required check
cannot see a retarget, and a merge driven through the Gitea UI or API is unaffected. That
is the accepted exposure — base changes are rare, manual, and this is a two-account repo
— but it now fails loud in the one place that evaluates consent, instead of living only
in a doc.

Verdicts posted before this change carry no `(base: …)` and get NO opinion rather than a
deny; denying would block every in-flight PR the day it lands, and the window closes on
its own since verdicts are per-head and short-lived.

Verified by mutation, six mutants, each killed by its intended test: remove the hook's
deny; compare base.sha instead of base.ref; drop graceful adoption; stop recording the
base; drop the TOCTOU guard; accept a PR with no resolvable base. The positive controls
matter more than usual here — the test PR is deliberately non-docs (a docs-only PR
short-circuits the whole gate and would never reach the base check) and the rest of the
gate is unstubbed, so "the hook denied" alone proves nothing.

Refs #632

Decisions-Edit: yes
2026-07-26 23:13:50 +02:00
timothy 9114a7e8af fix(649): point the ENFORCED review-verdict gate at the shared PR-file enumeration
#658 landed the shared implementation, `scripts/pr-changed-files.sh`, and rewired the
ADVISORY hook onto it. The ENFORCED copy — the one that writes the branch-protection-
required `review-verdict/h10` status — was left byte-identical to main, so its
fail-closed behaviour on a malformed or empty response stayed INCIDENTAL: an empty `n`
erroring `[ "$n" -lt 50 ]` to false. That is #649's second Done-when box, and the whole
point of the issue was that the gate with real authority was weaker than the gate with
none.

`review-verdict.yml` now:

- checks out the PR's BASE ref (`base.sha`, `persist-credentials: false`), never the
  head, so a PR cannot supply the code that judges it;
- runs `scripts/jq-preflight.sh` in FLOOR-ONLY mode — `--expect` here would deadlock
  every merge on `main` the day the runner's jq changes;
- calls `scripts/pr-changed-files.sh` and reads its EXIT STATUS, never its stdout on a
  failure path. The env trap flagged in review is handled: the script reads
  GITEA_BASE_URL and takes owner/repo as two separate arguments, so passing BASE_URL and
  a combined `owner/repo` would have silently fallen back to the hardcoded LAN default.

The ~40 lines of inline enumeration are deleted, so the two copies can no longer drift.
A base ref predating #658 has no such script; that posts `pending` with the reason
rather than dying with no status at all.

The drift guard is re-tightened from "the hook uses the shared script" to "BOTH callers
do", and the workflow's own preconditions are pinned by parsing the YAML rather than
substring-matching it — `head.sha` for `base.sha` is a nine-character diff.

Verified by mutation, six mutants, each killed by its intended test: ignore the exit
status; check out the head; drop `persist-credentials`; add `--expect`; re-inline a
`pulls/N/files?` fetch; delete the PROTECTED clause.

That last one initially MISSED, and the miss was the useful finding. The test used a
docs-only-plus-protected file list and passed with the clause deleted, because
PROTECTED (`.claude/ .gitea/ .husky/ scripts/ docker/ci/`) and DOCS_ONLY (`docs/`, root
`*.md`) are disjoint — on the docs-only path that clause can never fire, and DOCS_ONLY
was doing all the work. PROTECTED is load-bearing only on the BOT path, so the test now
covers a Renovate PR editing the shared script, with a positive control proving the bot
exemption fires at all.

The caller contract is tested by EXECUTING the workflow's `run:` block against a stubbed
enumeration that fails while emitting a perfectly docs-only list — the one combination
the "every failure path also happens to print nothing" redundancy cannot absorb, and the
exact mutation that survived the whole suite last round.

Docs: both "Landing note" blocks removed, and the record's base-ref paragraph converted
from a future-tense requirement to present-tense fact with its staging rationale kept as
history.

Refs #649

Decisions-Edit: yes
2026-07-26 23:05:14 +02:00
timothy 2c10f057b8 fix(648,649): stage the enforced-gate wiring behind the scripts it calls
Splits the review-verdict.yml rewiring out of this PR. That workflow checks out the
PR's BASE ref — deliberately, so a PR cannot rewrite the gate that judges it — and the
base is main, which does not yet contain scripts/pr-changed-files.sh or
scripts/jq-preflight.sh. Wiring it here would make the job exit 127 on its own PR and
block the merge gate through the combined status, which reads red jobs as blocking.

So this PR lands the scripts, their tests, the hook rewiring and the script-tests jq
pin; the follow-up points review-verdict.yml at them once they exist on main.

The two tests that asserted on review-verdict.yml are scoped accordingly, each carrying
the reason. test_review_verdict_never_pins_a_jq_version is asserted NOW rather than in
the follow-up, so the no-pin constraint on the required check is already enforced when
the wiring lands.

Decisions-Edit: yes
2026-07-26 22:21:07 +02:00
timothy 63fa81fbb5 docs(648,649): the jq contract + the shared PR-file enumeration record
Adds docs/ci-cd.md "The jq contract" (1.6 floor, the three divergent constructs,
and the deliberate pin-vs-floor asymmetry with its merge-deadlock reason), plus two
decision records: ci.jq-version-contract and ci.shared-pr-file-enumeration.
ci.script-tests-job stops restating the three jq rules and points at the new record.

Also corrects the script-tests preflight description: it is now two steps (git
presence, then jq VERSION via scripts/jq-preflight.sh --expect 1.6), not one.

A literal NUL byte had crept into the ci-cd.md paragraph describing jq 1.6's NUL
truncation — which git treats as a binary file. Replaced with the literal text.

Decisions-Edit: yes
2026-07-26 22:21:07 +02:00
timothyandClaude Opus 5 fefd11dffe fix(620): signal corpus size per RECORD; the aggregate becomes an unthresholded trend
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 20s
PR Gates / Docs update reminder (pull_request) Successful in 28s
PR Gates / decisions lifecycle (pull_request) Successful in 30s
Review verdict / Set review-verdict status (pull_request) Successful in 12s
PR Gates / Script tests (pytest) (pull_request) Successful in 42s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m31s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 18m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m27s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
review-verdict/h10 Review-verdict: MERGEABLE @ fefd11d
Squashed from 7 commits (4 review rounds) to keep the rebase onto #621 tractable; the
full round-by-round history is on PR #642.

corpus was 5658/5600 — over budget and warning again — 3h35m after #619 put it at 5228,
with nobody consolidating anything.

So this does NOT re-baseline. An aggregate over a monotonically growing corpus can only
ratchet; that is the "permanently red, therefore no signal" state #542 re-baselined away
from, and growth is not even a smooth rate to plan against (the corpus FELL from 5089 to
5042 across four days, then gained 427 in one evening as two large records landed).

Replaces it with a per-record prose ceiling (default 60), non-blocking, naming each
record over it — not monotonic, so it can go red AND green, and it points at a file. The
aggregate is still printed as an unthresholded trend notice, with record prose and
non-record scaffolding reported separately because they are not the same unit. The
GENERATED catalog is no longer counted at all: it gains one row per record and no
consolidation can shrink it, which made the metric partly a record COUNT in a line-count
costume.

The calibration test took FOUR versions, and the failures are the durable lesson:
  v1  true by construction (`max(under) <= 60 < min(over)` over lists built by that test)
  v2  a gap WIDTH — a ceiling of 200 also sits in a wide gap, so it passed
  v3  fraction band + "clear air" vs the nearest record above — hostage to an unrelated
      record: one ordinary 62-line addition reddened it with the ceiling correctly
      placed, and the only remedy was to RAISE the ceiling. That is this very treadmill,
      as a hard failure in what #631 makes a blocking job.
  v4  `p90 <= ceiling <= p95` — the property stated directly and scale-free.
Two rules recorded: a guard test must depend only on the thing it guards, and a threshold
over a growing population must be expressed in that population's own terms.

Candidates: all over-ceiling records assessed, each actioned or declined with a reason.
The largest (scan.libraryfolder-unique-identity, 230 lines) is a legitimate DECLINE — a
dozen-odd distinct traps whose only copy that is. Nothing pruned, so no archive or
supersession was required. An automated redundancy metric is explicitly rejected.

Also: `--budget` is accepted but announces its retirement rather than no-opping silently;
the dead `budget_ok` parameter is gone; and five "untresholded" typos are fixed, one of
which was propagating into the generated catalog row and MemPalace's per-key drawer.

Refs #620

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:57:01 +02:00
timothyandClaude Opus 5 f4473926d4 fix(643): close the status-string dodge, pin the allow-list anchors, fix two doc claims
Round-3 review (MERGEABLE, all findings Low/Nit) — applied anyway, because each is the
over-claim or unpinned-anchor family this PR keeps hitting.

LOW — the `renamed => previous_filename REQUIRED` clause matched `.status` by exact
lowercase string, so any other value took the `else true` branch: a row with
`"Renamed"`, or with no status at all, validated fine and silently dropped its SOURCE
path, letting `git mv ErsatzTV/Program.cs -> docs/a.md` read as docs-only. `.status` is
now checked against a closed set and an unknown value fails closed.

Two things that fix taught me, both caught by my own positive control rather than by
review:
  1. The first predicate was WRONG in a way that gated everything: inside
     `[...] | index(.status)`, jq's `.` is the ARRAY, so `.status` was null and every
     row failed. `$s` is now bound from the row before the context switches. A
     security check that rejects everything looks identical to a working one from the
     failing side — only test_gitea_real_status_values_are_accepted caught it.
  2. The set includes BOTH `changed` and `modified`. Live Gitea 1.25.4 emits `changed`,
     but a closed allow-list built from the wrong vocabulary is worse than the hole it
     closes: it would gate every genuine docs-only PR. The property wanted is "reject
     what we don't recognise", not "enumerate one version exactly".

LOW — three allow-list anchors had no test at all: dropping `^` from the `docs/`
alternative (`ErsatzTV/docs/Program.cs` would exempt), dropping `$` from `.md`
(`x.md.cs` would exempt), and dropping the non-empty-list guard. Since the round-3 `..`
finding WAS an anchor subversion, they are now pinned; all three mutation-verified.

NIT — docs/ci-cd.md called this job "a checkout plus a pure-stdlib pytest run", which
the same file contradicts 450 lines later and which this PR's own record names as the
bug that turned the job red on its first CI run. Also replaced a frozen "111 tests" with
an explicitly indicative figure — the suite is ~190 after rebasing onto main, and a
frozen count has rotted four times this session.

198 passed under both jq 1.8.2 and jq 1.6.

Refs #643, #631

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:24:20 +02:00
timothy 73577f484f ci(631): install pyyaml too — the suite is not pure-stdlib, and CI proved it
First CI run went red on `ModuleNotFoundError: No module named 'yaml'` at collection:
test_migration_equivalence.py imports scripts/migrate_decisions_split.py, which uses
PyYAML by design. It passed locally only because this machine already had PyYAML.

The original claim came from reading decisions_lib/decisions_validate/build_catalog
and the test files themselves — which is precisely the set that does NOT import yaml.
Replaced with an AST import scan over all of scripts/, which gives the complete
third-party set: pytest and yaml. (etv_client is imported only by
scripts/scripted-schedules/entrypoint.py, a container entrypoint pytest never
collects.)

This does not contradict the dependency-free decisions READ path:
decisions_lib._read_frontmatter is hand-written exactly so validation runs where
nothing is installed. The one-shot WRITE path is allowed PyYAML, and its test needs it.

Verified the way it should have been the first time: a clean venv with pytest alone
reproduces CI's collection error; adding pyyaml gives 111 passed. Comments, ci-cd.md
and the decision record all corrected to drop the "pure stdlib" claim.

Refs #631
2026-07-26 13:24:19 +02:00
timothy c0f4a52d7a ci(631): preflight jq/git in script-tests, and reflow the header comment
test_post_review_verdict.py and test_merge_consent_exemption.py exec the real
post-review-verdict.sh and pretooluse-merge-consent.sh, which shell out to jq ~26
times. Those tests shim `curl` on PATH but NOT `jq`, and no existing small-lane job
proves jq is present on that image — so a missing jq would land as ~20 opaque
assertion failures rather than a diagnosis.

Checks rather than installs: #390 deliberately removed run-time apt-get from CI, so
the correct fix for a genuine miss is baking the tool into the runner image, and the
error message says so.

Refs #631
2026-07-26 13:24:19 +02:00
timothy 69d8d3ccfe ci(631): run scripts/tests in CI as its own job, not inside the flake-covered decisions-guard
Nothing executed scripts/tests/. No workflow and no Husky hook invoked pytest:
decisions-guard runs decisions_validate.py and build_decisions_catalog.py directly
(the code, never its tests) and the `test` job is dotnet test only. The 111 tests
guarding the decision corpus, the #610 migration-equivalence harness, the
merge-consent exemption logic and the #622 review-verdict poster were enforced only
by whoever remembered to run pytest locally.

Adds a `script-tests` job to pr-checks.yml rather than the step inside decisions-guard
the issue proposed. decisions-guard is covered by ci.decisions-lifecycle-flake, a
standing instruction that a lone `decisions lifecycle` red is a known flake and must
not be investigated — so a genuine pytest regression there would surface as exactly
the red every session is told to wave through. A gate inherits the credibility of the
job it lives in.

Runs unconditionally: the suite executes the real post-review-verdict.sh and
pretooluse-merge-consent.sh, so a `scripts/**` path filter would miss a
.claude/hooks/** edit. ~10s, pure stdlib + pytest.

Negative control verified locally (broken test -> exit 1); CI-red measurement follows
on a scratch PR per the Done-when.

Refs #631
2026-07-26 13:24:19 +02:00
timothy 3d720a6bc1 fix(621): exempt by exact path, not basename — my "no basename exemption" claim was false
review-verdict/h10 Awaiting review verdict for 3d720a6
Review verdict / Set review-verdict status (pull_request) Successful in 2s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m48s
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 17m57s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 22m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Re-review found the basename exemption was not actually removed: the code still dropped
any wing-root README.md, and the docstring defended that with "no such file exists
today; the catalog README lives in TOPIC_DIR". That is false — docs/decisions/archive/
README.md exists and parses to []. So the rule text in the record, the catalog row and
ci-cd.md all asserted something the shipped code contradicted.

It was also a live hole, not just wrong prose: dl.active_files() globs RECORDS_DIR.rglob
with NO filter, so a future docs/decisions/records/README.md would be a corpus source
while exempt from the guard — verbatim the hole this PR cites as the reason to stop
using _NON_DECISION_FILES, reintroduced one directory over.

Now exempt by exact RELATIVE PATH ({archive/README.md}), with a test pinning that the
same basename in the ACTIVE wing is still faulted. Mutation-verified.

DISAGREEING with one review finding, deliberately. It asked for the empty-wing
anti-vacuity check to be symmetric across both wings. The semantics differ: zero ACTIVE
records means the scan measured nothing and any clean result is vacuous, while zero
ARCHIVED records is a normal state — nothing superseded yet, true of any young repo and
every fresh clone before the first supersession. Making it symmetric fails a correct
corpus (it reddened 7 tests). The asymmetry stays, with the reason in the code and a
test pinning it so nobody "fixes" it back.

127 passed.

Refs #621
2026-07-26 12:03:43 +02:00
timothy 98b3e8715b fix(621): close three exemption holes and pin the guard's own wiring, per cold review
An independent cold-context review found the guard could be disabled or bypassed in
four ways, one of them silently.

HIGH — the guard's only wiring was untested. Deleting `wing_faults=record_wing_faults()`
from main() left the ENTIRE suite green (120 passed) while a real block-scalar record
vanished again with `decisions-validate: OK`. Every existing test either called the
collector directly or handed validate() a hand-built list, so nothing pinned that
main() ever invokes it — the #609 "prints OK while doing nothing" defect, one level up,
inside the fix for that same defect. Independently reproduced before fixing. Now pinned
by a monkeypatched sentinel test.

MEDIUM — a record could escape by BASENAME. `_NON_DECISION_FILES` is a set of TOPIC-dir
names ({README, migration-map, retrieval-eval}) and was applied to the wings too, so a
genuine record at records/docs/retrieval-eval.md was skipped. Not hypothetical: the
path<->key rule forces key `docs.retrieval-eval` to that exact filename, and
dl.active_files() applies that filter only to the TOPIC_DIR glob — so the file would be
a corpus source while exempt from the guard. Now nothing is exempt by basename.

MEDIUM — the archive exemption was by LOCATION but justified by IDENTITY. Anything
directly in archive/ was skipped, so a new unparseable archive/foo.md would vanish in
the one directory left unwatched. Now exempt only if it really is a #610 stripped index:
one keyless record with a known generated heading.

LOW — parse-to-WRONG. `rule: >-` with an UNINDENTED continuation containing a colon
parses to one VALID keyed record whose rule is literally ">-", plus a junk key, silently
truncating the real value. PyYAML rejects that input, so the hand reader is more
permissive than the writer. Now faulted via an unknown-frontmatter-key check.

LOW — an absent or empty wing reported clean. Now loud: refusing to certify a corpus
from a scan of nothing.

Doc corrections, all measured rather than asserted: "all 167 records use single quoted
lines" was false (117 of 168 rule: values are unquoted plain scalars — the invariant is
single-LINE); the archive shape in the record, ci-cd.md and the rule: field said
`archive/<area>/` where the code deliberately uses archive/** ; the mutation count is 10,
not five; and the "converts ANY reader limitation" claim is softened to the class it
actually covers.

125 passed.

Refs #621
2026-07-26 12:03:43 +02:00
timothy 0f565b1f7e fix(621): make an unparseable decision record loud instead of silently invisible
A file under the record wings that the dependency-free frontmatter reader cannot
parse returned [] and simply vanished from the corpus: decisions_validate.py printed
OK, build_decisions_catalog.py --check said "up to date", the record was absent, and
nothing anywhere errored. Reproduced end-to-end with a YAML block scalar (`rule: >-`),
which is the natural thing to reach for on this corpus's very long rule: values and
which parsed fine under PyYAML before #610's dependency-free reader.

An EXISTING record disappearing was already loud (the no-vanish diff check). The hole
was a NEWLY ADDED record, which that check structurally cannot see — no base state to
diff against — so the author's own PR looks clean. Hence a per-PATH check, not a
per-construct or diff-driven one: asserting "this path yields exactly one keyed
record" turns any present or future reader limitation from silent to loud in one move.

Adds record_wing_files/record_wing_faults to decisions_validate.py, surfaced through
validate() as ERRORS (a file in the record wings that is not a record is a mistake by
definition) and reported first, since a file that failed to parse leaves every
downstream check silently evaluating an incomplete corpus. The five top-level stripped
legacy archive files are exempt — they are generated "Records formerly in this file"
indexes, keyless by construction, and are what keeps older date-based pointers
resolvable.

_read_frontmatter is deliberately NOT extended to accept block scalars; rationale in
the new docs.record-wing-parse-guard record.

8 tests, mutation-verified: with the check neutered 5 go red, restored all 119 pass.
A live-corpus positive control asserts the wings are non-empty so a clean result can
never be vacuous.

Refs #621
2026-07-26 12:03:43 +02:00
timothy 628ae46f7f fix(622): bind H10 merge consent to the reviewed sha via a required commit status
review-verdict/h10 Awaiting review verdict for 628ae46
Review verdict / Set review-verdict status (pull_request) Successful in 9s
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 26s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 1m28s
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 16m59s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 19m5s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
`pretooluse-merge-consent.sh` proves all three consent conditions at the moment
the merge tool is called. With `merge_when_checks_succeed=true`, condition (a) is
delegated to Gitea, which then merges whatever head is green at ITS merge time —
while (b) Done-when and (c) the head-referencing verdict were proven against the
head at SCHEDULING time. Every commit pushed in between merges unreviewed. The
gate is not bypassed; it is satisfied against a snapshot that stops being true.

Demonstrated as a controlled A/B rather than inferred, with a CI check left
pending so Gitea waits as it really does:

  without a required verdict context: unreviewed commit B MERGED
  with it:                            same sequence REFUSED
  after reviewing B and posting it:   merges — blocked, not deadlocked

NOTE the anecdote in #622 is wrong and is corrected in the docs rather than
repeated: PR #619 does carry `Review-verdict: MERGEABLE @ 02c82b35`, posted six
seconds before the merge, explicitly re-reviewing the follow-up commits. #622 was
filed off a lagging API read. The hole is real regardless, and structural —
nothing FORCED that re-review inside the window Gitea would have merged in. This
turns a property that held by diligence into one that holds by construction.

The fix is the sha, not a smarter check. A Gitea commit status belongs to exactly
one commit, so a status written for a parent cannot be inherited by a child that
did not exist yet. `review-verdict/h10` becomes a REQUIRED status check on main:
push a new head and the context is simply absent, which Gitea reads as
not-passing (verified against 1.25.4: missing AND pending both block, and
auto-merge re-checks the current head). It also covers merge paths the hook never
sees — Gitea UI, raw API, another agent's session.

- scripts/post-review-verdict.sh writes the comment and the status together so
  they cannot drift, and re-reads head after commenting: if a commit landed
  mid-flight it writes NO status and exits non-zero rather than retargeting the
  verdict at a commit nobody read.
- .gitea/workflows/review-verdict.yml auto-passes the two exempt classes that
  would otherwise deadlock — Renovate-authored (platformAutomerge) and docs-only
  — and marks everything else pending. Exemptions are void when the PR touches
  .claude/, .gitea/, .husky/, scripts/ or docker/ci/.
- The hook refuses to SCHEDULE an auto-merge unless that status is green on head,
  and no longer claims "CI green" on the mwcs path, where it never read CI.

Two silent false negatives in the exemption path, both found by verifying rather
than reasoning, both fixed at BOTH call sites (workflow and the hook's
pre-existing docs-only carve-out):

- The files endpoint caps at 50 rows and IGNORES a larger `limit` — PR #619 has
  194 changed files and `?limit=100` returns 50. A single page saw ZERO protected
  paths there where the full enumeration finds ten. Both now page to exhaustion
  and withhold the exemption if they cannot complete.
- A rename is ONE row whose `filename` is the destination, the source only in
  `previous_filename`. Verified live: `.gitea/workflows/renovate.yml` ->
  `docs/innocuous-note.md` presented as docs-only with no protected path visible.
  Both now read BOTH sides.

Limits are documented, not papered over: base changes leave the head sha (and so
the verdict) unchanged, and a PR editing the workflow is judged by its own edited
copy — so PROTECTED is a guardrail against accident, not a tamper-proof control.

fixes #622
2026-07-25 21:51:02 +02:00
timothyandClaude Opus 5 f49556b6ef docs(609): qualify the two ci-cd.md mentions as non-merge
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m55s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
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 15m16s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m30s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Last review Low, marked safe-to-defer but it is two words in a file this PR already
edits. Both prose mentions of the marker now say "non-merge commit", matching the
decision Rule, the catalog, the module comment and the function docstring.

Docs-only.

fixes #609

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 18:40:55 +02:00
timothyandClaude Opus 5 c597c49f02 fix(609): arm the decisions body-diff with a git trailer, not a bare substring
The rationale-edit exemption was a substring test over the whole commit range, so any
message containing the literal marker armed it -- including prose explaining why no
marker was needed, which is how it fired live in PR #605: a green --base/--head run
that was vacuous on the body-diff dimension, in the one PR that hand-resolved a merge
conflict inside the corpus the guard exists to police.

Now read as an affirmative `Decisions-Edit:` git trailer. Git parses trailers only in
the final paragraph, so a quoted example message cannot arm it -- which matters because
this commit and its decision record both quote one. A non-affirmative value (`no`) does
not arm it either; the retired substring arms nothing and gets a ::warning:: nudge.

Tests: negative controls (prose mention, quoted mid-body trailer, `no` value, retired
substring) plus positive controls (trailer, uppercase, alongside Co-Authored-By). All
four negative controls verified red against the old matcher.

fixes #609

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 18:21:12 +02:00
timothy 5dd0b147e8 docs(445): record that rebasing invalidates the CI toolchain image pin
Hit this for real on this branch. `ci-image-pin` went red after a rebase that
was otherwise clean, and the failure is confusing on three counts:

  - the pin must equal the short sha of the commit that touched docker/ci, and a
    rebase REWRITES that sha (e9fd26f6 -> 9130274c here);
  - the pin still resolves to a real commit and the tagged image still exists in
    the registry, so nothing looks broken;
  - the force-push does NOT republish: ci-image.yml filters on
    `paths: docker/ci/**`, and a rebase that leaves the Dockerfile's content
    unchanged produces no diff for that path.

And it cannot be fixed by re-dispatching ci-image.yml, because that tags
`git rev-parse --short HEAD` — the branch HEAD, not the commit that touched
docker/ci. The two coincide only when the docker/ci commit IS HEAD, which is why
the original two-step worked and the post-rebase state does not.

Recorded in docs/ci-cd.md with the recovery, plus the cheaper lesson: land a
toolchain-image change on its OWN branch first, so the consuming branch never
carries the docker/ci commit through a rebase.

This commit is also the recovery itself — it touches docker/ci, so it becomes the
commit ci-image.yml tags, restoring the pin dance.

Refs #445
2026-07-25 14:04:52 +02:00
timothy f7fa57c816 docs(445): fix dangling decision key + clarify UI-E2E step reference [decisions-edit]
Two review findings from the cold docs/convention pass.

HIGH — the new `ci.ui-e2e-harness` record cited `` `ci.toolchain-image` `` as if
it were a resolvable decision key. No such key exists anywhere in the corpus
(verified: the only occurrence in all of docs/ was that citation itself). #390's
toolchain-image work was deliberately never migrated to a standalone record —
`ci.runner-placement`'s own Signals line says so.

This is exactly the breadcrumb hazard docs/README.md -> "Knowledge retrieval"
warns about: a future session (or a MemPalace lookup) resolving that key gets
nothing back, and cannot distinguish "retired" from "never existed". Replaced
with an explicit pointer to #390, `ci.runner-placement`, and docs/ci-cd.md, and
stated outright that #390 has no standalone record.

Swept the whole class rather than the one instance: every dotted key cited in
added lines across the diff now resolves (ci.functional-e2e-harness,
ci.runner-placement, ci.ui-e2e-harness).

NIT — docs/ci-cd.md said the UI-E2E step is "Step 4 of this same job", which
refers to the doc's own 4-item prose summary, not the YAML step list (where it
is the 11th `steps:` entry). Reworded to name the actual step so a reader
skimming the workflow isn't sent looking for a 4th YAML step.

Refs #445
2026-07-25 14:03:59 +02:00
timothy d8c0b3e752 feat(445,533): headless Playwright UI-E2E flows + fix e2e-local readiness probe [decisions-edit]
Adds the last deferred #299/#363 follow-up: the flows that CANNOT be expressed
as curl calls. Scope rule (the durable part) — assert only what the curl
harness structurally cannot reach:

  1. client-side form validation (the Setup confirm-password gate is pure React
     state and makes no request, so there is no HTTP contract to assert)
  2. AuthGate's RENDERED states (Setup vs Login vs app)
  3. the session cookie authenticating the SPA's OWN /api XHRs — curl proves the
     cookie works for curl, not that the app sends it
  4. sign-out through the UserMenu back to the login gate

New: web/e2e/boot-gate.spec.ts, web/playwright.config.ts, scripts/e2e-ui.sh
(owns the whole lifecycle: fresh config dir -> boot -> specs -> always kill).

Runs as a second step of the EXISTING advisory `functional-e2e` job rather than
a new job: the dominant cost there is `npm ci` + the Release build, both already
done, so this adds ~5s instead of duplicating a heavy job. It boots its own
fresh instance on port 8410 because the first spec asserts the one-shot Setup
gate that the curl step has already claimed on its config dir.

Determinism (the issue asked for it explicitly): `serial`, `workers: 1`,
`retries: 0` even in CI — a retry would let a flaky flow merge looking green.
Measured 5 consecutive clean runs, ~2s each.

Pins all five `container:` jobs to the toolchain image built by the preceding
commit, which bakes `chromium-headless-shell`.

Non-obvious coupling fixed: vitest's default include glob would have collected
web/e2e/*.spec.ts and run it under jsdom. Excluded `e2e/**` by spreading
`configDefaults.exclude` rather than narrowing `include` to `src/**`, because
web/scripts/ holds a real vitest test an src-only include would silently stop
running.

`RebuildSearchIndexHandler` logs one of two mutually-exclusive lines just before
`SystemStartup.SearchIndexIsReady()`:

  fresh config  -> "Done migrating search index in {Duration}"
  reused config -> "Search index is already version {Version}"

The probe watched only the first, so a reused dir waited out the full 120s
timeout and then killed a perfectly healthy server. Widened to a `grep -Eq`
alternation; the handler's if/else is exhaustive, so the pair covers every path
to readiness.

Verified with a negative control: on a reused dir the server is ready in 2s via
the "already version" line, and the OLD probe string is genuinely ABSENT from
that run's log — so the old code would have hung, i.e. the fix is load-bearing
rather than incidentally passing.

The "prefer a fresh config dir" guidance stays: that guards state bleed, which
is a separate concern from the probe hanging.

- `wait "$PID"` in the cleanup trap was a NO-OP: the server is a grandchild
  (launched in e2e-local.sh's subshell, which then exits), so `wait` fails
  instantly and was swallowed by `|| true` — cleanup did not actually ensure the
  port was released, exactly what its comment claimed. Replaced with a bounded
  `kill -0` poll, then SIGKILL.
- Added a port pre-flight check: previously an occupied port surfaced as a 120s
  readiness timeout that reads like a broken build. Now fails in 0s naming the
  PIDs, and warns against blanket-killing `dotnet ErsatzTV.dll` (that reaps
  other sessions' servers).

- UI-E2E: 5x clean (3 specs, ~2s); back-to-back runs pass with no manual cleanup
- curl harness unaffected by the boot-script change: 45/45 PASS
- web: 983 tests / 105 files green; typecheck + lint clean
- vitest collection verified: excludes web/e2e, still collects web/scripts
- Dockerfile sequence + browser launch validated verbatim in a container on the
  real amd64 base before committing; chromium launches as root with NO sandbox
  opt-out needed
- decisions validator green; catalog regenerated
- docs/decisions.md TOC repaired: it had drifted to 69 of 97 records and held a
  dangling anchor to the #72 record that #415 superseded into archive/.
  Regenerated with a generator validated against the 68 existing anchors (0
  mismatches) -> 97/97, no dangling, no duplicates.

Docs: docs/e2e-local.md (new "UI-E2E harness" section), docs/ci-cd.md (toolchain
image + UI-E2E step), docs/testing.md, docs/README.md, docs/decisions.md
(new `ci.ui-e2e-harness` record; `ci.functional-e2e-harness` amended — its Rule
said "curl-only", now accurate).

Refs #445 #533
2026-07-25 14:03:59 +02:00
timothyandClaude Opus 5 c8e79f49f4 chore(586,594,485): PID-scoped E2E cleanup, ci-image-pin length guard, .gitignore core fix
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 11s
PR Gates / decisions lifecycle (pull_request) Successful in 12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m55s
Three independent CI/repo-hygiene fixes swept together; disjoint file sets.

fixes #586 — E2E cleanup is scoped by PID, never a pattern-wide pkill
  - New decision record `testing.e2e-cleanup-scope-by-pid`.
  - docs/e2e-local.md states the constraint where a BRIEF-WRITER sees it (the
    #586 root cause was a delegation gap, not agent error).
  - scripts/e2e-local.sh: reviewed against e2e-ui.sh's trap lifecycle and
    deliberately does NOT adopt it — its contract is to hand a running instance
    back to its caller, so an EXIT trap would kill the server the instant the
    launcher returned (both callers use `OUT="$(e2e-local.sh ...)"`). Recorded.
  - Instead it gains what actually prevents the incident: an lsof pre-flight
    that NAMES a foreign listener's PID rather than letting Kestrel fail its
    bind and surface as "process N exited before becoming ready".
  - Pre-flight probes BOTH bound ports, and ETV_STREAMING_PORT now defaults to
    ETV_UI_PORT. Program.cs binds a second listener whose port defaults to 8409
    independently of ETV_UI_PORT, so `ETV_UI_PORT=8420` alone still bound 8409
    and died against a foreign holder — i.e. the documented escape hatch was a
    dead end that led straight back to the confusion behind the pattern kill.

fixes #594 — ci-image-pin accepts any hex length
  - Length is a separate invariant from correctness: the resolve/staleness
    checks compare resolved shas, so an 8-char pin of the right commit passes
    green while matching NO registry tag, and all five container: jobs then die
    at image-pull with `manifest unknown` (reads like a registry outage).
  - Guard fails at the gate and prints the exact tag to use. Verified against
    doctored pins: 7 green; 6/8/10 red.
  - Uses a literal 7 rather than a derived `--short=7`: in a full clone git may
    widen an ambiguous abbreviation, demanding a pin ci-image.yml can never
    publish. Escape hatch documented inline.
  - Also fixes a pre-existing misdiagnosis: zero pins reported "MORE THAN ONE".
  - docs/ci-cd.md documents the 7-char rule and `git rev-parse --short=7 HEAD`.

fixes #485 — .gitignore `core` silently ignored `*/Core/` files
  - A bare `core` matched any path component named `core`; case-insensitively
    on macOS that swallowed every `*/Core/` SOURCE dir, so new untracked files
    were dropped by `git add -A` while tracked ones stayed fine — a clean local
    build and a CI checkout that fails to compile.
  - Now `/core` + `/core.[0-9]*`, both anchored (an unanchored `core.[0-9]*`
    would re-introduce the same silent-exclusion class this fixes).
  - Verified by diffing the full ignored-file set before/after: identical, and
    the three real Core/ dirs are trackable without -f.

Docs updated in-PR: docs/e2e-local.md, docs/ci-cd.md, docs/decisions/
workflow-process.md (+ regenerated catalog), docs/handoffs/chicorytv-issue-queue.md.

Follow-ups filed: #596 (the same shared-host reap in the Playwright-MCP
recovery record) and the ci-image.yml `--short=7` publisher-side fix, which
cannot ride this PR — editing ci-image.yml re-points ci-image-pin's `expected`
at this commit and reds the gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:46:13 +02:00
timothyandClaude Opus 4.8 b4ac46fce1 ci(535): split PR-only git gates into pr-checks.yml so release tags don't red
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m24s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m39s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m49s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m20s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Three PR-only git-diff gates (ci-image-pin, docs-reminder, decisions-guard)
lived in docker-build.yml, which also triggers on push to main and v* tags.
Gitea dispatches a job as a runner task even when its `if` skips it, so on
every tag/main push these three were dispatched to the `small` lane just to
evaluate the skip. On the v26.12.0 tag those dispatched skip-tasks wedged in
act's setup phase and were killed by a runner restart mid-setup, reporting
`failure` (no logs) and reddening the tag's overall commit status even though
the release built, scanned, and deployed fine. The two identical-`if:` jobs on
ubuntu-latest (api-docs, format) skipped cleanly — the job logic was never the
problem; the kill lands in the dispatch window before any step or skip runs, so
tweaking the `if:`/step logic could not fix it.

Relocate exactly those three (pure checkout + git-diff, no container:, no image
pin) verbatim into a dedicated pr-checks.yml that triggers `on: pull_request`
only. Gitea evaluates a workflow's trigger before creating any job, so on a
tag/main push this workflow produces zero jobs: no dispatch, no kill, no
spurious red — for the whole class, permanently.

- ci-image-pin carries no pin and still greps docker-build.yml, where all five
  pin-bearing jobs (test/migrations/functional-e2e/api-docs/format) remain, so
  its validation is unchanged.
- None of the three are required checks (only Build & test + EF migration
  integrity are), so the status-context prefix change (Build ErsatzTV Image / ...
  -> PR Gates / ...) does not affect merges; the merge-consent hook reads the
  prefix-agnostic combined status.
- pr-checks.yml declares `defaults: run: shell: bash` (ci-image-pin uses
  mapfile / set -o pipefail).

docs/ci-cd.md: new "PR gates workflow" section + cross-references.

fixes #535

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 18:07:56 +02:00
timothyandClaude Opus 4.8 6148805e37 docs(release): v26.12.0 headline — ErsatzTV MCP server (#58) + external-logo download (#525)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 11s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / decisions lifecycle (pull_request) Successful in 16s
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 12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 35s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 35s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:47:20 +02:00
timothyandClaude Opus 4.8 962dc2a31a feat(520): parallel orientation+selection startup; retire #237 as live state; #642 retrieval bullets [decisions-edit]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:48:14 +02:00
timothyandClaude Opus 4.8 f93458c76c fix(521): whole-branch review — correct Gitea anchors, guard archive+demotion, reciprocal links, stale append-only refs, budget warning [decisions-edit]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 03:00:15 +02:00
timothyandClaude Opus 4.8 b1e7e08884 feat(521): rework decisions guard — lifecycle validator + narrow [decisions-edit]; header + CI rewrite [decisions-edit]
Retire the line-level append-only mechanic (ersatztv#303 H9) in favor of the lifecycle validator
built in Tasks 1-5. .claude/hooks/decisions-guard.sh is now a thin fail-open shim around
scripts/decisions_validate.py; .husky/pre-commit calls it for the structural (working-tree) checks,
.husky/commit-msg drops the old staged/[decisions-edit]-deletion block and keeps only the
Co-Authored-By check. The Gitea decisions-guard job is renamed "decisions lifecycle" and now runs
decisions_validate.py --base/--head (structural + body-diff + no-vanish) and
build_decisions_catalog.py --check (active catalog drift), with actions/setup-python@v5 added since
the bare `small` lane doesn't guarantee python3; the old 1800-line consolidation-floor step is
removed (replaced by the validator's aggregate active-corpus budget). docs/decisions.md's header is
rewritten from append-only to lifecycle framing (metadata schema, statuses, generated catalog,
archive, same-PR supersession); [decisions-edit] is re-scoped (not removed) to rationale-prose
edits/factual corrections only. docs/ci-cd.md's release ritual and hook/job descriptions are
rewritten to match.

Also fixes a pre-existing validator false-positive surfaced while sanity-checking against
origin/main: Task 6's #303 H9/H3 split (commit d09be57e) renamed the archived record's heading
away from the pre-split original, which the validator's heading-based relocation check reads as
"removed without an archive copy." Restored the archived heading to match the original text
(functionally unchanged — still status: superseded, same key) and updated the two prose
cross-references (migration-map.md, release-ci-governance.md) that pointed at the old anchor.

PR1 scope only (per brief): does NOT wire the kickoff-guard CI step or touch the
kickoff/README/select-queue docs — that's Task 8/PR2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 03:00:15 +02:00
timothy 7248416bd6 docs(release): Komodo stack is jazz-media; no auto-update fallback
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17s
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 16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Verified live while promoting v26.11.0. Two corrections:

1. `DeployStack media-servers` targets a DEAD stack. The Komodo stack name
   changed to `jazz-media` with the move to jazz; the compose PROJECT is still
   `media-servers` (which is what container labels show, so the labels don't
   catch this). A `media-servers` stack still exists on bumblebee in state
   `unhealthy` — the stopped migration leftovers — so the documented command
   silently deploys the wrong, dead thing.

2. There is no Global Auto Update fallback: `jazz-media` has
   auto_update=false (poll_for_updates=true only). Promotion is manual, full
   stop, and the 'don't cut a tag near the 03:00 run' caveat is obsolete.

The pre-deploy safety chain is intact and jazz-aware (#635) — verified by
reading the deploy's Pre Deploy stage: image-change trigger fired, 286M backup
with integrity_check=ok plus off-box PBS, migration smoke PASS against the
prod-copy, then only ersatztv recreated.
2026-07-20 23:04:34 +02:00
timothyandClaude Opus 4.8 4263cf7919 ci(508): move both docker build jobs off the small lane
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Failing after 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 14s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 14s
Build CI Toolchain Image / Build & push CI image (push) Successful in 2m37s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
`runs-on: small` carried two jobs that are not small: docker-build.yml's image
build, and ci-image.yml's toolchain buildx. The second reads as lightweight
because it is "docker-only, no toolchain needed — it *builds* the toolchain",
which is true and yet describes the heaviest job in the lane.

A lane's per-job memory cap is set by its worst member, not its median, so these
two pinned `small` at --memory=10g. On bumblebee's 25 GiB — also the prod media
host — that permits exactly ONE slot, and four jobs shared it. So "widen the
lane" and "keep the heavy jobs" were never simultaneously available.

The symptom that forces the issue is not queue wait. A saturated lane also wedges
DISPATCHED jobs in act's setup phase: >10 min in_progress, no log file written at
all, then failure, before Checkout runs. That is where "decisions.md is a known
flake, just rerun it" came from — the rerun works only because it lands after load
clears, so a capacity problem read as a bug in the guard.

With both builds on ubuntu-latest, `small` is a checkout plus a `git diff` and
server-management#639 caps it at 1 GiB, widening it to 4 slots across two hosts
while committing LESS RAM to CI than the single slot did.

so it cannot be dispatched until the jobs it would queue behind have finished.

refs #508

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:24:12 +02:00
timothy a352d832ad docs(release): v26.11.0 headline is the QSV VA-API decode fix (#498) + unified logo bug (#67)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 33s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 38s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
The release range grew after the host-correction PR landed: #67 and #498 both
merged into main. #498 in particular flips a default (QsvPreferNativeDecoder is
ON), so it leads the row rather than sitting in a fixes list.
2026-07-20 22:20:52 +02:00
timothy d6aec6de76 docs(release): prepare v26.11.0 promotion — correct the deploy host to jazz
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 33s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 33s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 34s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The media transcoders (Jellyfin, ersatztv, ersatztv-test) moved from bumblebee
to jazz (192.168.1.29) on 2026-07-20, server-management#633. Our docs still sent
the release operator to bumblebee for security-scan.sh and named it as the docker
host, which would have scanned the wrong box.

Also fixes the inverse error: `jazz` was an EARLIER name for the .99 host, so
three pre-migration references meaning today's bumblebee read as jazz. Those are
now bumblebee, and a Hosts table + name-reuse warning is added at the top of
ci-cd.md so the next session resolves hostnames by IP rather than re-breaking it
in either direction.

Version table had drifted (stopped at v26.9.0) — adds v26.10.0 + v26.11.0.
2026-07-20 19:18:03 +02:00
timothy c5369b1d69 ci(412): sample true peak-anon in the test job, not cache-inflated memory.peak
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m15s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m10s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m12s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 13m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m12s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The test-job memory instrument (#411) reported memory.peak — the high-water mark of
memory.current, which charges reclaimable page cache to the cgroup. A build does heavy
NuGet/npm/obj/bin/coverage I/O, so cache can dominate the peak, and page cache is reclaimed
under a tighter cap rather than OOM-killed. Sizing a per-job cap (server-management#604) off
memory.peak therefore inverts the decision. The OOM-forcing quantity is peak anon, which the
kernel exposes no counter for and which the end-of-job split misses (a job that peaks
mid-dotnet-test then frees reports a low anon).

New scripts/ci-peak-anon.sh: a `start` step (before Build/Test/Coverage) launches a detached
background sampler tracking the high-water mark of cgroup anon; a `report` step (last) stops it
and prints the sampled peak anon as the headline, keeping memory.peak + end-of-job split as a
cache-inflated ceiling and reference. Both continue-on-error + fail-open so they never redden a
build. Validated on bumblebee: survives step-boundary re-execs, catches a transient 2.5 GiB
anon spike the snapshot reports as 0, stops cleanly on kill, degrades gracefully.

Compiler-server A/B (swap-off, sampled peak-anon, n=2 interleaved): OFF (CI config) ~5.84 GiB
consistent; ON (defaults) 6.3-7.6 GiB, always higher, + a ~3 GiB resident VBCSCompiler.
Disabling the servers is worth it, but OFF sits right at 6 GiB for the build phase alone and the
test job adds test+coverage, so #406's "budget loosens well under 6 GiB" premise is not
supported. Size the cap off the live test-job sampler.

Docs: ci-cd.md instrument section rewritten (peak-anon headline + A/B table + premise verdict);
decisions.md entry added. No .cs touched.

fixes #412
2026-07-19 20:49:08 +02:00
timothyandClaude Opus 4.8 33657b4753 docs(469): sweep remaining stale format-job cost/memory claims (review)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m49s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m28s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m33s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 5s
Cold adversarial review (MERGEABLE) flagged two spots still describing the
pre-#469 heavy `format` job as current, plus a wording imprecision:
- ci-cd.md CI-lane table row: annotate `format` runtime 37s → ~0.5s (#469).
- ci-cd.md #406 memory narrative: note the 3.95 GiB Roslyn heap is now moot
  (folder mode loads no workspace); api-docs remains the lane's real consumer.
- Precise the coverage mechanism in ci-cd.md + decisions.md: the naming rule
  passes the full gate because naming violations have no `dotnet format` batch
  code-fixer (so `--verify-no-changes` sees no change), not merely a severity
  floor.

Docs-only; no workflow/hook logic change from the reviewed commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 19:34:43 +02:00
timothyandClaude Opus 4.8 eafb2e39e2 perf(469): format gate uses dotnet format whitespace --folder (~480s → ~0.5s)
Build ErsatzTV Image / decisions.md append-only (pull_request) Waiting to run
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Has been cancelled
The blocking `format` CI job and the Husky pre-commit hook verified changed .cs
files with `dotnet format ErsatzTV.sln --no-restore --verify-no-changes
--include <files>`. `--include` only narrows *which* files are checked, never
what gets loaded: the full recipe loaded the ~10-project MSBuild workspace and
built a Roslyn compilation per project before checking a single line (~480s
locally, whole-solution). Switch both to `dotnet format whitespace . --folder
--verify-no-changes --include <files>`, which treats the tree as a plain folder
of files, skips MSBuild/Roslyn entirely (~0.5s), and needs no `dotnet restore`
(NuGet-cache + Restore steps removed).

Coverage is unchanged: folder mode reads .editorconfig and enforces exactly the
gate's purpose — whitespace + charset (BOM). Proven non-vacuous (error
WHITESPACE on a trailing-space line, error CHARSET on a prepended BOM, exit 0
clean). The full gate never enforced the style/analyzer pass either — a
warning-severity naming violation passes the full solution format (exit 0) — and
the analyzers that must block (NU1904, S3981) are enforced at compile via
WarningsAsErrors, not by this job.

Docs: ci-cd.md Formatting section + the obsolete #406 memory note; decisions.md.

fixes #469

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 18:33:31 +02:00