Commit Graph
21 Commits
Author SHA1 Message Date
timothyandClaude Fable 5.1 a7d91bf15a fix(876): sweep session narrative out of hooks, workflows, scripts, tests and code comments; grow the detector to the process corpus
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 35s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 57s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 1m0s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ a7d91bf (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
`docs.no-session-narrative` reaches every durable artifact, but its detector scanned only
`docs/**/*.md` and root markdown, and nothing had ever swept the rest. The issue named four sites
from one grep and called them a floor. Deriving the population instead — a whitespace-joined sweep
over every tracked file outside the detector, for the detector's own phrasings plus the attribution
and review-round class #812 found — gave 453 sites in 108 files at `fb5592971`, and a second pass
for phrasings the first list missed (hyphenated `round-N`, "an earlier version", "the reviewer
proved") added residuals in the same files. Every site was classified with #812's three
dispositions (CUT / SEVER / KEEP with its sub-kind) under the who-benefits test; the per-site
manifests are on the PR. The rejected designs, tested-and-rejected fixtures, measurements and
traps stay; the attribution of who found them and the round in which they were found go.

The detector's population grows to `.claude/`, `.gitea/`, `.husky/` and `scripts/` regardless
of extension, minus the detector and its own test (whose fixtures ARE the phrasings) and minus
`scripts/tests/fixtures/` (test data, including decision-record copies — the same reasoning as
the records' own exemption, and what keeps the record's depth measurement true), and `--all`
lists tracked REGULAR files only — a symlink's content is its target and a gitlink has none. The #812
argument for leaving `docs/superpowers/**` in the population runs the other way here: `--diff`
sees only ADDED lines, and 287 of the 453 sites were under 30 days old — this corpus is where
narrative is being added, so the advisory nudge has reach. Density agrees: 56 line-mode hits over
the 113 regular files the predicate admits, against 9 over 66 docs files before #812. `web/` and C# stay out on the same
measurement (3 of 74 PATTERNS-matching sites, ~4,600 files). The predicate did not grow: PATTERNS
matched 74 of 453 sites, and widening the word list to the attribution class is the treadmill
the withdrawn parity test ran on. The population oracle is restated over segments with the new
arms, the synthetic cross product gains the process heads and non-markdown extensions, a fixture
witnesses that a tracked symlink is neither scanned nor counted, a `.py.bak` axis separates a
by-name exemption from a `startswith` over the same tuple, and eight mutants (drop the process
arm, drop the by-name exemption, exempt by `startswith`, drop or add a prefix, drop the fixtures
exemption, list only markdown, drop the symlink filter, test the mode per row instead of per
path) each
redden it. A pre-existing silent drop in `--diff` goes with it: git tab-terminates a `+++`
filename that contains a space, and the kept tab made `is_scanned_path` refuse the file with no
notice — fixed, with a positive control and its own mutant.

Code is unchanged by construction, measured per file type against `origin/main`: Python modules
are AST-equal with docstrings stripped, except `#` lines inside the embedded fixture programs
(string literals) of three test modules; workflows differ only in `#` lines inside `run:` block
scalars; shell, C#, TypeScript and jq are equal with comment lines stripped. The stated
exceptions: the detector and its test, 26 vitest titles that carried review-round or severity
labels or a reviewer attribution (call sites whose title changed — every changed title line
walked back to its `it(` / `it.each(...)(` anchor, so a `' + '` concatenation counts once), two
registry note strings and the mutation manifest's prose fields. scripts/tests: 1565 passed.
Web: lint, typecheck, 1319 tests green. Closes #876.

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEcBoFw7ctrf3Nb7R7x7wk
2026-09-03 20:51:39 +02:00
timothyandtimothy cf5f42edf9 fix(858,859): a rule the classifier cannot read is not a rule that matches nothing (#897)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 10m49s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m41s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 7m18s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m26s
#859 was filed as a wrong STATED CAUSE. It was masking a live false-open in the merge gate.

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

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

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

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

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

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

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

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

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

fixes #858
fixes #859

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-30 11:33:50 +00:00
timothyandtimothy 761e575836 fix(787): derive the dropped-step guard's scope, and reconcile its snapshot against the server (#861)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 6s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m22s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m56s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m33s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-27 22:37:02 +00:00
timothyandtimothy d4c72697f2 feat(780): commit a ruff config and enforce it in CI (#813)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 28s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m54s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m59s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 15s
Python lint here was a property of the operator's laptop: the global instructions
say to run ruff, no workflow ran it, and with no committed config ruff fell back
to whichever ~/.config/ruff/ruff.toml the machine happened to have.

- ruff.toml at the root, pinned ruff==0.12.11 in the script-tests job.
- Both lint steps pass an EXPLICIT population from `git ls-files` with
  `--no-force-exclude`, never `ruff check .` — an `exclude` empties a
  discovery-based run into a GREEN one (top level empties both commands, [lint]
  empties check, [format] empties format --check), and `ruff check .` over zero
  files exits 0 with only a stderr warning. Guarded by an empty-population arm.
- Tree clean: 74 findings at 706674272, 57 fixed in code, 17 per-site noqa with
  reasons inline. S105 deliberately per-site, not a directory blanket. RUF100
  selected so a suppression that suppresses nothing is itself a finding.
- pyright stays ungated; reasoning in the record.

Both steps witnessed red on the runner against the shipped bodies: run 2173 job
9176 (ruff check) and run 2170 job 9163 (ruff format --check).

Docs: new record ci.python-lint-ruff-config-committed, ci.script-tests-job
cross-ref, docs/ci-cd.md (also correcting a stale ~190-tests/~10s figure to the
measured 773 tests / ~4.5 min), docs/defect-shapes-773.md §5.2 resolved.

fixes #780

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-22 00:33:18 +00:00
timothyandClaude Opus 5 7347a08979 fix(778): the 200-not-an-array arm had the same lying reason, one branch earlier
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 31s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 43s
PR Gates / Docs update reminder (pull_request) Successful in 48s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 55s
Review verdict / Set review-verdict status (pull_request_target) Successful in 16s
PR Gates / Script tests (pytest) (pull_request) Successful in 4m1s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m51s
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
review-verdict/h10 Review-verdict: MERGEABLE @ 7347a08 (base: main)
Local review returned MERGEABLE — no Blocker, High or Medium, both sentinels verified
pinned by mutation, no regressions. Its one Low is taken rather than deferred, because
it is a one-line fix and because deferring it would leave exactly the shape this PR
exists to document.

A 200 whose body is NOT an array never reaches the classifier: the array gate diverts
it, `bp_code` stays 200, and the generic ask then reported "HTTP '200' — Gitea
unreachable, or these credentials lack the repo-admin scope" about a read that plainly
succeeded. That is the identical defect the previous commit fixed for the
throw-inside-the-classifier arm, one branch earlier — fixed where it was noticed, left
in its twin.

The previous commit's message even generalised the pattern ("a sentinel that doubles as
an HTTP code makes a decision state a cause that did not happen") while covering only
one of the two arms it applies to. The test is now parametrised over all three shapes
that reach an unusable 200 — UNPARSEABLE-RULES, GARBAGE, EMPTY — and reverting the new
sentinel reddens the two that the first fix missed.

Also finishes the de-indent the previous commit claimed: that comment block went from
19 leading spaces to 6 while its siblings use 2, so the claim was true of the direction
and not of the result.

729 tests green.

refs #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:50:23 +02:00
timothyandClaude Opus 5 3f0af38509 fix(778): pin the nomatch sentinel — it shipped unpinned, exactly like the guard before it
Local review of the previous commit returned BLOCKED. Both findings taken; nothing
pushed to CI while this was iterating.

MEDIUM — the `nomatch` fix was entirely unpinned. Reverting both sites to `bp_code=404`
left the suite 33/33 green, because no fixture ever emitted an HTTP 404 on the LIST
read: the codes exercised were 000, 403, 500 and 200, and the old `NOT-FOUND` mode had
been repurposed to return 200 with `[]`. So the defect that commit describes could be
reintroduced silently — in a PR whose subject is unfalsifiable guards, one round after
being blocked for precisely that shape. There is now a `LIST-404` fixture and a test;
reverting the sentinel reddens two tests.

The same class, one arm over and found while fixing it: a 200 whose `branch_name` is a
number makes the classifier throw (`//` fires only on null/false), and that was mapped
to `bp_code=000`, reporting "HTTP '000' — Gitea unreachable" about a read that plainly
succeeded. It gets its own `unreadable-rules` sentinel and message, with a fixture and
a test — reverting it reddens.

The pattern across both: a sentinel that doubles as an HTTP code makes a decision state
a cause that did not happen. The decision was safe each time; only the reason lied.

MEDIUM — four comments still described 404-as-a-finding as live, contradicting the hook
comment added in the same commit. The worst said a 404 means "this branch is entirely
unprotected" in a test whose fixture now returns 200 with `[]`, which would have talked
the next reader into re-adding the deny. Renamed that mode `EMPTY-LIST` so it says what
it is.

Also: the hook quoted a reason string it no longer emits; a stray over-indented line
survived the de-indent; `bp_called` was write-only after its reader was removed.

727 tests green.

refs #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:09:16 +02:00
timothyandClaude Opus 5 7d81f85b38 fix(778): make the by-name pin falsifiable — it was filtering on the property it asserted
Confirmation pass returned BLOCKED, and its lead finding is the one worth having.

The test forbidding the by-name lookup recorded URLs from INSIDE the
`endswith("/branch_protections")` branch, so the only URLs it could ever record were
ones that already satisfied the assertion. A by-name request was invisible to the very
test written to forbid it. Cold review proved it by reintroducing the lookup in the
hook: the suite stayed 33/33 green. That is the filter-on-the-asserted-property defect
this PR's sibling record exists to describe, committed inside the guard against it —
and the commit message had called the twin "pinned so it cannot come back".

The recorder now sees every branch-protection URL whatever its shape. Re-verified by
the same mutation: reintroducing a by-name call reddens exactly the two tests that
forbid it.

Also from that pass:
- an HTTP 404 on the LIST read reached the "the full rule list was read and none
  matches" deny — a claim about a read that never happened. Gitea answers 404 for a
  repo that is absent or invisible to the credential, so the classifier's own verdict
  is now the sentinel `nomatch` and HTTP failures reach the ask;
- two comment blocks still described the deleted by-name endpoint as live, one of them
  asserting the classifier "is never reached at all";
- the decision record still documented `branch_protections/{base}` and its 404
  semantics as the mechanism, in the record this PR authored — now rewritten to the
  list endpoint, with why reading the LIST is the load-bearing choice;
- seven assertions on a string the hook no longer emits, and three test
  names/docstrings describing the removed 404 flow;
- an unused fixture helper, and 79 lines left over-indented by the removed nesting.

724 tests green.

refs #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:29:09 +02:00
timothyandClaude Opus 5 f3863c3720 fix(778): delete the by-name lookup — the precedence check was guarding the dead path
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / Docs update reminder (pull_request) Successful in 19s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 18s
review-verdict/h10 Awaiting review verdict for f3863c3
Review verdict / Set review-verdict status (pull_request_target) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
PR Gates / Script tests (pytest) (pull_request) Successful in 4m21s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m20s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m2s
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 6s
Final review returned MERGEABLE with no Blocker and no High. Its one Medium is taken,
and it is my own recurring trap for the third time in this PR: fix one path, then check
its TWIN.

The hook looked a rule up by NAME first and enumerated the rule list only on a 404.
But `branch_protections/{name}` is an exact DB lookup — `GetProtectedBranchRuleByName`
— which performs no matching and knows nothing about precedence. A 200 from it means
"a rule with this NAME exists and lists this context", never "this context is required
on this branch". So the precedence argument added last round guarded the 404 path while
the 200 path granted without it — and since this repo's rule IS named `main`, the by-name
lookup always returns 200. The hardened code was dead and the unhardened code was live.

Given a rule `main` requiring review-verdict/h10 and a rule `m*` with better Priority
that does not, Gitea applies `m*`; the by-name hit on `main` saw h10 and granted anyway.

Fixed by DELETING the twin rather than documenting it: one fetch of the full list, one
classifier, one argument, no second path to keep in step. Two things fall out for free
— the ref no longer reaches a URL segment, so the percent-encoding hazard is removed by
construction rather than escaped (its test is replaced by one asserting no ref reaches
the URL at all), and every case the classifier already covered now applies to the live
path instead of an unreachable one.

Verified against the live Gitea: the classifier returns `exact` -> rule `main` ->
enable_status_check=true, h10 present. A new test pins that the precedence check runs
even when an exactly-named rule exists, and asserts the by-name endpoint is never
requested, so the split cannot come back silently.

The grant string now states what was actually established — read from the full rule
list, matched with Gitea's own plain-vs-glob split, refusing wherever precedence or
folding is not derivable — rather than the stronger "confirmed required" it claimed
while consulting a single named rule.

724 tests green.

refs #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:07:09 +02:00
timothyandClaude Opus 5 c7bea972d6 fix(778): close the last three residuals — ambiguous exact rules, non-ASCII folds, one unpinned test
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 15s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 21s
PR Gates / decisions lifecycle (pull_request) Successful in 27s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Awaiting review verdict for c7bea97
Review verdict / Set review-verdict status (pull_request_target) Successful in 20s
PR Gates / Script tests (pytest) (pull_request) Successful in 4m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m15s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m41s
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
Confirmation review returned MERGEABLE at 356cddbb5, having independently re-executed
every claim (the "2 -> 6 dead-classifier" figure exact, the "20 tests" trap figure
exactly reproducible, the arm reorder pinned, live branch protection confirming the
dated claim). Its three residuals are taken here.

M1 — the backslash test hit the undecidable arm but asserted only `"ask" in reason`,
which a CRASHED classifier also satisfies. Its two siblings got `"could govern it"`
one commit earlier and this one did not. Under the dead-classifier mutation it was the
single green test of that arm; now red.

L1 — with two plain rules differing only in case, `first` picked LIST order while
Gitea picks by Priority. Given `MAIN` requiring review-verdict/h10 and `main` not, the
hook could inspect the rule that requires it and auto-grant on a base where Gitea
enforces the other — the same defect as the arm order, one level down. Two fold-equal
rules are now undecidable.

L2 — the same standard, applied where I had waived it. The backslash paragraph rejects
"nearly unreachable" as a standard for the arm that issues a DENY, and two paragraphs
up the ASCII-only fold was accepted on exactly those grounds: rule `ünstable` and base
`Ünstable` fold equal under Gitea's EqualFold and not under `ascii_downcase`, landing
on `none` -> deny with a false stated cause. A non-ASCII rule name or base is now
undecidable rather than fold-compared.

The non-ASCII test is `explode | any(. > 127)`, not a `\uXXXX` regex: the first
attempt was a character-class regex whose backslashes are ambiguous through a
single-quoted shell string into jq, and a standalone probe showed it classifying plain
`main` as non-ASCII. Codepoints have no escaping layer to get wrong.

Both new arms are mutation-proven individually; an earlier attempt at those proofs used
perl substitutions that silently matched nothing and reported green, which is why they
were redone in python with an explicit assert on the target.

refs #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 20:23:32 +02:00
timothyandClaude Opus 5 356cddbb59 fix(778): pin the arm order, and correct a wrong explanation I had just written
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 18s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 27s
PR Gates / Docs update reminder (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 18s
review-verdict/h10 Awaiting review verdict for 356cddb
Review verdict / Set review-verdict status (pull_request_target) Successful in 44s
PR Gates / Script tests (pytest) (pull_request) Successful in 4m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m12s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m1s
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
Two self-found defects while pre-empting the confirmation round's own questions.

THE REORDER WAS UNPINNED. Swapping the classifier arms back to exact-first left all 29
tests green, so the previous commit's central change was invisible to the suite — an
unproven change shipping under a green run. The missing fixture is the one that
distinguishes the orders: a list holding BOTH an exactly-named rule that requires
`review-verdict/h10` AND a glob rule that could also govern the base and does not.
Exact-first inspects the rule that requires h10 and auto-grants; undecidable-first
asks. Now mutation-proven in both directions.

I HAD MISDIAGNOSED THE TRAP, and asserted the wrong cause in a comment. A three-line
repro disproves "an EXIT trap suppresses output" — it does not. The real mechanism is
that this file already owns its EXIT trap: `scripts/hook-fire-log.sh` installs
`trap 'etv_hook_fire_end "$?"' EXIT` (#776), and in capture mode that handler is what
REPLAYS the decision JSON to stdout. A second `trap ... EXIT` silently replaces it, so
the decision is captured and never emitted. The trap slot is a single shared resource
and the sourced library claimed it first.

That second one is the shape this whole PR is about, committed against my own work: an
explanation that fits the symptom, is written down as fact, and is wrong. It would have
told the next person the construct is unusable rather than that the slot is taken.

refs #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 19:54:47 +02:00
timothyandClaude Opus 5 d8ff6fa229 fix(778): ask before trusting an exact rule, and make the tests tell classified from crashed
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 15s
PR Gates / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 22s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Awaiting review verdict for d8ff6fa
Review verdict / Set review-verdict status (pull_request_target) Successful in 14s
PR Gates / Script tests (pytest) (pull_request) Successful in 4m55s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Canceled after 7m39s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Canceled after 0s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Canceled after 0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
Ninth cold review: MERGEABLE, no Blocker, no High. Its three Mediums taken anyway,
because each was a one-line fix retiring the last "asserted rather than verified"
surface in the file whose whole subject is that shape.

M1 (backslash missing from the metacharacter class) was already closed in cd1b28637 —
found independently while stress-testing the superset claim, in the window the reviewer
was working against the previous head.

M2 — the `exact` arm was not decidable. Gitea picks the governing rule with
GetFirstMatched over a list sorted by Priority and THEN by plain-name-ness, so a glob
rule with a better Priority outranks an exactly-named one. Preferring `exact` would
inspect a rule Gitea might not be applying: if the exact rule requires
`review-verdict/h10` and a higher-priority glob rule does not, the gate auto-grants on
a base where the check is not enforced. `undecidable` is now evaluated FIRST, which
makes the classifier sound without knowing the precedence rules at all — the only
claim this code is entitled to make about somebody else's resolver.

M3 — four tests could not distinguish "classified correctly" from "classifier
crashed", because a dead classifier lands on the generic could-not-read ask and they
asserted only `"ask" in reason`. Measured with the reviewer's method rather than
argued: injecting `error(...)` at the head of the jq program left 2 of them red; the
strengthened assertions leave 6. The glob arms now pin the text unique to the
undecidable ask, and the decidable arms assert the rule was HONOURED rather than
referred to a human.

Also: the module docstring still described a three-arm contract after this change added
a fourth; the inventory row still summarised the old two-way behaviour; the ASCII-only
case fold is now stated as a deliberate under-match rather than as parity with
EqualFold; the live-config claim is dated; and one jq call rejoined the file's
`|| true` discipline.

A `trap ... EXIT` for temp-file cleanup was tried and REVERTED: it suppressed the
hook's decision output entirely and took 20 tests red. A gate that prints nothing is
the one outcome it must never produce, so the tidier construct loses to the one that
works, with the reason recorded where the next person will try it.

refs #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 19:47:06 +02:00
timothyandClaude Opus 5 cd1b286378 fix(778): backslash is a metacharacter too, or the none arm's proof does not hold
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 18s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
review-verdict/h10 Awaiting review verdict for cd1b286
Review verdict / Set review-verdict status (pull_request_target) Successful in 16s
PR Gates / Script tests (pytest) (pull_request) Successful in 4m12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m56s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m49s
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
Self-found while stress-testing the superset claim I introduced one commit earlier —
the crux the new classifier's safety rests on.

`none` authorises a DENY on the stated grounds that nothing can possibly govern this
base, so its premise must hold unconditionally, not usually. The superset was "literal
prefix + .* + literal suffix", which is sound for every glob dialect EXCEPT one case:
gobwas/glob reads `\{` as a LITERAL brace, so a rule `a\{b` governs the base `a{b`,
while a superset treating `\` as an ordinary character builds `a\.*b`, misses, and
denies a base that is in fact protected.

Verified before and after: with `\` outside the metacharacter class the classifier
answered `none` for that pair; with it inside, `undecidable` -> ask. 18 adversarial
rule/base pairs (brace alternation, negated and ranged classes, `**`, leading and
trailing metacharacters, unicode, empty alternation) all answer `undecidable`, never
`none`, so no dialect-matching case falls through the deny arm.

Git ref rules make this nearly unreachable — a branch name may not contain `*`, `?`,
`[` or `\` — but `{` IS legal in one, and "nearly unreachable" is not the standard for
the arm that issues a deny. Checked with `git check-ref-format` rather than assumed.

refs #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 18:59:36 +02:00
timothyandClaude Opus 5 456042ee51 fix(778): stop claiming parity with Gitea's glob matcher — classify, or ask
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 27s
PR Gates / Docs update reminder (pull_request) Successful in 29s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 23s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 28s
review-verdict/h10 Awaiting review verdict for 456042e
Review verdict / Set review-verdict status (pull_request_target) Successful in 15s
PR Gates / Script tests (pytest) (pull_request) Successful in 4m56s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Canceled after 7m16s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Canceled after 0s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Canceled after 0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
Eighth cold review: no Blocker, no High. Three Medium, two Low, one Nit.

MEDIUM — the substantive one. The glob fallback asserted it matched rules "the same
way Gitea applies them", and it does not. Gitea compiles a rule name with gobwas/glob
and a `/` separator: its `*` does NOT cross a slash, `?`/`[…]`/`{a,b}` are wildcards,
and a plain name is folded case-insensitively. Mine used `.*` for `*` and escaped the
rest. The divergence has a false-OPEN direction — `release/*` does not govern
`release/26/hotfix` in Gitea, but `release/.*` matched it here, which would auto-grant
a scheduled merge on a base where the check is not required. That is #622's hole,
reached through the block written to close it, via exactly the failure this PR
records: a claim about an external system asserted rather than verified.

Reimplementing somebody else's glob dialect would be a second copy of a parser, which
this repo has already withdrawn a change for. So the classification is three-way and
each arm is safe WITHOUT knowing the dialect: an exact non-glob name folded
case-insensitively is decidable; a glob rule that could govern the base is
UNDECIDABLE and asks; and "could" is tested with a provable superset of any glob
dialect — literal prefix, `.*`, literal suffix — so if even that cannot match, no
dialect can. Over-matching would grant on unestablished protection; under-matching
would deny with a false cause. Asking is the only answer honest in both directions,
and it is rare: this repo's rule is the plain name `main`.

MEDIUM — a count that was wrong the moment it was written ("46 of the 69 rows are
N/A"; it is 44). It was added by the same commit that demoted two rows. That is the
FOURTH stale number in this change, in the deliverable whose own record argues against
hand-maintained counts. Removed rather than corrected, with the reason stated.

MEDIUM — `migration-smoke.sh` still said "Same shape" as `security-scan.sh`, whose
note had just been rewritten to the opposite conclusion, so the backreference had
silently inverted. It is the same pull-then-run over a mutable tag and deserves the
grade MORE, not less: `security-scan.sh` boots a throwaway container and authorizes
nothing, while this gates a production stack recreation. Regraded.

LOW/NIT: an `end <= start` guard that `str.index(…, start)` makes unreachable by
construction is replaced with the reachable failure it was describing; a docstring
still named a fixture from an earlier draft; a reflowed `#707.` was line-initial.

Two mutations were ineffective on the first attempt — one left the `decide ask`
continuation in place, the other had no test covering case-folding. Both redone; each
arm now reddens a named test.

Decisions-Edit: yes

refs #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 18:52:18 +02:00
timothyandClaude Opus 5 7db4101b24 fix(778): the escape emitted TWO backslashes, turning a false-open into a false DENY
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 10s
PR Gates / decisions lifecycle (pull_request) Successful in 24s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 25s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 21s
review-verdict/h10 Awaiting review verdict for 7db4101
Review verdict / Set review-verdict status (pull_request_target) Successful in 28s
PR Gates / Script tests (pytest) (pull_request) Successful in 4m56s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
Seventh cold review. One High, one Medium, three Low, seven Nit — all in the two
newest commits, which is where every round of this PR has found its defects.

HIGH, and it is my own fix from the previous commit. In jq source `"\\\\"` decodes to
TWO backslashes, so escaping produced `\\.` — "a literal backslash, then any
character" — instead of an escaped dot. Every rule name containing a metacharacter
became UNMATCHABLE, and a rule named `a[b` crashed jq outright (swallowed by
`|| true`). Verified: `release/26.*` no longer matched base `release/26.4`, so the
fallback found nothing and hard-DENIED with the stated cause "has NO branch protection
at all" — converting a false-open into a false deny, which the block's own comment
calls the worse outcome. One character: `"\\" + .c`. Correct across 14 rule/base pairs.

WHY MY TEST MISSED IT, which is the transferable part: it asserted only the NEGATIVE
direction (`mai.` must not match `main`). A rule matched literally and a rule made
unmatchable both fail to match the wrong base, so the assertion passed for the wrong
reason. Only a rule that SHOULD match separates them, and there was no positive
control. There is now — plus a char-class case — and both go red against the
over-escaped version. That also needed a base containing a dot: a rule cannot carry a
metacharacter and still match `main`, so the first attempt at the positive control was
unsatisfiable by construction.

MEDIUM — four live claims that the population "derives from the filesystem", left
standing by the commit that replaced that mechanism: the guard's own docstring 45
lines above a comment shouting the opposite, the inventory heading 21 lines under
"Every git-tracked file", the docs/README entry, and — worst — the record's
`mechanics:` frontmatter, which is the copy the catalog and MemPalace mirror, so
discovery would have returned the superseded lesson. All corrected.

LOW/NIT: the URL-encoding test grepped the source for `@uri` (it now asserts the URL
actually requested, and reddens when the encoding is removed); the hoist comment said
"every path below" without noting the docs-only enumeration above it (bounded — that
path is a passthrough to a human prompt, never a grant); a now-unreachable guard is
annotated rather than left reading as live; `issue-qualification-audit.sh` was `N/A`
while `select-queue.sh` was `UNSAFE-KNOWN` on the same argument, and
`security-scan.sh` claimed "one step" for a pull-then-run over a mutable tag — both
regraded; the `PINNED` definition now says what separates its second shape from an
`N/A` "one step" row (the identifier's immutability, not the step count); the section
parser raises a message naming both required headings instead of a bare ValueError;
and the record's body is rewrapped.

Decisions-Edit: yes

refs #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 18:21:04 +02:00
timothyandClaude Opus 5 01de6cbd8b fix(778): * is the only wildcard — a rule name's regex metacharacters were live
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 21s
PR Gates / Docs update reminder (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 24s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Awaiting review verdict for 01de6cb
Review verdict / Set review-verdict status (pull_request_target) Successful in 32s
PR Gates / Script tests (pytest) (pull_request) Successful in 4m51s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 4m27s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
Found by attacking my own glob fallback from the previous commit before the reviewer
got to it, which is the round where this PR's defects have landed every time.

The 404 fallback matched a branch-protection rule by substituting `*` into a raw
regex, leaving every other metacharacter active. Verified directly: a rule named
`main.x` matched the base `mainax`, and `a+b` matched `aab`. The direction is the one
that matters — a spurious match to some OTHER rule that happens to require
`review-verdict/h10` reports this base as protected when nothing governs it, so a
consent gate answers yes on evidence about a different branch.

Each literal segment is now escaped before the pattern is assembled, so the wildcard
survives and nothing else does. Verified across 11 rule/base pairs: metacharacters are
literal, `*` still spans, exact and non-matches unaffected.

The regression test needed two goes to stop being vacuous, both times for the same
reason the rest of this PR keeps hitting: the stub never 404'd for the new mode, so
the run denied earlier via the by-name lookup and never reached the fallback at all. It
now goes red against the unescaped predecessor.

refs #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 18:01:42 +02:00
timothyandClaude Opus 5 4261f76dd2 fix(778): derive the population from git, not the disk — the guard was red on every dev checkout
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 22s
PR Gates / Docs update reminder (pull_request) Successful in 17s
PR Gates / decisions lifecycle (pull_request) Successful in 24s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 10s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Canceled after 45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Canceled after 0s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Canceled after 0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
review-verdict/h10 Awaiting review verdict for 4261f76
PR Gates / Script tests (pytest) (pull_request) Canceled after 19s
Review verdict / Set review-verdict status (pull_request_target) Successful in 20s
Sixth cold review (a different reviewer, in-repo, worktree-isolated after the
cross-family runs wedged twice on their sandbox). One High, one Medium, two Low, two
Nit. All fixed.

HIGH, and it is the third time this population has been wrong. `rglob` is recursive,
so it also enumerated `.husky/_/` — 17 husky shims generated by `npm ci` via
web/package.json's `prepare`, gitignored and untracked. The guard therefore derived 76
files against a 59-row table and was RED on every checkout that has run `npm ci`,
while staying GREEN in CI, whose `script-tests` job checks out and pip-installs but
never runs `npm ci`. A guard that fails everywhere except where it runs is the fastest
possible route to "that test is always broken, ignore it" — on the artifact whose
entire thesis is population correctness. Reproduced, then fixed at the source rather
than with a fourth traversal patch: the population now comes from `git ls-files`. The
index is authoritative, identical for CI and every checkout, and excludes untracked
build output by construction instead of by an exclusion list someone must maintain.
That is what this PR's own record says to do; the first three attempts each derived
from whatever happened to be on disk. Three tests go red against the rglob
predecessor.

MEDIUM — twin-missed, in the fix from the previous round. Round 4 re-read the base
before the branch-protection lookup, inside the scheduled branch only, leaving the
#632 retarget DETECTION still reading the top-of-hook snapshot. The reviewer
demonstrated it with this PR's own fixture: scheduled+retarget denied while
immediate+retarget AUTO-GRANTED. The re-read is now hoisted above every base-dependent
consumer, so one read serves both paths, and the duplicate is gone. Note for the
record: the hoist is the load-bearing part — once `live_base` is fresh, #632's own
comparison catches the retarget too, so the explicit deny only bites when no verdict
records a base. The tests are scoped to exactly that case, because as first written
they passed under mutation.

LOW — a 404 from `branch_protections/<ref>` does not prove the branch is unprotected.
Gitea keys that endpoint on the RULE name, so a base covered by a glob rule 404s while
being fully protected, and an unencoded ref containing `/` (`release/26.4`) 404s
because the path is malformed. Both produced a hard deny stating a specific, false
cause — and a deny blocks outright rather than prompting. The ref is percent-encoded,
and a 404 now consults the rule list before denying; an unreadable list asks.

LOW/NIT — the scope prose attached the extension restriction to `scripts/` alone while
the guard applied it everywhere (a `.py` hook would have joined the described scope and
acquired no row); `.yaml` workflows are now in scope too. The `PINNED` definition
required re-validation, which two legitimately-pinned rows do not do because their
check and use are one step over an immutable event-payload sha. Row ordering restored.

And once more, the recurring one: adding a scope TABLE to the doc made three prose
rows parse as inventory sites — the parser reading its own documentation as data, the
same defect as the UNSAFE-KNOWN check that once parsed the paragraph defining
UNSAFE-KNOWN. Row parsing is now bounded to the inventory section explicitly.

refs #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 17:53:25 +02:00
timothyandClaude Opus 5 f1e9cce4ae fix(778): round 4 — the guard was checking a stale base, which is the defect it enforces against
PR Gates / decisions lifecycle (pull_request) Successful in 19s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 26s
PR Gates / Docs update reminder (pull_request) Successful in 23s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 21s
review-verdict/h10 Awaiting review verdict for f1e9cce
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 15s
Review verdict / Set review-verdict status (pull_request_target) Successful in 36s
PR Gates / Script tests (pytest) (pull_request) Successful in 4m44s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m48s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m34s
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 6s
Fifth cold review: no Blockers, no Highs. 1 Medium, 1 Low, both fixed here. It also
confirmed the #803 deferral is sound and re-verified every count (59 files, 69 rows,
3 PINNED; guards 34/5/15, proofs 8/6/20).

MEDIUM — and it is the one worth the round. The branch-protection lookup used
`$base_ref` from the PR snapshot taken at the TOP of the hook, and everything between
is round trips (the file enumeration alone can be forty pages). A PERSISTENT retarget
in that gap needs no ABA and no force-push: the lookup names the OLD base, confirms
`review-verdict/h10` on a branch the PR no longer targets, and grants a scheduled
merge onto one that may require nothing. The guard written to enforce
"checking a stale identifier is not checking" was doing exactly that. The base is now
re-read and compared immediately before the lookup; a move denies and names both
branches. Mutation-proven.

LOW — my caveat erred in the rare direction, understating a clause instead of
overstating it. The scalar-row test's docstring called the whole `.statuses` member
validation defence-in-depth because the #632 block masks it. That block validates
`.context` and `.description` but NOT `.status`, so an object row with a numeric
status passes it and does reach the new validator — where without the clause it
becomes `vstate=7` and is reported as "the verdict is '7'" rather than as an
unreadable payload. The caveat is now scoped to the payload rather than the clause,
and the reachable case has its own test, also mutation-proven.

The inventory row now conditions the guarantee on BOTH of its preconditions — the PR
still targeting that base (fixed here) and the protection still standing (cannot be
closed here, and said so).

refs #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 15:31:49 +02:00
timothyandClaude Opus 5 8e02b9961d fix(778): round 3 — fix the overclaim I left in the code, and defer the pre-existing ones to #803
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 18s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 19s
PR Gates / Docs update reminder (pull_request) Successful in 22s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 26s
review-verdict/h10 Awaiting review verdict for 8e02b99
Review verdict / Set review-verdict status (pull_request_target) Successful in 17s
PR Gates / Script tests (pytest) (pull_request) Successful in 4m9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m57s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m53s
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 5s
Fourth cold review: no Blockers, 2 High / 2 Medium / 2 Low. It independently
re-derived the population (59 files, 59 sites, 69 rows, 3 PINNED) and verified every
numeric and factual claim in the diff, including the corrected confinement rationale.

THE ONE THAT STINGS. The grant reason string still said a commit pushed before Gitea
merges "will clear it and block the merge" — the exact sentence the new decision
record quotes as THE overclaim this issue exists to remove. I documented it in three
files and left it in the code a human actually reads. It now states the guarantee and
its condition: the required check was confirmed rather than assumed, and it holds
while that branch protection stands.

FIXED HERE (all in files this PR already touches):
- enable_status_check is validated as a BOOLEAN. `"true"` is not `true`, and comparing
  the string to `true` produced a confident deny from a payload never understood —
  the tri-state collapsing to two, the same defect as the contexts shape one line down.
- `.statuses` members are validated, not just the array (see the honest caveat below).
- the docs-reminder N/A rationale said "the job cannot fail and never reaches the
  combined status", which is false — any job's status joins the combined state. The
  true, narrower reason is that its fetch and diff are failure-swallowed, so the
  remote read can only change the warning's wording.
- docs/README names the scripts/tests exclusion in BOTH statements.

A VACUOUS TEST, CAUGHT BY ITS OWN MUTATION PROOF. The regression case for the
`.statuses` member validation stays GREEN against the predecessor: the #632
base-retarget block runs first and already validates every member it consumes, so it
catches the payload before the scheduled branch is reached. The two guards overlap —
duplicate guards masking each other, again — which makes that finding LATENT, not
live, and my added clause defence-in-depth rather than a fix. The test now asserts the
observable contract (a decision is always emitted) and says plainly that it is not a
mutation proof of the newer clause. Shipping it as one would have been the exact
grade inflation round 2 rejected.

DEFERRED to #803, with the reason stated rather than implied: a head-ABA
(force-push H1 -> H2 -> H1 during pagination) defeats pr-changed-files.sh, and three
OLDER contracts still assert more than the new inventory rows do. That residual
predates #778 and lives in #707's mechanism; correcting another active decision
record inside a PR already at four review rounds is how a scoped change stops being
reviewable. The inventory rows are accurate today and now point at #803.

refs #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 14:15:24 +02:00
timothyandClaude Opus 5 84b4c11188 fix(778): round 2 — recursive population, and grade down five more overclaims
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 22s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 23s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / decisions lifecycle (pull_request) Successful in 24s
review-verdict/h10 Awaiting review verdict for 84b4c11
Review verdict / Set review-verdict status (pull_request_target) Successful in 22s
PR Gates / Script tests (pytest) (pull_request) Successful in 4m10s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Canceled after 5m4s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Canceled after 0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
Second cold review: no Blockers, 4 High / 1 Medium / 2 Low. All accepted.

POPULATION, WRONG A SECOND TIME. Round 1 removed a content filter that had
omitted `git fetch`. Round 2 found the replacement traversal used non-recursive
`Path.glob`, so four nested files were still outside it — including
scripts/scripted-schedules/entrypoint.py, which calls get_context() against a live
ErsatzTV server and then drives define_content/reset_playout/build_playout off the
result. Now rglob, with scripts/tests/ as the single stated DIRECTORY-level
exclusion (a scope choice, reviewable in one line; not a predicate over content).
Population 55 -> 59, rows 63 -> 68.

The generalisation is in the record, because the deliverable made the same mistake
twice: the scope may be hand-written, but anything narrowing the POPULATION has to
be executed and its output compared against the filesystem — the members it drops
are invisible by construction. That is the #774 rule turned on the artifact meant
to enforce it.

FIVE MORE OVERCLAIMS GRADED DOWN. Both merge-consent head/base rows (the hook
returns `allow` and a separate call merges, so the window is small, not absent —
"no async window" was simply false); the release smoke pull and the ci-image verify
(the concurrency group is PER-REF, so a branch build and a tag build of one commit
can publish the same :<short-sha>); and the workflow base-fetch rows, which are not
advisory — the merge hook reads the COMBINED status, so any red context blocks the
auto-grant. Also fixed a stale cross-reference where the enumeration row still said
it "inherits that row's pins" from a row graded down in the same commit.

Four PINNED rows survive out of 68. That ratio is the honest finding.

CIRCULAR JUSTIFICATION REMOVED. The scheduled-merge row said its residual was
"closed one layer down" by the very branch protection an admin may have removed.
It is not closed, it is BOUNDED by a trust assumption, and the row now says so.

Low: jq's `//` fires on `false` as well as null, so `status_check_contexts: false`
was defaulted to [] and produced a confident deny from a shape never understood —
absent and null are now defaulted explicitly, everything else is "unknown". And the
title sentence claimed "every executable in this repo" while the guard covers four
directories; both it and the docs/README entry now say what is actually enforced.

refs #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 13:44:10 +02:00
timothyandClaude Opus 5 a0849eec58 fix(778): address the cold review — drop the population filter, grade down three overclaims
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 18s
PR Gates / decisions lifecycle (pull_request) Successful in 24s
PR Gates / Docs update reminder (pull_request) Successful in 9s
review-verdict/h10 Awaiting review verdict for a0849ee
Review verdict / Set review-verdict status (pull_request_target) Successful in 17s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 13s
PR Gates / Script tests (pytest) (pull_request) Successful in 3m58s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m43s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m30s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m29s
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 6s
Independent cross-family review (Codex, cold brief, read-only worktree) returned
BLOCKED with 9 findings. All 9 accepted; #5 partially, keeping one row PINNED with
its justification stated.

POPULATION (the finding that mattered most). The derivation filtered the scope by an
outbound-network token list and argued that was a scope choice rather than a
population filter. It omitted `git fetch` — this repo's most common remote read — so
prepush-rebase-check.sh, which fetches origin/main and derives a PUSH DECISION, was
structurally invisible to a guard claiming to cover "every executable that reads live
remote state", along with three others. The defence offered was that over-inclusion is
the safe direction; the filter also under-included. The content filter is gone: the
population is now all 55 files in the scoped directories, and a file that reads no
remote state carries an explicit N/A row.

OVERCLAIMS, graded down. Three rows asserted more than the code does:
- the scheduled-merge path was PINNED while the hook's own comment concedes the
  branch-protection read pins nothing → UNSAFE-KNOWN, "preflight, not a pin";
- pr-changed-files.sh was PINNED and claimed "any movement fails", but
  before-and-after equality is ABA-vulnerable (main → scratch → main) → UNSAFE-KNOWN,
  pointing at the caller-side event-count fence that does close it;
- the CI toolchain image was PINNED on a mutable TAG, against this file's own
  definition naming a digest → UNSAFE-KNOWN. The release smoke pull stays PINNED: it
  pulls the tag the same concurrency-serialized job just pushed.

The guard-inventory MUTATION regrade is reverted to BEHAVIOUR-ONLY (8/6/20). The
review is right on species: the test feeds the real script an input the clause
rejects, which this table explicitly defines as behaviour-only and has already
regraded three rows for. A manually-executed disarm does not change what the test is.

TWO REAL FAIL-OPENS FIXED:
- jq `index()` on a STRING is substring search, so a status_check_contexts arriving
  as "prefix-review-verdict/h10-suffix" answered yes and would auto-grant. Membership
  is now exact equality over a value first proven to be an array of strings.
- post-review-verdict.sh guarded both re-read comparisons with `[ -n "$x" ] &&`, so a
  2xx body that merely omitted .head.sha or .base.ref made the check a no-op and the
  status was posted having confirmed nothing.

That second fix carries a lesson worth the line: the obvious mutation (disarm the new
`-z` arm) stays GREEN, because the unconditional `!=` also rejects empty — the two
overlap, exactly the duplicate-guards-mask-each-other shape. The proof is taken
against the REAL predecessor with the `-n` conjunct restored, which goes red showing
returncode=0 and a status written.

Also: 404 is now separated from 403/transport (an unprotected branch is the strongest
form of the finding; `curl -sf` collapses both to an empty string), and the positive
control asserts the decision is `allow` and that the endpoint was actually reached,
rather than the absence of one phrase.

refs #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 13:20:42 +02:00
timothyandClaude Opus 5 fe578c5864 feat(778): name the check-and-use race, and verify the protection the merge gate rests on
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / Docs update reminder (pull_request) Successful in 24s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 26s
PR Gates / decisions lifecycle (pull_request) Successful in 31s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
review-verdict/h10 Awaiting review verdict for fe578c5
Review verdict / Set review-verdict status (pull_request_target) Successful in 14s
PR Gates / Script tests (pytest) (pull_request) Successful in 4m8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m38s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m19s
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) Canceled after 4s
#773 §3 Family D is the one class its taxonomy had no bucket for: a check and the
action it authorizes are separated in time over state that can change in between,
with nothing pinning a version (#536, #622, #632, #706, #707).

The repo had already solved this twice without noticing it was one problem — a
compare-exchange claim in-process (ffmpeg.work-ahead-slot-atomic) and RFC 7232
If-Match across /api/v1 (concurrency.ifmatch-rfc7232) — and then solved it a third
time from scratch for the tooling at #706/#707. Hence a class-level record rather
than a fourth per-instance one.

What the enumeration actually found, which none of the five records predicted:
the merge-consent hook's scheduled-auto-merge path is safe only because
`review-verdict/h10` is a REQUIRED status check on main. That is branch-protection
CONFIG, it lives outside this repo, nothing compared the two, and the hook asserted
it in a comment AND in the grant reason a human reads. Switch the context off and
every word of that sentence is false while the hook keeps printing it and keeps
auto-granting. The hook now reads the branch protection and treats it as three
outcomes: present proceeds, unreadable asks, absent denies.

Two defects were caught by the new checks themselves rather than by review:
- the population test found .gitea/workflows/dependency-scan.yml absent from the
  first draft of the inventory (a sixth workflow the recon slice never listed);
- self-review found the guard denying with a confident wrong reason when jq errors
  one level down on a malformed contexts member, so the word is now matched
  exhaustively rather than compared against "yes". Same swallow that survived the
  first fix in the #632 base-change guard.

Detector D has no plausible linter, so the detector is detector A applied to an
enumerated inventory: docs/remote-state-inventory.md classifies every in-scope
executable, and scripts/tests/test_remote_state_inventory.py derives the population
from the filesystem and asserts set equality both ways.

Deferred with reasons stated in the inventory: select-queue.sh (advisory, authorizes
no write), ci-detect-already-validated.sh (skip elides re-validation only, the image
still builds), review-verdict.yml's status POST (Gitea offers no conditional write;
already fenced by #706's retarget counter).

Mutation proofs witnessed for both new guards, clause-level, not whole-file.

fixes #778

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 12:57:04 +02:00