Commit Graph
26 Commits
Author SHA1 Message Date
timothyandClaude Fable 5.1 a9341d8415 fix(881): a GREEN claim is only readable if its proof REACHES the clause
Round three found the one half of the new mechanism with no relevance gate.
`verify_claim`'s GREEN path read exactly two things — the run exited 0, and
something PASSED — and both are satisfied by a proof that never touches the
mutated file at all. Reproduced before fixing: retargeting the shipped GREEN
entry's proof from `test_check_doc_narrative.py` to `test_bom_guard_detection.py`
changed nothing, and the entry still reported verified. The RED direction never
had this hole, because a proof that ignores the mutation stays green and is
refused as "the clause is not load-bearing".

So a GREEN entry now declares a `reach_replacement` and its `reach_expect`: a
SECOND mutation of the SAME clause, required to REDDEN the same proof, executed
through `verify_mutation` so its red is read through the diagnostic gate rather
than on exit status. The shipped entry declares `path = p` — dropping the `b/`
stripping every scanned diff header goes through — and the run then scans
NOTHING, which is what the declared diagnostic reads. The same retarget now
fails, naming the reach verdict.

The gate runs LAST of the three: run first it would refuse before the status and
vacuity gates were read and neither could be witnessed failing alone (#685), and
the sandbox is reset between a claim's two proof runs for the reason it is reset
between mutations. It has its own disarm proof, and the two synthetic claim
sandboxes are now real git repositories so `reset_sandbox` has a baseline;
`_lib_with` shares the baseline registry, since a copied module's own starts
empty.

Also from that round:

- The record no longer counts the mutation-outcome claims in the pinned
  proposal-3 scan. A third of the same shape sits in the same result set
  (`test_a_verdict_BEYOND_A_SHORT_PAGE_is_still_found`), and which side of the
  line a sentence falls on is a judgement, so an exact count is a figure the
  next reader re-derives differently — the failure this record is about.
- The calibration paragraph no longer restates the post-review-verdict outcome
  as a dated witnessing. It points at the `CLAIMS` entry that executes it, which
  is the form the rewritten shell comment beside it demands.
- The comment in `check-doc-narrative.py` claimed a universal ("reddens no
  test") while one file is executed. It now names that file, so the quote binds
  an outcome no wider than what is checked.
- Proposal 4 from the issue is dispositioned explicitly: rejected as a rule
  here, on the issue's own argument that an exhortation does not fire at the
  moment of least slack.
- `docs/README.md`'s task-signal parenthetical now names the `CLAIMS`
  population; the file was owned by another slot when this branch started.

Cost re-measured 2026-09-05, three baseline/branch pairs: the `CLAIMS` half adds
31.7-43.6%, up from the 12.7-16.6% measured before the gate existed. The old
figure is retired rather than scaled — growing the population invalidates the
measurement that described it.

Refs #881

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 05:02:46 +02:00
timothyandClaude Fable 5.1 7dba36cd58 fix(881): my own paragraph asserted the scan found no mutation-outcome claims - it found two
Self-review of the previous commit, on the same class it fixes.

"none of them asserts a mutation outcome", written about the 58 lines the
proposal-3 scan returns, is false. Two do: the mutation table at
`docs/decisions/records/ffmpeg/watermark-resolution-unified.md` line 104, which
names a dropped discriminator and the single test that catches it, and
`web/src/screens/AutoTuneScreen.test.tsx` line 174, which says what a revert to
the old flex row can redden. Both read in full at `efadbec29` rather than from
the truncated grep line - the truncation is how the first pass missed them.

Three kinds of sentence under one pattern is a better argument than the one the
false claim was making: it is not that the pattern finds only rationale, it is
that it finds rationale, state anchors and mutation-outcome claims side by side
and nothing in the string tells them apart.

Two smaller ones in the same commit. The new test's docstring said the gate is
"the one gate the others cannot cover" and the manifest said "the one PRE-FLIGHT
refusal a red proof cannot be told apart from" - both assert uniqueness among
the pre-flight refusals that neither measured, and a clause occurring zero times
also leaves the text identical. Narrowed to what the mutant demonstrates: no
later gate stands in for it. And the fixture comment glossed `verify_claim`'s
GREEN refusal in quote marks, which under this record's own proposal-2 clause
reads as a quotation of the library; it is not one, so the marks are gone.

refs #881

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 04:44:29 +02:00
timothyandClaude Fable 5.1 99966f8bd7 fix(881): a control's purpose stated backwards, and an enumeration that grew past what it could carry
Four review findings, all of the class this branch is about - prose asserting
what a mechanism does, with nothing binding it to the mechanism.

The record's `mechanics:` said a GREEN claim over an already-red proof "would
otherwise be satisfied by the redness it is supposed to rule out". Inverted:
`verify_claim`'s GREEN branch REFUSES any non-zero exit, so redness refutes a
GREEN claim and can never satisfy one - which is what
test_MUTATION_disarming_the_GREEN_EXIT_STATUS_gate_accepts_a_proof_that_WENT_RED
asserts. The hazard the control removes is the same one it removes for the
rows, and it runs in both directions: an already-red proof satisfies a RED
claim with redness its mutation did not cause, and refuses a GREEN one for a
reason unrelated to its mutation. Both the record and the fixture comment now
say that, and both say what the control CANNOT do - its assertions are over the
aggregate of every proof ref, so a single ref collecting nothing is invisible to
it and is caught per-claim by the vacuity gate instead.

The manifest's `why` had widened a scoped sentence into "THE OTHER GATES EACH
CARRY THEIR OWN PROOF" and then enumerated them, which made the enumeration a
completeness claim it could not meet: the identical-replacement refusal carried
no proof at all. The review measured that at 8adf21eff - `if mutated ==
original:` disarmed, whole file 49 passed 1 skipped. That gate is the one a red
proof cannot be told apart from: the mutant is byte-identical, so the proof runs
against the original tree and an already-red one reddens exactly like a
detection. Disarmed, the harness certifies it as "the named test went red under
the declared mutation, with the declared diagnostic" - witnessed here on the
real library, restored after. So the measurement above no longer holds, by
construction: the gate now has a disarm proof, and the sentence says explicitly
that naming the gates is not a claim the list is closed.

Proposal 3's rejection quoted "16 lines" with no predicate - the defect the
record's own body names three paragraphs later, where the population scan is
pinned verbatim for exactly that reason. The figure is not reproducible from the
text. Replaced by a pinned `git grep` over the same corpus at the same sha
(`32 files, 58 lines`), with what reading all 58 shows: they are rationale, the
class the rule carves out, and the few real state anchors among them are not
separable by pattern, because the difference is whether the sentence explains or
asserts.

refs #881

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 04:44:29 +02:00
timothyandClaude Fable 5.1 0c970fc7c1 docs(881): the CLAIMS preamble kept a count nine lines above saying it keeps none
"one of the three entries below asserts that a mutation is NOT noticed" is a
count of the entries it sits above, and the preamble's last line - and the file's
own docstring - say no count is kept here, because a count of the entries is a
second copy of them. The previous wording said "three", which was also wrong: one
entry is GREEN. Corrected to "three" would have been an accurate second copy;
the sentence now states the shape and counts nothing.

refs #881

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 04:44:29 +02:00
timothyandClaude Fable 5.1 11e4d74256 fix(881): three more claims in my own fix, and a cost band that rounded outward
Self-review over the fix commit, on the same predicate the record states.

The cost band said 13-17% over measurements spanning 12.7% to 16.6% - a band
that ROUNDS is still a band the data does not support at its lower edge. It now
states 12.7-16.6%, which is the span itself, with the per-pair figures beside it.

The quote-scope rule was stated as an OUTCOME claim ("free to be rewritten under
a green harness", "with the entry still reporting the red as verified") in the
record's `rule`, in the manifest preamble and beside the entry. That is a
mutation-outcome claim about the harness with no `CLAIMS` entry behind it -
manufactured by the sentence that introduces the rule against it. All three now
state the STRUCTURE, which is what a reader can check by looking: the assertion
and the test it names are outside the binding.

The manifest preamble said "three of the entries below assert that a mutation is
NOT noticed". `CLAIMS` holds three entries and exactly ONE is GREEN; the three
the `Claim` docstring names are CORPUS sites, not entries. Corrected to one, and
"the most common shape prose actually takes" - a frequency nothing measured -
dropped rather than quantified.

Two claims in the new record prose were themselves overstated. The 69-line green
narrowing was described as the negative direction rather than as candidates for
it: sampling the hits shows `green` in this corpus is as often a CI job's colour
as a mutation's outcome, so both figures are now labelled CANDIDATE counts. And
the "the number moves under the commit that records it" sentence now carries the
figure that shows it - the same command with `HEAD` in place of the sha prints
`107 files, 344 lines`, measured on the committed tree.

refs #881

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 04:44:29 +02:00
timothyandClaude Fable 5.1 3489254c8b fix(881): re-derive the population from a PINNED scan, and bind the claim quote to the outcome it asserts
Four review findings, every one the defect class this record is about: a prose
assertion with nothing binding it to what it asserts.

THE DERIVED POPULATION WAS NOT REPRODUCIBLE. "128 lines across 65 files" and
"47 of the 128 carry the NEGATIVE direction" cannot be reached from the predicate
the record described, while the record told the reader to re-run it there. A
review swept ~40 readings of that description at efadbec29 and none returns
either number. The scan is now pinned VERBATIM as the command that produced it,
and the figures are what that command prints at efadbec29 on 2026-09-05:

  104 files, 311 lines  - candidates
   39 files,  69 lines  - the same command with the outcome half narrowed to
                          `green`, i.e. the negative direction

Both were re-run by extracting the fenced command from the committed file and
executing it, so the text and the numbers cannot have diverged. This supersedes
the 47/128 figures quoted in 05992bec7's message.

CLAIMS[0]'s QUOTE BOUND THE WRONG HALF. It stopped at the comma after the
mutation, leaving "so `test_a_readback_whose_statuses_array_is_NULL_is_refused`
reddens" outside the binding - the words that make the sentence a claim. The
harness counts occurrences of the quote alone, so the outcome could be rewritten,
or the test renamed in the prose, with the entry still reporting the red as
verified. The quote now spans both halves, and the scope rule is stated in the
record's `rule` and in the manifest beside the entries, where the next one is
written.

"BOTH WERE CORRECTED IN THE SAME CHANGE" WAS FALSE. Only the shell comment was:
the Python test has asserted the shape diagnostic since 5d955000f (#889), and
this branch does not touch it. What this change adds beside the rewritten comment
is the binding.

THE COST BAND CONTRADICTED ITS OWN MEASUREMENTS. `mechanics` said 13-15% over
three pairs spanning 12.7%, 16.6% and 14.4%. It now states 13-17% and the
per-pair figures, since the record tells the reader to carry the percentage
forward rather than the seconds.

refs #881

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 04:44:29 +02:00
timothyandClaude Fable 5.1 1fc24e8cf6 fix(881): a mutation-outcome claim is executed wherever it is written, bound to the sentence that makes it
`testing.mutation-claims-are-executed` was the right rule scoped to its first
site: `mutation_manifest.py` declared itself "one per `MUTATION`-graded row of
`docs/guard-inventory.md`", so the same claim written in a code comment, a test
docstring or a decision record was outside it by construction. That is where all
four of ersatztv#812's consecutive review-round defects lived.

Extend the rule in place rather than adding a sibling record: a sibling would
recreate the exact shape (a rule per site class, with the next site class outside
both) that #773, #784 and #743 each are. The subject is unchanged; only the
population widens.

Mechanism: `CLAIMS` in `scripts/tests/mutation_manifest.py`, keyed on the PROSE.
Each entry carries the tracked `site` and the verbatim `quote`, checked every run,
so a reworded sentence reports as a retarget instead of drifting from the entry
that justifies it — this is proposal 2 (a quotation of another file is a claim
about that file) adopted where the referent is declared. Each entry also declares
RED or GREEN and is executed in the existing sandbox. GREEN is new: 47 of the 128
candidate lines the corpus grep returns at efadbec29 assert that a mutation is NOT
noticed, and no `MUTATION` row can express that, so the rule was unsatisfiable for
them. The green direction is read by two separate clauses (exited 0, and something
actually passed) so neither can mask the other, and each carries its own disarm
proof.

Proposal 3 (never anchor prose to a state your own commit moves) is rejected as a
DETECTOR and kept as a phrasing rule: measured 2026-09-04, the only plausible
pattern set for it matched 16 lines across the scanned corpus and every one was
legitimate rationale prose.

The seed set falsified a shipped claim on its first run: `post-review-verdict.sh`
asserted that disarming its array-TYPE read-back test left the suite green. It
does not — jq refuses to iterate a `null` `.statuses` and the script dies with the
parse message, reddening `test_a_readback_whose_statuses_array_is_NULL_is_refused`.
Comment corrected, entry graded RED.

fixes #881

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 04:44:28 +02:00
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
8fd9eae0bf fix(891): a sourced path is code, so every hook resolves it from its own tree (#903)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 12s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 30s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m12s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m3s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m10s
Every hook under `.claude/hooks/` assigned `ETV_HOOK_FIRE_LIB` from `${CLAUDE_PROJECT_DIR:-<self>}`
and then `. `-SOURCED it. Sourcing is execution, so a file of that name in an env-designated tree ran
as code inside the hook before stdin was read and before it could decide anything. Measured on the
merge gate before #858 fixed that one hook: a decoy tree's copy printed an `allow` and exited 0.

Reachable without an attacker, because husky is a different launcher: `.husky/pre-push` invokes
`./.claude/hooks/…` relative to the PUSHED tree, independent of the variable, so a push from one
worktree while the environment names another sources the other tree's code into a gate.

Sweeps the remaining twelve hooks together (population derived from `git ls-files`), reconciles the
second resolution inside `scripts/hook-fire-log.sh` itself, and requires the root to OWN the sink
(`-ef`, not `-e`). The static guard pins the preamble BYTE-FOR-BYTE — a withdrawal, after a lexical
rule was defeated by five successive shapes.

Also pins two arms of the checker that were unsubsumed AND unpinned: the begin call's presence and
its missing stdout-mode token. `…_LOSES_its_instrumentation_…` looked like their proof and was not —
it asserts only that the fault list is non-empty, and a stripped hook trips four arms, so deleting
either left the suite green.

fixes #891
refs #858, #859, #776

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYNbVwgVszv6Pum7ZuGd75
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-30 21:41:19 +00:00
timothy dd0f75f1b6 fix(855): two glob dialects cannot be canonicalised into one, so model one shape and refuse the rest (#902)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 26s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m46s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m4s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m16s
`ci-image.yml`'s `on.push.paths` decides which pushes to `main` publish a toolchain image;
`ci-image-pin`'s `git log` pathspec decides what the pin must name. #744 removed the shared
self-reference that kept them in step, leaving the agreement carried by three prose comments, and
divergence is silent and green in the dangerous direction.

The guard derives both lists from the workflow documents and compares them for set equality in both
directions. The comparison is deliberately narrow: it accepts a publish entry spelled exactly
`<dir>/**` against a pathspec entry spelled exactly `<dir>`, segments restricted to
`[A-Za-z0-9._-]`, and raises on every other spelling rather than deciding what that spelling would
have selected.

That narrowness is the substance. Measured against Gitea 1.27.1's own in-tree compiler
(`modules/actions/workflowpattern` -> `modules/glob.CompileWorkflow`) and real git: a bare
`docker/ci` in `paths:` compiles to an anchored `^docker/ci` and selects none of the directory's
contents while the git pathspec `docker/ci` selects all of them; `<file>/**` matches nothing while
the pathspec `<file>` tracks the file; a leading `/` is literal to Gitea while git refuses it
outright. A canonicaliser mapping the two dialects onto one string form was built twice and defeated
twice, each repair surfacing another spelling, so it was deleted rather than extended per
`testing.verification-code-needs-its-own-proof`.

The guard also asserts from the git index that each named path really is a directory, since
`<file>/**` and the pathspec `<file>` spell the same string; takes the pathspec from the `git log`
assignment rather than any `git log` in the job; refuses a `<<` token on a code line (a herestring
excluded) and a second bare `--`; and treats an absent and an empty `paths:` alike, because Gitea's
`Skip` returns false on an empty sequence, so `paths: []` filters nothing and every push publishes.

The docstring states the boundaries rather than implying coverage: the guard compares the pathspec
the pin job writes and does not establish that the staleness comparison consumes it, and a descendant
whose path below `<dir>` contains a newline is matched by the git pathspec but not by the publish
pattern.

Verified by nine independent cold-review rounds, none of which found a false green; the last fuzzed
27,720 publish/pathspec pairs against a port of the deployed compiler and real `git ls-files`.

fixes #855
2026-08-30 19:25:23 +00:00
timothyandClaude Opus 5 376aad6774 fix(887): withdraw the command parser — pin the command TEXT instead
Round 4, and the third cold review found the same mechanism failing again, so it is
removed rather than patched a tenth time.

WHAT KEPT BREAKING. Three versions of this guard asked "does this command RUN the suite,
and can its failure be swallowed?" of arbitrary shell text. That predicate was wrong NINE
times across three review rounds, and twice a clause added to remove a FALSE RED opened a
FALSE GREEN on the guard's headline assertion:

  * heredoc bodies were skipped as data, but BuildKit EXECUTES `RUN <<EOF` — and the
    opener regex also fired inside quotes (`echo "tags<<__EOT__"`), which blinded the
    whole-file scan over the last 303 lines of docker-build.yml. Wrong in both directions
    at once, and measurably live on this tree.
  * `shlex.shlex` does not clear `commenters` the way `shlex.split` does, so `#`
    truncated a command mid-word — including the live `${#reports[@]}` idiom — and made
    this file's own stated residual false.
  * compound punctuation (`);`) welded two commands into one segment.
  * `npm t`, `./node_modules/.bin/vitest`, `pnpm vitest`, `yarn vitest`,
    `node …/vitest.mjs`, `timeout …`, `su -c …`, `if npm test; then` — all invisible.
  * `true || npm test` counted as the gating run while never executing it.
  * `continue-on-error: ${{ … }}` passed a check written against two literals — a
    presence test that cannot see polarity, fail-OPEN in the one direction that matters.

WHAT REPLACES IT. Nothing in the file decides what a command means any more. The commands
that may run in the two risky places are PINNED as text: the `RUN` lines of every
SPA-carrying Dockerfile stage, and the gating step's `run:` body and `if:`. A suite run
re-added in ANY spelling is simply not equal to its pin — the pin does not have to
recognise a spelling in order to reject it. A pin cannot produce a false green, only a
false red, and a false red is a human reading a diff they should have read anyway.

The population/pin split is the load-bearing distinction, and it is now stated in the
inventory: a POPULATION decides what is CHECKED, so a hand-written one goes silently
short; a PIN decides what is EXPECTED, so a stale one goes loudly red. Only the second is
safe to write by hand. Populations stay derived from the git index.

Two premises that were prose are now assertions: the publish step keeps its own
`docs_only` gate (without it, a docs-only push skips the suite and publishes anyway), and
no step other than the pinned one mentions the suite — a SUBSTRING sweep, deliberately
not a predicate, whose failure mode is a false red asking someone to look.

41 mutants, 0 missed, including all nine spellings above and the three from the previous
round. Exactly ONE is declared in `mutation_manifest.py` and re-executed every suite; the
other 40 were witnessed during development and are NOT standing — stated in the row
rather than left to be assumed.

Also fixed: the truncated sentence the round-2 rewrite left in the Dockerfile comment,
and the `web/src/api/*.guard.test.ts` glob, which over-claimed — it matches three files
and only two of them need git.

Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 13:37:58 +02:00
timothyandClaude Opus 5 a4df8f7958 fix(887): the gate must be REAL, not merely present — two cold reviews' findings
Both independent reviews (Codex GPT-5.6 cross-family, and a cold Opus agent in an
isolated worktree) returned BLOCKED. Both independently confirmed the CI path itself is
sound — neither found a route that publishes an image on which the suite never ran — so
every finding is about the guard's reach, plus one factual error in the prose.

THE STRUCTURAL ONE. The guard asserted a `needs:` edge EXISTS, never that it is load
bearing. Since this change deletes the in-image run, that edge is the only remaining
layer, so `continue-on-error: true`, `if: false`, a job-level `if:`, `npm test … || true`,
a pipe into `tee`, and `set +e` each certified a publish over a red suite with every
assertion green. `test_the_gating_suite_run_is_NOT_ADVISORY` closes all six.

A filter written into `web/package.json`'s script body was invisible at the call site:
`"test": "vitest --exclude x"` with a workflow saying `npm test -- --run` is a filtered
gating run reading as clean — the removed defect, one level down. `vitest_scripts()` now
derives each script's own narrowing arguments and `suite_args` prepends them.

PARSER REACH, every case measured rather than argued. `shlex.split` yields `lint&&npm` as
one token, so unspaced `&&` and `;` re-adds were invisible; `shlex` in punctuation_chars
mode splits them. Added: `sh -c` payload expansion, `npm --prefix`/`npx -p` flag skipping,
`xargs`, heredoc bodies as DATA (a `cat > f <<'EOF' … npm test … EOF` block counted as a
real run), `ADD`/JSON-form/no-trailing-slash `COPY` in `carries_spa_source`, and
redirections no longer read as spec filters. `--root` and `--config` moved to the
narrowing set: both change which specs vitest collects.

A FACTUAL ERROR, in five places including the mutation `expect`: "the build context is
`web/` + `design-system/`, so there is no `.git`". The context is the repository root
(`context: .`) and `.dockerignore` does not exclude `.git`. The true statement is about
the STAGE, which copies only those two directories. The conclusion survives — bookworm
slim has no git binary either — but a reader who checked would have found `.git` in the
context and concluded the note was stale.

ONE FINDING WAS MINE, from the mutant battery rather than from either review, and it is
the reason the battery exists: `failure_suppressions` tokenised the whole multi-line
`run:` body at once. A newline is not a shell separator, so a realistic two-line step —
the `ci-step-ran.sh` marker line, then the suite — merged into ONE segment whose head was
the marker script, and three suppression mutants passed while my single-line unit test
was green. It now works per logical line, and the regression test uses the two-line shape.

17 mutants, 0 missed, each caught by the intended assertion; baseline green. The
`docs/guard-inventory.md` residual list is rewritten as MEASURED reach — the previous one
was wrong rather than merely short, which cold review rightly called worse than silence.

Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 13:37:58 +02:00
timothyandClaude Opus 5 febaad77d7 fix(887): the image build builds the SPA and does not test it
`docker/Dockerfile`'s web-build stage is gitless twice over — the build context is
`web/` + `design-system/` so there is no `.git`, and `node:22-bookworm-slim` ships no
git binary. Members of the SPA suite need one or the other, so running the suite there
required naming the ones that cannot run. That list was a population nothing derived:
#883 added a third member without updating the hand-written pair of `--exclude`s, and
because `Build & push image (amd64)` is `if: github.event_name != 'pull_request'` the
resulting red was unreachable on a PR. It landed on `main` and on the `v*` tag path
instead — every image build failed, `:latest` stopped being republished, and a release
cut would have failed at the image build.

Adding a third `--exclude` re-arms the trap, so the list is removed rather than
extended: the stage now lints, typechecks and BUILDS the SPA, and the suite runs once,
unfiltered, in `docker-build.yml`'s `test` job on a real checkout. `build` carries
`needs: [test, migrations, scan]`, so no image is published past a red suite.

`scripts/tests/test_image_build_delegates_the_spa_suite.py` holds both halves — the
negative one alone would be satisfied by deleting the `needs:` edge. Three populations,
all derived: tracked Dockerfiles and workflows from the git index, and which npm scripts
ARE the suite from `web/package.json` (so `test` is in and the Playwright `test:ui-e2e`
is out, with no exemption list). Publishing jobs come from the `docker/build-push-action`
step and the Dockerfile each builds from that step's own `file:` input, which is why
`ci-image.yml` is out of scope by derivation rather than by an entry that would outlive
its reason.

Four mutants witnessed red, each by the intended test: a filtered suite run put back
into the Dockerfile, the `needs:` edge deleted, and the gating run narrowed in both the
block and the single-line `run:` step forms.

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

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

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

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

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

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

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

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

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

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

fixes #845

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

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

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

fixes #836

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

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

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

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

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

fixes #820

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-29 22:06:38 +00:00
timothyandtimothy 4b3f6f6c90 fix(786,789): workflows declare their own per-job metadata; guard populations derive from it (#872)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m21s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m58s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m8s
Closes #786 and #789, bundled because working either alone would build the artifact the other removes.

Every job in all six tracked workflows declares `env.CI_JOB_ROLE` (guard/report-only/none); the
`docker-build.yml` jobs also declare `env.CI_EXECUTION_CLASS` (toolchain/bare-runner). Both guard
populations derive from those markers; the `TOOLCHAIN_JOBS`/`BARE_RUNNER_JOBS` literals are deleted.
A missing or unrecognised marker is a hard failure in both checkers.

#789's literal had a real justification — set equality between two DERIVED sets is blind to a member
leaving both at once — so the marker is the anchor that replaces it, and the cost (proximity to the
`container:` block) is paid by a THIRD derivation from each job's own steps, which is also the only
check that sees the failure #789 filed: a .NET step moved into a bare-runner job, where no set
changes. The residual is disclosed: drop the block, flip the marker AND hide the tool behind a
script and all three go blind, bounded by the failure mode being a loud missing-binary crash.

#786's guard jobs join a machine-checked population: a new `test_workflow_job_guards.py` asserts set
equality both ways against a new "Workflow-job guards" table, and the four jobs with no dropped-step
guard each carry a recorded decision.

Two issue claims were refuted by measurement: #789's "editing docker-build.yml re-points the pin"
(the pathspec is `docker/ci` only) and #786's job count (17, not 15).

Four cold adversarial review rounds across two model families; rounds 1-3 BLOCKED, all findings
fixed and each fix demonstrated by reproducing the reviewer's own test. The recurring defect class
was prose drifting from code, including a mechanism claim in the decision record that execution
refuted. All five mutation proofs redden when their shipped detector is disarmed.

New decision record: `testing.workflow-declares-its-own-job-metadata`.

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-28 20:54:53 +00:00
timothyandtimothy e11d577193 docs(796): verification code is code under test, and the proof it was claiming an exemption from (#871)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 8s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m38s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m9s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m18s
Records `testing.verification-code-needs-its-own-proof`: the proof obligation follows the
VERDICT rather than the file, so it binds harnesses, wrappers, timeouts and checkers — not
only the files the guard population derives.

The issue asked for a stated position on whether non-guard checker scripts get mutation
proofs. The position as first written claimed `scripts/mcp_smoke.py` "cannot participate"
because driving it needs the gitignored `.mcp.json` and a cold-built language server. Cold
review refuted that by execution: it takes its config path and server name as positional
arguments. The record had failed its own headline rule on the one claim its decision rested
on, so this ships the proof instead of the exemption.

- `scripts/tests/test_mcp_smoke.py` — a hermetic stub JSON-RPC responder and six cases
  pinning the defects the checker has already had, with the positive control as a fixture
  the refusal tests depend on, so a node-id or `-k` selection cannot skip it.
- A declared clause in `mutation_manifest.py` targeting the unguessable request id, using
  the `guard=test / target=script` shape that already exists for `mutation_harness_lib.py`.
  Witnessed red: `id_init = 1` makes the pre-answer accepted at `initialize` (rc 9 -> 10),
  and only that test moves.

`mcp_smoke.py` still gets no inventory row — one is rejected as a phantom (measured). The
row goes to the test file, which joins the derived population automatically.

Five cold-review rounds, four BLOCKED. Round 2 caught a `ruff format` red that would have
failed `script-tests`. Rounds 3-5 found only hand-maintained counts and uniqueness claims in
prose, three of them created by the previous round's fix; that class was deleted rather than
corrected again, per this record's own stop-and-subtract rule.

Docs updated in the same PR: `docs/README.md` task-signal map and `docs/guard-inventory.md`
(row, summary counts, scope-limit item 6).

fixes #796
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-28 20:06:45 +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 45b17e58e1 fix(744,835): ci-image.yml publishes from main only; guard persist-credentials with no exemption (#857)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 6s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m23s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m27s
Closes the push route into ci-image.yml (#744) and ships the persist-credentials guard that was waiting on it (#835).

ci-image.yml's push trigger had no branches: filter and was path-scoped to docker/ci/** AND to the workflow file itself. Gitea resolves a push workflow's definition from the pushed ref, so any branch push touching those paths ran that branch's own YAML on a docker-capable runner holding the credential that writes ersatztv:prod and the ersatztv-ci:<sha> five container: jobs execute.

Be precise about what the filter buys: it is loaded from the pushed ref like the rest of the file, so a branch that deletes it re-enables the route. This closes the DRIVE-BY case - publication as a side effect of an ordinary push - and is not a boundary against a writer who intends to run their own YAML. The wider class is #853.

The self-reference left both paths: and ci-image-pin's expected in the same change - a decided tradeoff with both prices stated, not a necessity. Branch publishing moves to workflow_dispatch, probed live: run 2340 on this branch published ersatztv-ci:43b1e45 and left :latest unchanged.

With both mechanical blockers gone, ci-image.yml's checkout takes persist-credentials: false (16 of 16) and scripts/tests/test_workflow_persist_credentials.py holds the convention with NO exemption list - git-index population, declared clause mutation re-run every suite, guard-inventory rows.

fixes #744
fixes #835

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-27 20:44:44 +00:00
timothyandtimothy 469d19852c fix(788): one declarative H10 verdict vocabulary, derived by both sides (#846)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 6s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m17s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m22s
The verdict words lived in two hand-written shell copies — the `case` arms of
post-review-verdict.sh (write) and the POS_RE/NEG_RE regexes of
check-review-verdict.sh (read) — held together by nothing but a comment that had
already gone stale. scripts/lib/review-verdict-vocabulary.sh now declares them
once and both sides derive; neither script enumerates a verdict word any more.

Only the WORD SET moved. The grammar stays in check-review-verdict.sh, where
every #629 false-open actually lived.

No parity test: #774 shipped one and withdrew it after six rounds, because a
regex over shell source is not a shell parser. The proof is behavioural and
graded MUTATION — the harness restores the pre-#788 hardcoded POS_RE each run and
requires it to redden.

Enforcement is a DATA dependency, not a control-flow gate. Review round 1 found a
real fail-open in the first commit: `${#arr[@]}` is nounset-safe only for a
declared-empty array, and under `set -u` that error inside a function called as
`if ! validate` skips BOTH branches — so on the reader (deliberately no `set -e`)
an explicit BLOCKED @ head classified `positive`, exit 0. Validation now sets a
sentinel on its last line and the derived views refuse without it.

Six cold review rounds; rounds 2-6 found no fail-open across differential fuzzing
(4788 / 2612 / 7560 payloads, zero divergences from origin/main's grammar),
sentinel forgery, environment poisoning, declare -p evasion on bash 5.3 and 3.2,
path/symlink resolution and probe TOCTOU. Every malformation fails closed: reader
exit 2, writer exit 1 with nothing posted.

Also corrected: CLAUDE.md and release.review-verdict-gate both enumerated the
vocabulary without LGTM, a word the code has accepted since #629.

fixes #788

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 20:58:09 +00:00
timothyandtimothy 3e2c07b42f fix(807): SPA full-replace bodies are built as Complete&lt;T&gt;, with a derived guard over droppable request members (#827)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 14s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 30s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m9s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m49s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 15s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-23 00:58:19 +00:00
timothy 0cf355e494 fix(772,792): address both cold reviews — an unverified pin is no longer a green job
Two independent reviewers (one cross-family) converged on the same defect, and it was the
important one: the preflight WARNED and exited 0 on every answer that was not 200 or 404,
so a missing `curl`, a moved registry or a DNS change would have left it green forever —
"the check could not run" presenting as "the pin is fine", in a script whose own header
disclaimed exactly that. Unknown answers are now retried (3x, 5s) and then FAIL, with
wording kept distinct from the deleted case because the two send an operator to different
places.

Also from the reviews:

  * An absent secret does not arrive as an unset variable. `${{ secrets.X }}:${{ secrets.Y }}`
    interpolates to ":", a perfectly non-empty and perfectly useless credential, and the
    tests covered only the unset shape. Both halves are now required, and the parametrised
    test drives the production shape.
  * HTTP 200 is not a manifest. A proxy or a login page answers 200 too, so the body is
    fetched and matched for `schemaVersion` (a shell `case`, so no jq dependency and no
    pipeline that can inject).
  * The curl stub ignored `-u` and answered 200 regardless, so deleting the real `-u` would
    have left the suite green while the live registry rejected every request. It now 401s an
    unauthenticated read, as the registry does.
  * The mutation's declared diagnostic changed with the script: now that unknown fails too,
    the exit code no longer separates "deleted" from "could not check", so the proof turns on
    the message and `expect` says so.
  * docs/ci-cd.md: `scan` is no longer the only `docker-build.yml` job on the small lane, so
    the tag-push exclusivity claim and the lane membership were both false. Fixed.
  * "Immutable" was overstated: `ci-image.yml` tags `rev-parse --short HEAD`, so a dispatch or
    a weekly no-cache run at the same HEAD republishes that tag from a rebuilt image. Stated,
    along with what the rebuild recovery does NOT restore (mutable bases and apt, so equivalent
    rather than bit-identical).
  * The recovery recipe left you in a worktree checked out at the pin commit — where the
    verify script does not exist, and where the workflow carries the pre-bump pin. It now
    keeps `$repo`, returns, and removes the worktree. It also needed BuildKit's `http = true`
    caveat: the container driver does not inherit the daemon's insecure-registries.
  * The root cause carries its evidentiary limit and its reproduction commands, and says what
    to conclude if a pin vanishes after server-management#842 lands (refuted, not re-applied).
  * The `ci.required-job-step-execution-markers` carve-out named one container-free job; there
    are two now, and the membership is what rots.
  * The decision record's `''` YAML escapes leaked into rendered prose; "status, no comment ->
    ask" is qualified (a prior positive verdict for the SAME head still satisfies condition
    (c)); "exits 1" is "exits non-zero" (usage exits 2, jq its own status, signals 128+n).

refs #772
refs #792
Decisions-Edit: yes
2026-08-22 23:25:26 +02:00
timothy 5ba442c11c fix(772,792): name the missing toolchain image, and stop a refusal leaving a verdict comment
#772 — the pinned CI toolchain image can be deleted out from under us, and when it was
(2026-08-11..13) all five `container:` jobs died at image pull, both required contexts
included, with the cause buried in each job's log. Root cause is registry-side and is now
established rather than guessed: an owner-level Gitea package cleanup rule (keep_count 15,
remove_days 1, remove_pattern `.*`, keep_pattern no 7-hex sha can match) deletes a sha tag
once 15 newer versions exist, and `ExecuteCleanupRules` ran nightly through the window. The
`ersatztv` package carries the same rule's fingerprint exactly — every sha tag older than
the 15-slot window is gone, every keep_pattern tag back to 26.3.1 survives. Version deletes
leave no audit row, so the specific run cannot be replayed; that limit is stated where the
claim is made. The durable fix belongs to the registry's repo: server-management#842.

What lands here is what a consumer of someone else's registry can do:

  * `toolchain-preflight`, a container-free job (a job consuming the image could not run to
    report it missing) resolving every pin against the registry and failing with a message
    that names the tag and the recovery. Not a `needs:` of the jobs it diagnoses — gating
    five jobs behind a checkout and one curl taxes every green run to speed up a rare red
    one, and they already fail fast.
  * Only HTTP 404 means gone. Everything else is could-not-tell, and rejected credentials
    fail rather than pass as unknown — "the check could not run" must never present as
    "the pin is fine".
  * A recovery path that does not need CI: rebuild the SAME tag from the commit it names
    and push it. The push half was verified against this registry on 2026-08-22 with a
    throwaway package (created, resolved 200, deleted).

#792 — the reported defect was the exit code, and re-measuring says that premise is false:
every no-status path already exits 1, and eight refusal modes now assert it against the real
predecessor, where they pass. The observed 0 came from the invocation, not the script. What
WAS broken is the half-state the issue describes second: the comment was written before the
status, so every refusal left `Review-verdict: MERGEABLE @ <head>` on a PR with no gating
status behind it. The two writes are now ordered status-then-comment, which makes the only
reachable half-state the safe one — a status with no comment leaves the merge hook's
condition (c) with nothing to classify, which is an `ask`. The refusals themselves are
untouched. Ordering rather than compensating deletion: an orphaned-comment cleanup needs a
Gitea call, and these refusals are usually caused by Gitea being unreachable.

Proof for the ordering is the split against origin/main's script: the 8 orphan/ordering
tests go red there, the 8 exit-code tests stay green.

fixes #772
fixes #792
Refs: server-management#842
Decisions-Edit: yes
2026-08-22 22:52:56 +02:00
timothyandtimothy 29d7a06e65 fix(806): guard populations over FILES derive from the git index, not a filesystem walk (#818)
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m57s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m56s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m20s
`testing.guard-derives-population-from-source` (#774) was silent on the commonest
population in our own guards — files in a directory — and every one answered with a
filesystem walk. A walk is not authoritative: it reports build output, generated
shims and editor droppings, and differs per machine. #778 measured the cost by
getting the same population wrong three times in one PR.

CONVERTED (a completeness claim over tracked files): `test_guard_inventory.py`,
`test_hook_fire_log.py`, `test_ci_image_pin_population.py` (which also gained
`*.yaml`), `test_remote_state_inventory.py` (folded onto the shared derivation), and
`test_pr_changed_files.py` (not on the issue's list — found by sweeping the whole
repo).

ASSESSED AND RECORDED, not silently skipped: `_repo_copy` takes its file list from
the index for hermeticity though it makes no completeness claim;
`test_ci_dropped_step_guard.py` has no filesystem population at all; the decisions
corpus is recorded as unexamined rather than cleared; and the SPA page-size guard is
deferred to #819 with its obstacle documented. This is not "replace every glob".

`scripts/tests/tracked_files.py` is the single derivation.
`test_guard_populations_derive_from_git.py` proves it in two measured complements:
exhaustive removal catches a hardcoded `.exists()` admit and memoisation; the call
log catches an append-only source that yields nothing on this machine — #778's
shape — which removal cannot see because it has nothing to remove.

Twelve rounds of independent cold review, alternating model families in isolated
worktrees. The production derivations were confirmed sound every round; every
blocking finding after the first was in the proofs or in prose claims about them.
Counts over growing populations were removed rather than corrected, after three
drifted.

fixes #806

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-22 16:07:58 +00:00
3b386649bf feat(790): execute every MUTATION grade instead of asserting it (#815)
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 18s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m2s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m30s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m10s
Every `MUTATION` row of `docs/guard-inventory.md` now carries a DECLARED clause mutation that is applied to an isolated copy of the repository on every suite run, with the row's own named test required to go red carrying a declared diagnostic. Manifest and MUTATION rows are compared for set equality both directions; the other 22 guards each carry a stated reason, compared the same way.

Measured rather than assumed: 12 of 13 guards admit a single-clause mutation; `instrumentation_faults` does not, and that entry carries the surviving finer mutation, re-run every suite.

Nine cold cross-family review rounds. Rounds 1, 2, 7 and 8 each found real mechanism defects — two mutations that measured nothing, an incomplete git-environment sanitisation, a reset that restored its own mutant, and a proof of that fix which was not itself isolated. All fixed and witnessed red.

fixes #790

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-22 13:12:15 +00:00