80cd0a9ee9f582eec8dcc1fd2f7faa73e0707dca
291
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
80cd0a9ee9 |
docs(881): a quotation that resolves nowhere, and a paragraph contradicting the one above it
Two more in the same class, both in prose this branch wrote. The calibration paragraph quoted `post-review-verdict.sh` as asserting "the absent-entry check catches every case on its own". That string occurs in no file: the comment wraps it across a line break at `catches`/`every`, so `git grep` for it finds exactly one hit - the record asserting it. That is ersatztv#812's second defect reproduced inside the record written to end it. Paraphrased without quote marks and pinned to lines 316-317 at `efadbec29`, which is what this record's own proposal-2 clause prescribes for a quotation that cannot be checked. The closing paragraph called the scan's hits "the remaining population" and "a backlog", three paragraphs after establishing that both figures are CANDIDATE counts and that reading them as a backlog of real claims overstates them. The closing text now says what is actually known: a place to look, with nobody having established how many are claims. refs #881 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
443df1391b |
docs(881): drop a figure anchored to the branch HEAD - the anchor moves
The sentence explaining why the scan pins a sha quoted a second figure for `HEAD`, which is exactly the anchor-to-a-moving-state shape this record settles: correct on this branch, wrong the moment anything else lands. The reason it was supporting is checkable without a number - the paragraph's own prose, the pinned command included, matches the pattern. The first draft of that replacement said "twice over". Three lines of the paragraph match, so the count is dropped rather than corrected; a count of matching lines in a paragraph nobody will re-measure is the same defect one size smaller. refs #881 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
1b5dd80681 |
fix(881): regenerate the catalog the previous commit's rule edit staled
The generated catalog embeds each record's `rule`, so rewording the quote-scope clause in 38bdf7bd0 left `docs/decisions/README.md` behind the record. Nine tests red on it - the four `build_catalog_check_path` reformat cases, its stale-catalog CLI proof, two `decisions_validate` main() cases, and the two mutation-harness entries whose positive control runs that validator. refs #881 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
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
|
||
|
|
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 |
||
|
|
4008f4b574 |
fix(881): exempt the claim binding from the index-population registry, and fix three prose claims in the change itself
`test_mutation_harness.py` now imports the shared index derivation, which `test_every_index_derived_module_is_registered` requires be registered or exempt. It is exempt: its population is `CLAIMS`, and it consults the index only per member, to answer whether a declared `site` is a path git tracks. The exemption list's own docstring counted its entries, so that count and its review date move with it. Three claims written by this change were falsified by this change, which is the shape it exists to catch: - the binding test's docstring said membership comes from the index "not from `Path.is_file`", while the same test now asserts existence with `is_file`; - the record quoted the manifest docstring this change rewrites — an anchor to a state the commit moves, which the record itself rejects. It now anchors to `efadbec29`; - the `Claim` docstring quoted three files without naming them. They are named. refs #881 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
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
|
||
|
|
692affca97 |
fix(911): a round with no reviews is an error, the history binds each fix to the round it answered, and a harness executes the loop
A round in which every lens returned null read as a clean pass and would now have been quoted verbatim into the PR body; it is an error before the push, in the review loop and in the post-rebase round, which also gains the same blocking-or-should-fix filter; a fixer that dies or stops (no done) is an error too, the same test the implementer already gets. The history entry now carries the fix that answered that round and only the commits that fix added (a line-set difference against the previous branch log — a fixer that reformats or rebases mid-loop defeats it, which is why the finisher is told to read git show, not the list). An empty fix-commit set is described as "answered without a new commit" when a fix round ran, and as "round one was clean" only when none did. web/scripts/orchestration-workflow-loop.test.mjs compiles the committed script bodies with stubbed agent/parallel and pins eleven paths per script (22 tests). Measured: reverting the loop condition to blocking-only reddens six cases per script (every case that needs a should-fix round to reach the fixer); deleting any of the three zero-lens guards, the fixer guard or its done half, or the empty-fix sentence branch reddens its own case, in both scripts. web/vite.config.ts is untouched: it is pinned whole by test_image_build_delegates_the_spa_suite.py, comments included. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
e1d211cd1b |
test(830): pin which ARM the call site reaches, not only that the hook has one
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
PR Gates / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 10s
Review verdict / Set review-verdict status (pull_request_target) Successful in 22s
review-verdict/h10 Review-verdict: MERGEABLE @ e1d211c (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m34s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m5s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Review measured M6: swapping `reportFailure(...)` for `onAddFailed(...)` in `AddItemsDialog`'s catch left all 490 tests across the 42 `src/screens` files green. Under that mutant a failure that lands while the dialog is STILL OPEN renders into `ManualItemsView`'s screen banner, which sits behind the dialog's `createPortal` panel with `aria-modal="true"` — covered for sighted users, hidden from AT, and the surface the user is actually looking at stays blank. That is the exact shape the decision record calls "its own defect", and the whole gap was the call-site wiring: the hook's inline branch is pinned at unit level in `hooks.test.tsx`, but a unit test of the hook cannot see which arm a consumer reaches. Adds the integration assertion: fail the POST with the dialog still up, assert the message is inside `[role="dialog"]` and appears exactly once in the tree. Re-executed the mutation with it in place — 1 failed / 490 passed, and the red is this test alone. Records the new pin as mechanics (5) on `spa.dismissible-write-failure-reporting` and the general form in `spa-conventions.md` §3c, so the next site wired to the hook pins both arms at its call site rather than inheriting the hook's unit coverage. Refs #830 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
e4c346e649 |
test(830): pin the ORDINARY close too — the negative test did not cover it
Round 6 verdict was MERGEABLE with two follow-ups; both are one-liners on lines this branch just touched, so they are in rather than deferred. L11. I believed the suite covered "a normal add still closes the dialog". Review MEASURED that it did not: deleting the `onClose()` call entirely -- so a successful add leaves the picker open forever -- kept the whole suite green, 1277/1277. The new #830 test only pins the NEGATIVE direction (do not close when unmounted), so a future edit dropping the call, believing the guard had made it dead, would have shipped silently. The Song add test now asserts the dialog closes; with that line, the same deletion reddens. Both directions of the report/dismiss split are pinned. Worth naming the shape: I asserted coverage from plausibility rather than from a mutation, in the same PR whose whole subject is claims that were written down before they were measured. N12. The guards test's "exactly ONE post-await write to state THIS component owns" is still true, but it now reads as a census of `mountedRef` reads, and `submit` has two -- the success path's guarded `onClose()` is the other, which that failure-path test never reaches. Added the clause so nobody derives the guard population from that number. 1277 tests green, tsc/eslint/build clean, pytest 1228 passed, validator OK, catalog no drift. The two red CI contexts on the previous head are runner flakes, not this branch: both failed inside `Post Checkout` with `Cannot find module '/var/run/act/actions/<hash>/dist/index.js'`, their logs are timestamped 19:18 (before that head existed), this branch touches no CI or docker/ci file, and both contexts were green on its earlier heads. refs #830, #877 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY |
||
|
|
86e9ad41a6 |
fix(830): gate onClose at the exemplar — the convention contradicted its own reference
Round 5 found that §3c and the `rule:` field now instruct readers to gate the DISMISS request, while `AddItemsDialog` -- the one site this record names as "the shape fixed here" -- called `onClose()` unguarded, with a comment arguing that was correct. So a reader following the convention wrote the gate and a reader copying the reference implementation did not. The defect is pre-existing; the CONTRADICTION is mine, introduced when round 3 withdrew H1's code but kept the convention it produced. I checked the docs against the withdrawn addTo code and did not re-check them against the exemplar that stayed. Measured at this site: submit, Escape mid-request, reopen the picker to retry, first POST returns 204 -> the stale instance's `onClose()` (`() => setPickerOpen(false)`) closes the dialog the user just reopened, discarding the selection they rebuilt. Identical mechanism to the addTo clobber. Unlike the addTo layer, the one-line gate IS sufficient here, and that difference is the point: `AddItemsDialog`'s parent has no competing closer (`onAdded` is `load`, which never touches `pickerOpen`), whereas `AddToMenu.handleAdded` closes its dialog itself. That is now stated in the record as the concrete reason one half shipped and the other went to #877. - `onAdded()` stays unguarded -- it REPORTS, and the parent's list reload must survive dismissal - `onClose()` is guarded -- it REQUESTS A DISMISSAL, and after dismissal it aims at whatever the user opened next - comment rewritten to say which is which and why, instead of defending both as "belong to the still-mounted PARENT" Pinned, and nothing pinned it before: "a late SUCCESS does not close the dialog the user reopened after dismissing (#830)". It carries an anti-vacuity check that the late response was actually processed -- `onAdded` is `load`, so a second GET of the items endpoint must have happened -- because otherwise "the dialog is still open" holds trivially. Executed: deleting the `if (mountedRef.current)` around `onClose()` reddens it alone. Also rewrapped five record body lines left ragged by earlier splices. 1277 tests green, tsc/eslint/build clean, pytest 1228 passed, validator OK, catalog no drift. refs #830, #877 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY |
||
|
|
4b12125f4d |
docs(830): drop a coverage claim this record's own mechanics field contradicts
Round 4 found one clause left, and it is a good example of the thing this record is about. The one-consumer paragraph said `hooks.test.tsx` "is the ONLY thing pinning the diverted branch". Measured at the previous head, disarming `reportRef.current(message)` reddens THREE tests -- both hooks.test.tsx divert tests AND the CollectionsScreen integration test -- which is exactly what the `mechanics:` field of the same record says 58 lines earlier. So the record asserted a coverage fact and then contradicted itself. The concrete harm is not the inconsistency: a future session pruning tests reads "hooks.test.tsx is the only pin", concludes the CollectionsScreen #830 test is redundant, and deletes the only end-to-end pin of the whole path -- the one that actually drives Escape-dismissal through the real dialog. Clause dropped; the argument the paragraph needed (the hook's shape earns its own unit tests) survives without it. The clause originated in the reviewer's round-3 wording and I transcribed it without checking it against a field I had written myself two rounds earlier. Worth recording: a review finding is not exempt from verification just because it came from the reviewer. Also: - the `AddToMenu` clobber sentence now splits what was MEASURED (a late success closes a reopened dialog) from what was READ (both parents call `clearSelection()` unconditionally, so the wipe follows). On a record whose subject is over-attributing measurements, that distinction has to hold in its own prose. - §3c now carries the same "nothing diverts to those screens today" disclaimer the record's limit (2) has, so the two artifacts say the same thing - rewrapped one 141-char comment line left ragged by the previous round's splice Docs only, plus one comment rewrap. 1276 tests green, tsc/eslint/build/validator/catalog clean. refs #830, #877 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY |
||
|
|
626cbfbf9d |
docs(830): three sentences survived the revert of the code they described
Round 3 verified the withdrawal itself is clean -- the eight reverted files are byte-identical to origin/main, no orphans, and the mutation gives the stated three reds -- but found docs still asserting the withdrawn change shipped. That is the stale-comment failure in its usual form: after a retraction, the retracted WORDING has to be swept, not just the code. - `hooks.ts` said "#830 removed that gate", flatly false at this head, in the hook's own doc comment right above the export -- the first thing a maintainer reads. It also carried round 2's framing ("both halves of the outcome") as the hook's purpose, when what ships carries only the failure half. Rewritten to the present tense of the shipped tree. - The `rule:` field still said the surviving surface "differs per screen", naming MediaBrowseScreen and SearchScreen as wired. They wire nothing. This one matters beyond an ordinary sentence: `rule:` is the canonical summary, it is what the catalog row shows, and it is what gets mirrored per-key into MemPalace -- so it is the version a future session retrieves WITHOUT opening the file. Now: exactly one wired screen, the Toast pair named as a CANDIDATE. - The "two limits" bullet described a failure being diverted to those same screens and announced politely. Nothing can divert there -- they receive no reporting callback. Restated as the limit the second surface will have when it is wired. - `onFailed` in a hooks.ts comment was a dangling identifier; the real prop is `onAddFailed`. Also added the caveat the reviewer asked for rather than leaving it to be discovered: this is a shared hook with exactly ONE consumer. It earns that shape (directly unit-tested, and those tests are the only thing pinning the diverted branch; prescribed by §3c; #877 queued as a second consumer) -- but #877 may land a shared reporting SURFACE instead of a per-site prop, in which case the second consumer never arrives. Accepted risk, now written down. Docs only. No code change, 1276 tests still green, tsc/eslint/validator/catalog clean. refs #830, #877 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY |
||
|
|
de9432ff49 |
fix(830): withdraw the media/addTo extension — two rounds, two defects, one coupled gate
Round 2 of adversarial review found that my round-1 fix introduced an adjacent defect, and it is the same mechanism both times: `onAdded?.()` and `onClose()` sat behind ONE unmount gate in the four `media/addTo/` dialogs, and those two callbacks do not mean the same thing. - Gate both (origin/main): a write that SUCCEEDS after dismissal reports nothing. Measured on `AddToCollectionDialog` -- `onAdded` called 0 times after dismissal. That was round 1's finding. - Un-gate both (my round-1 fix): a late success closes a dialog the user REOPENED to retry, and on SearchScreen/MediaBrowseScreen `clearSelection()` wipes a multi-select they rebuilt. Measured against the real `AddToMenu`. That was round 2's finding, and I introduced it. - Gate only `onClose`: still wrong on its own, because `AddToMenu.handleAdded` nulls the dialog itself. Needs three coupled edits across five files -- plus a genuine product question nobody has answered: should `clearSelection()` fire for a write the user walked away from? That is a design change, not a bug fix, and #830 never asked for it -- the issue is about `AddItemsDialog`. Two defects from one mechanism in two rounds is the signal to stop widening, so the `media/addTo/` extension is REVERTED here and moves to #877 with every measurement attached (#877 comment). What ships is the thing the issue asked for, proved: - `useDismissSafeError` + `AddItemsDialog` + `CollectionsScreen` wiring - the witnessed red is unchanged: disarming `reportRef.current(message)` reddens the integration test on `Unable to find an element with the text: Request failed with status 500` Docs now describe what is actually true rather than what I hoped: - the record says ONE A1 site is fixed and explains why the other four were withdrawn, keeping the wrong first claim visible because "one site read, four assumed" is the lesson - §3c splits the rule the round-2 defect came from: report the OUTCOME unguarded, gate the DISMISS request separately -- the earlier text lumped `onClose` in with `onAdded` and would have propagated the clobber to the next screen that adopted it - §5c no longer tells authors to wire an `onFailed` that the addTo layer does not have; it says the layer has no failure channel at all and points at #877 - the reporting prop is REQUIRED where the host has a surface (`AddItemsDialog.onAddFailed`), which is what the docs now say instead of calling it optional - `mechanics:` no longer implies the shared clause reddens one test; it reddens three, so re-running the mutation should expect three refs #830, #877 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY |
||
|
|
f61764d051 |
fix(830): report SUCCESS past dismissal too — the review measured my claim false
Adversarial review (cold, worktree-isolated) blocked the first commit on its central prose claim,
correctly. I wrote "at every one of these sites SUCCESS already outlives dismissal" into three
durable artifacts -- the decision record, spa-conventions §3c and the hooks.ts header -- after
reading ONE site. `AddItemsDialog` does report success past dismissal and says so in a comment; I
generalised from it. The review probed the other four instead and MEASURED `onAdded` called 0 times
after dismissal: all four `media/addTo/` dialogs gated `onAdded?.()`/`onSaved?.()` behind their own
`activeRef`, exactly like the failure path.
So after the first commit those four were still asymmetric, just inverted: dismiss-then-fail loud,
dismiss-then-succeed silent -- and additionally leaving the caller's selection state stale, because
SearchScreen's `onAddedToSelectionTarget` never ran to clear it. The record's own advice ("add the
failure counterpart") followed literally would have reproduced it.
Fixes, each proved by execution:
- the `activeRef` gate above `onAdded?.()`/`onSaved?.()` is removed in all four dialogs; those two
statements belong to the still-mounted PARENT, which is the reasoning AddItemsDialog already had
- `AddToCollectionDialog.test.tsx` covers the media/addTo half in BOTH directions. It had NO
coverage before: reverting `reportFailure` to `setInlineError` in all four left the whole suite
green. Restoring the success gate reddens the SUCCESS test alone; disarming
`reportRef.current(message)` reddens the FAILURE test alone
- the three prose sites now say what was measured, and the record keeps the wrong first version
visible, because "one site read, four assumed, written down before measuring" is the finding
Also from the review:
- hooks.ts said "React 18"; package.json pins 19.2.7. Now "React 18+"
- the "nothing better to do" comment overclaimed: diversion reaches ONE level, so Back out of a
collection mid-add still drops the message. Stated, with where it would be fixed
- recorded two limits rather than leaving them to be rediscovered: useIsMountedRef clears in a
PASSIVE effect cleanup, leaving a narrow window where the message renders inline into a detached
tree (useLayoutEffect would close it, but that hook is shared by every async caller -- #877, not a
bug fix); and Toast is role="status" with one last-writer-wins slot, so it is not equivalent to
CollectionsScreen's role="alert"
- §5c now cross-links §3c, since that is the section a screen author reads before wiring AddToMenu
- the sweep count is 67 caller-owned + ConfirmDialog's own internal <Dialog>
Three other media/addTo dialogs remain unpinned; they are identical in shape to the covered one,
which is a reason to expect the same behaviour, not evidence of it. Said so in the record.
refs #830, #877
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY
|
||
|
|
9ba397e1dd |
fix(830): a write failure reports to a surface that outlives the dialog that started it
`AddItemsDialog.submit` POSTed to `/api/v1/collections/{id}/items` and reported failure into a
banner rendered from its OWN state. The dialog is dismissible mid-request through three paths that
never consult `adding` -- Escape and a backdrop click (both `useOverlayBehavior`) and the header
close button -- and the caller remounts it on `key={`add-${pickerOpen}`}`, so dismissal genuinely
unmounts it. Select 12 items, Add, press Escape, the request fails: nothing surfaces, the list
reloads unchanged, and the user believes 12 items were added.
drop deliberate rather than accidental. A deliberate drop is still a user who is told nothing.
The asymmetry is the finding: SUCCESS already outlived dismissal everywhere here, because it is
reported through a parent callback (`onAdded`/`onDone`, which the screens turn into a `Toast`).
Only failure died with the surface. So this is not a new notification system -- it routes failure
through the channel success already uses. `AddToMenu` had `onDone` and no counterpart at all.
`useDismissSafeError` (`web/src/hooks.ts`) renders the message INLINE while the surface is mounted
-- the better surface, since it keeps the user's selections and context -- and diverts to a
caller-supplied `onFailed` once it is gone. The surviving surface belongs to the parent and differs
per screen (a `role="alert"` banner on CollectionsScreen, `notice`+`Toast` on MediaBrowse/Search),
so it is a prop contract rather than a rendering decision. Gating dismissal on the busy flag was
considered and rejected: it traps the user behind an in-flight request with no cancel path, and
would not cancel the write anyway.
Applied to the A1 shape -- where the surface owns the error state and is really unmounted:
AddItemsDialog plus the four `web/src/media/addTo/` dialogs, whose failures previously could not
reach the screen Toast that already showed their successes.
Proofs, executed rather than described:
- deleting `reportRef.current(message)` alone reddens the new CollectionsScreen test on
`Unable to find an element with the text: Request failed with status 500`
- `hooks.test.tsx` pins both branches directly, plus that the report goes through the LATEST
callback rather than the one captured on first render
- `CollectionsScreen.guards.test.tsx`'s is-mounted read count moves 2 -> 1 because the catch's
guard migrated into the hook (its `...actual` module mock cannot see the hook's internal
`useIsMountedRef()`); removing the surviving `finally` guard takes it to 0 and reddens, so the
anti-masking property that count was added for is intact
Scope is stated rather than implied. A sweep of all 68 Dialog/ConfirmDialog/SlideOver call sites
found three shapes; only A1 is fixed here. A2 -- error state that survives but whose render site is
gated by the same condition dismissal clears, mostly delete-confirm flows -- is left open in #877
because its right answer is probably a shared surface, not twenty prop threads.
fixes #830
refs #877, #740, #685
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY
|
||
|
|
a0203f2863 |
fix(907): round two — a guard denial means a foreign worktree, required keys under every branch, a gated post-rebase round
The marker-overwrite instruction asserted an antecedent no agent can verify and, since same-session worktrees carry no marker, could only fire in another session's worktree; the scripts now stop and report. `ran` and `patch_changed` move into required keys of their own schemas so a missing field cannot read as a successful cross-family review or an unchanged patch. A blocking finding in the post-rebase round now returns an error like every other failure path. Each reviewer lens gets its own E2E port; the gate text travels with the reviewer brief. The standing prompt no longer contradicts the substitution the scripts perform; the record names patch-id, the mechanism the scripts use. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
63256afd96 |
fix(907): answer the cold review — drop the E2E lock, scope force-with-lease in a record, referee ticks the boxes
The mkdir lock around scripts/e2e-local.sh serialised the launch, not the run (the launcher returns with the server up), and its stale-holder path double- acquired in 4 of 91 measured races; the launcher's documented conflict is its per-worktree wwwroot, so slots now run on their own port and the lock is gone with its inventory row. process.orchestrated-session records the two scopings the harness needed: a rebase pushed with --force-with-lease as the one sanctioned rewrite, and the referee as the only agent that ticks Done-when boxes. Scripts: required-arg guard, per-issue claim probe, reviewer fetch recipe, codex fallback to a cold review-only agent with the substitution stated in the PR body, rebase before the review loop with a patch-id check at the push, non-interactive squash recipe, Land phase. README bullets re-parented; kickoff bullet keyed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
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 |
||
|
|
fb55929711 |
fix(869,893): re-establish the 1.25.4-dated CI claims on 1.27.1, and settle the page_statuses asymmetry from source (#905)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m24s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m10s
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 4m17s
Finishes the 1.25.4-dated CI claim sweep #747 deliberately left incomplete (#869), and answers #893 from the Gitea v1.27.1 source instead of inferring it from a header. Docs and comments only - zero non-comment changes in scripts/ and .gitea/. Population derived with `git ls-files`, not from the issue's item list: 17 files, 43 occurrences of `1.25.4`, against the 4 items #869 named. Re-established on 1.27.1: the `creator`-attribution claim the H10 allow-list rests on (4 merged heads, both endpoints); the scope enum (no `status` scope); the `reqRepoWriter(unit.TypeCode)` gate; the `write:package` 403 (live probe with a read control 200 and a write control 201, throwaway repo, artifacts deleted); the absence of any REST cancel route (from source, which a 404 alone cannot establish); and `pull_request`/`pull_request_target` definition resolution. #893: `/statuses/{sha}` does NOT drop rows after pagination. `getCommitStatuses` appends unconditionally and its only filter is a SQL WHERE in the same query as the LIMIT/OFFSET, so an empty page really is the end, `page_statuses` terminating on its first empty page is safe, and the asymmetry with `count_pr_mutations` is correct - recorded with its reason and a date so it is not tidied away. Corrected rather than re-dated: the `--depth=1` no-merge-base claim was filed against the wrong axis (a git property, re-probed on git 2.55.0), and `enable_bypass_allowlist` postdating 1.25.4 had an issue body as its only provenance. Five cold review rounds plus a cross-family Codex pass. They caught a wrong MECHANISM for `creator: null` (it is `CreatorID == -2`, not `== 0`), an evidence count that straddled the upgrade, and a reason for not re-probing MCP `cancel_run` that was invented - all fixed, final verdict CLEAN. fixes #869 fixes #893 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Whe75djeAEuZpdNk6KU7No Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
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>
|
||
|
|
9da0020462 |
docs(887): sweep the withdrawal through the record — it still described the withdrawn pin
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 17s
PR Gates / Docs update reminder (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 16s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 12s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
review-verdict/h10 Review-verdict: MERGEABLE @ 9da0020 (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m53s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m47s
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 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
Round 9 returned MERGEABLE with BLOCKER and HIGH empty. Every remaining item was a sentence, and every one erred by UNDERSTATING the guard — which is the safe direction and still worth fixing, because the decision record is what CLAUDE.md routes convention lookups to. The record's `rule:` still listed "`web/vite.config.ts`'s `test:` block" among the pinned things — the very mechanism the previous commit withdrew — and named only `vitest.config.*` as the outranking family, omitting `vite.config.js`/`.mjs`, which is the MEASURED attack from round 7 (a `web/vite.config.js` ran the suite in the gitless stage with 1411 tests green). That family went short in round 7 and again in round 8. This is `enumerate-CLAUSES-to-close-a-sweep`: the survivors were phrased in a different category (WHAT is pinned) from the retracted claim (HOW it is extracted), so sweeping for the retracted words missed them. Also: "any edit to this file reddens, including a comment" was an absolute and is refutable — a reindent, added blank lines, tabs, and a form feed all stay green, because `_normalise_lines` collapses whitespace. Restated as what is actually true (a line's TOKEN sequence, a comment's words included) plus the reason the tolerance is currently inert: this file has no template literal and no ASI-sensitive token outside a comment. And a YAML single-quote escape had leaked from the frontmatter into the markdown BODY, where `''` renders literally. No code change; the guard is unchanged and still 73/0. Refs: #887 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF |
||
|
|
58def5faa1 |
fix(887): withdraw partial matching too — web/vite.config.ts is pinned WHOLE
Round 8. Three consecutive rounds had each closed one SPELLING of the same marker match,
which is `every-blocker-was-one-mechanism-so-delete-it` at the count where it says
withdraw. Round 6 pinned a block; round 7 fixed `test: {` (two spaces); round 8 defeated
the repaired matcher four more ways — `test : {`, `"test": {`, and the same two for
`plugins:` — plus two that never touched the marker at all:
plugins: [react(), trackedSourceFilesPlugin()].concat([evil])
test: { …pinned… }, ...moreTest
`defineConfig` is the identity function in BOTH vite and vitest (read from the installed
tree), so a spread AFTER the pinned span simply replaces what the pin matched. No
respelling of the marker could ever have caught those: the defect was partial matching,
not the pattern.
So the file is pinned whole. 48 lines, nothing generates it, no marker to respell and
nothing after the span. One assertion replaces a bracket walk, a block extractor and two
uniqueness assertions — and catches all seven measured routes. Stated cost, which is the
same one every other pin here carries: any edit to that file reddens, a comment included.
This also retires a claim I made in a commit message AND in the inventory row: that the
two pins "share one bracket walk and cannot drift apart again". It was false when
written — the block extractor had its own inline copy and never called the shared helper.
Verified by spying on the call: the `test:` pin made zero calls to it.
Also closed: the second exec-form branch in `copies_spa_source` was DEAD code (measured by
poisoning it with a `raise` — every test stayed green), since the round-7 reorder rewrites
`remainder` before it; the exec-form `--from` case that reorder was written for had no
STANDING test, only the development battery, and now has three; and
`OUTRANKING_CONFIG_PREFIXES` matched nested paths, so a `web/e2e/vite.config.ts` — and
`web/e2e/` is a real directory — would have reddened with a message asserting it outranks
the pinned config, which is false. Direct children of `web/` only.
Battery 68 -> 73, 0 missed.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
|
||
|
|
8141ac7807 |
fix(887): the rule applied to one config family and not its sibling
Round 7. BLOCKER empty; three real holes, two of them introduced by round six's own
fixes, which is this repo's recorded `each-fix-round-introduces-an-adjacent-defect`
happening inside a single commit.
**`web/vite.config.js` outranks the pinned `.ts` for `vite build`.** Read from the pinned
tarball rather than remembered: vite 8.1.3's `DEFAULT_CONFIG_FILES` is
`["vite.config.js", "vite.config.mjs", "vite.config.ts", …]`. So a `vite.config.js` whose
plugin shells out to the suite runs it in the gitless stage — measured with all 1411
tests green. The governing rule was already written down one family over ("pinning one
config is worthless while a second can outrank it") and had been applied to
`vitest.config.*` and not to this one. The refusal now covers both families.
**The decoy fix counted one SPELLING of the marker.** `text.count("test: {")` is defeated
by writing the real block `test: {` with two spaces beside a decoy that matches exactly:
count is 1, the comparison takes the decoy, and the live block filters out precisely the
specs #883 broke on. Now `re.finditer(r"\btest:\s*\{")`.
**The plugin pin added in that same commit shipped the identical decoy hole** it was
written next door to fix — a raw `text.count(PINNED_VITE_PLUGINS) == 1` with a decoy
above `defineConfig`. Both `vite.config.ts` pins now share ONE bracket walk and ONE
whitespace-tolerant uniqueness rule, so they cannot drift apart again.
PROSE, and this one is a false completion claim in my own previous commit message: I said
the `PUBLISH_ACTION`/anti-vacuity sentence and the singular "only an `ENV`" residual were
corrected. They were — in the record and the inventory row, and NOT in the guard
docstring, which is the artifact a code reader hits first. Both are now fixed there too,
the route COUNT is removed from the docstring and the record and kept in ONE place, and
the residual that stated its own false version before retracting it now states the
boundary once.
Also: the `--from=` branch never reached the JSON exec-form parser, so
`COPY --from=web-build ["/source/web", "/dest"]` left the receiving stage unpinned; the
revalidate arm of the gating `if:` is now described as a DEPENDENCY on
`ci-detect-already-validated.sh` (graded `MUTATION: NONE`) rather than as something
asserted here, since only the `docs_only` arm is; and the plugin-bodies residual now says
there are TWO plugins, `react()`'s being third-party and unmitigated.
Battery 64 -> 68, 0 missed. One of those four exists because the battery itself briefly
reported NOTHING and exited 0 after a bad splice deleted its `main()` — it now carries an
anti-vacuity assert on its own mutant count.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
|
||
|
|
3ec73f3769 |
fix(887): a pin assumes it is pinning the thing that still DECIDES
Round 6 found three more false greens and named the class they share, which is worth
more than any of the three fixes:
* `web/vitest.config.ts` OUTRANKS the pinned `vite.config.ts` — closed in the previous
commit, found by probing vitest rather than reading about it.
* A DECOY first `test: {` block. The comparison took `text.index("test: {")`, so a copy
of the pin placed above `defineConfig` satisfied it while the real block was narrowed.
Exactly one is now required — the same assertion this file already made about the
gating step's NAME, for the same reason, not carried across.
* A `needs:` edge matched by bare job id. `needs:` resolves within its own workflow, so
a SECOND workflow publishing this Dockerfile while needing its own unrelated job
called `test` satisfied it. Now bound to `GATING_WORKFLOW`. (The reviewer downgraded
this to MEDIUM on measuring that `test_remote_state_inventory.py` forces a human to
classify any new workflow — so the hole is "the guard is blind", not "silent". The
forced review asks about remote state, not about whether the image is gated, so the
one-line fix stands.)
* A vite PLUGIN can shell out to the suite from `buildStart()`. The plugin ARRAY is
pinned; the plugin BODIES are a stated residual, mitigated because
`trackedSourceFilesPlugin` is deliberately lazy — a fact its own comment now marks as
LOAD-BEARING for the image build rather than leaving as an optimisation note.
THE CLASS: **a pin assumes it is pinning the artifact that still decides.** Every route
found so far is authority moving where the pin is not looking — to another FILE, another
OCCURRENCE in the same file, another WORKFLOW, or a HOOK the pinned command invokes. That
question is now written down for the next person adding a pin, because a list of four
instances is not what generalises.
Prose, all refuted by execution: the residual naming the uncovered COPY shapes was wrong a
THIRD time at the same site (`/source/web /elsewhere` IS recognised — only the destination
is renamed — and the file's own test 700 lines below said so); "only an `ENV` is
unmodelled" was an absolute and is now a list; "Reach: N mutants, 0 missed" is restated as
a DEVELOPMENT BATTERY, since it is not in the repo, nothing re-derives it, and an
independent battery found misses against an earlier head; and `PUBLISH_ACTION` was claimed
covered by anti-vacuity, which proves the selector is non-empty and cannot prove it
complete.
Battery 61 -> 64, 0 missed.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
|
||
|
|
848216e277 |
fix(887): a second config file outranks the pinned one — found by probing vitest itself
Pinning `web/vite.config.ts`'s `test:` block is worthless while a file that takes precedence over it can simply be added. Vitest resolves `vitest.config.*` (and `vitest.workspace.*` / `vitest.projects.*`) BEFORE `vite.config.*`. MEASURED, not read: dropping a `web/vitest.config.ts` carrying `include: ['nope/**'], passWithNoTests: true` beside the pinned file made `npx vitest run` report "No test files found, exiting with code 0". The gating step would be green having run NOTHING — worse than the filtered run ersatztv#887 removed, because a filtered suite at least reports on what it ran. The construct is refused rather than modelled: no such file exists, so the guard asserts none appears. Its population is the git INDEX, which is right and worth stating — an untracked config does not exist in a CI checkout either, so the mutant proving this has to STAGE the file. It failed to redden until it did, which is the correct behaviour demonstrating itself. Route count five -> six -> seven -> eight, wrong at every previous count, so it stays a running total with its history attached. Battery 60 -> 61, 0 missed. Refs: #887 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF |
||
|
|
9b99a23835 |
fix(887): a selector where a pin was available — the blocker round 5 measured
Round 5 found a BLOCKER, and it is the sharpest kind: I made the exact mistake I had described one screen earlier. `test_only_the_PINNED_npm_SCRIPTS_run_vitest` SELECTED the scripts to pin by asking whether their body contained the literal `vitest` — a selector, the category this file calls the worst-behaved because going short is silent — and then its docstring claimed "going short is caught by the equality below", which is false: a script the substring misses is absent from the compared map, so the equality still holds. Four one-line `web/package.json` edits, none of which spells `vitest`, each put the suite back into the gitless stage with the whole guard green: `"build": "npm run test -- --run && …"`, the same via `npm t`, and the `prebuild` / `preinstall` LIFECYCLE HOOKS, which npm runs for `npm run build` and `npm ci` without anything naming them. That is #883 verbatim, through the route round 4 identified and the previous commit reported closed. The fix is the one the file's own vocabulary prescribes: pin the WHOLE script map. A script that does not exist cannot be a lifecycle hook, and one that changes is not equal. The category disappears rather than being widened by two entries. ALSO CLOSED, all measured: * `web/vite.config.ts`'s `test:` block is now pinned. `npm test -- --run` collects what that file says, so `test.exclude` is where a filter would now naturally be written — it is the only place left after this change removed the Dockerfile's. Three mutants narrowed the gating suite through it with the step's own command unchanged. * A step-level `shell:` and a job-level `defaults:` each override the pinned workflow default. Both forbidden. * `test_no_run_BODY_builds_or_pushes_an_image` is RESTORED — I dropped it in the parser withdrawal, and a job publishing via `run: docker build … && docker push …` was then outside the action-derived population with anti-vacuity none the wiser. * A leading-slash context copy (`COPY /web/. ./web/`) was not recognised. * The sweep gains `yarn test`, `pnpm test`, `bun test`. The residual naming the uncovered COPY shapes was wrong for the SECOND consecutive round — it named `COPY --from=X /source/web /elsewhere`, which is covered (only the destination is renamed). The real gaps are an ANCESTOR source (`/source` brings `/source/web` along) and `/source/.`. Both measured. Route count: five, then six, now seven. It has been wrong at every count, so it is now stated as a running total with that history attached rather than as an enumeration. Battery 51 -> 60, 0 missed. Refs: #887 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF |
||
|
|
3a88f5dc3d |
fix(887): six meaning-change routes, not five — counted by checking rather than asserting
The previous commit said "five such routes" in three places. Checking rather than restating found six, and the sixth is one this guard must NOT close itself: the gating job runs in a `container:`, whose image decides which `npm` exists at all. That is already pinned by `test_ci_image_pin_population.py`, so it is CITED — two guards on one condition mask each other (ersatztv#685), and the way to find that out is to delete one and look for a red, which nobody does. Also measured rather than assumed: an INDIRECT script chain (`"test": "npm run inner"` with `inner` running vitest) needs no clause of its own. The set-equality against `PINNED_VITEST_SCRIPTS` reddens on it, because `inner` mentions vitest and `test` no longer does — verified across four scenarios, three red and one green. An enumeration is a claim like any other. This one was written from memory of what had been fixed rather than from the code, and it was short by one. Refs: #887 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF |
||
|
|
566cabea6d |
fix(887): the pin's real limit, measured — and three absolutes it does not support
A fourth cold review attacked the pin itself. BLOCKER empty, the mechanism upheld, and
every finding was prose claiming more than I had measured — plus one one-token gap that
was live.
THE ABSOLUTES, refuted by execution and now corrected in all three places they appeared
(guard docstring, `docs/guard-inventory.md`, the decision record's `rule:`):
* "A pin cannot produce a false green." True only in the trivial reading. A pin is
immune to a different SPELLING of the command — the entire class that defeated the
parser nine times — and is NOT immune to the same text MEANING something else. Two
mutants re-armed ersatztv#887 through `web/package.json` alone: `RUN npm run build`
executes whatever that file says, so `"build": "vitest run && …"` puts the suite back
into the gitless stage with every pin still matching, and `"prepare"` does it via
`npm ci`. Now pinned: exactly one script may mention vitest, and its body is fixed.
* Residual (1), "a stage that does not carry the SPA source is unpinned — correct,
since without `web/` there is no suite there". False. The boundary is what
`copies_spa_source` RECOGNISES, which is narrower than "has the suite available".
Restated, with the case still outside it named: a stage copy that RENAMES the tree.
* The substring sweep's "never a false green". Its reported failures are false reds;
what it fails to REPORT is not. `SUITE_MENTIONS` is a hand-written SELECTOR — a third
category beside population and pin, and the worst-behaved, because a population going
short is caught by an equality and a stale pin reddens loudly, while a selector going
short is silent. It was short by exactly one entry: `npm t`, npm's own alias, which
this guard already names among the spellings that defeated the parser. A stage
running `npm t -- --run` escaped it. Fixed, and the category is now named.
ALSO CLOSED: `run: |` -> `run: >` folded the two-line body into one command whose
whitespace-normalised text was byte-identical to the pin, so the marker script swallowed
the suite as its arguments — the body is now compared LINE BY LINE, since a newline
separates two commands. A SECOND step named `Test SPA` inherited the exemption both the
pin lookup and the sweep key on; exactly one is now required. And `COPY web*/` — a glob
that matches `web/` — was read as not carrying the source, leaving the receiving stage
unpinned.
Battery 45 -> 51, 0 missed. The remaining meaning-change route, an `ENV` rewriting `PATH`
so a pinned `RUN` resolves a different `npm`, is not modelled and is recorded as a
residual rather than implied away.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
0e40ac283b |
fix(870): an empty timeline page is not exhaustion — the walk reads to its cap (#896)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 5s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 10m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m56s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 7m2s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m35s
`count_pr_mutations` treated an empty page past page 1 as proof it had reached the end of the PR timeline. Gitea does not mean that: `ListIssueCommentsAndTimeline` applies the LIMIT/OFFSET in `FindComments` at the DATABASE level and filters AFTERWARDS, dropping `CommentTypeCode` rows and inaccessible cross-references into a nil slice that serializes as bare `null`. A page of 50 inline review comments is byte-identical to a page past the end while later pages still hold events, and rows are ASCENDING, so the events a fence looks for are the furthest from page 1. Fifty comments, which a PR author can create on their own PR, truncated both walks at the same place: both counts agreed, the sha comparison agreed, and an ABA force-push yielded an exemption `success` over a diff no single head justified. The walk no longer infers the end from an empty page BEFORE its cap. Such a page is skipped; the loop reads every page to its 20-page cap and trusts the counts only when the LAST page came back empty. An empty FIRST page and any unreadable shape still end the walk untrusted. NARROWED, NOT CLOSED, and the docs say so in one unit: the page-20 terminator is still trusted for the same unprovable reason, so the defeat now costs a timeline of over 1000 rows rather than ~100, with the same 50-row filtered block pinned to offsets 950..999. Measured at Gitea 1.27.1, ruling out the cheaper fixes: `X-Total-Count` on this endpoint is the post-filter length of the PAGE, not a total (`?limit=1` returns 1 on a 14-row timeline), while `/activities/feeds` returns a true total; `limit` clamps to 50; the only query params are `since`, `before`, `page`, `limit`, so the paged and serialized sets cannot be made to agree. Also: each page bounded `--connect-timeout 5 --max-time 15` and retried once, mirroring `page_statuses`, because the walk went from ~2 requests to a fixed 20 and the third call site runs after the exemption `success` is posted. Costs stated rather than hidden — worst case 40 requests and 20 sleeps, wall-clock pessimum 620s per walk, and the suite roughly doubled (202s -> 474s). Seven tests, each mutation-witnessed red; three reproduce the defeat against the shipped predecessor. Two independent cold reviews plus a re-review of the fix: no Blocker or High in the code. Their real finding was prose claiming the hole was closed, and cost arithmetic wrong twice. One reviewer claim was refuted by execution. Fixes #870 Refs: #803, #706, #664, #751, #893 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
528383cf3a |
fix(880): an absent recurrence array means unrestricted, an explicit [] is rejected (#892)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 10m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 7m34s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m59s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m27s
The three recurrence arrays are read CONJUNCTIVELY by AlternateScheduleSelector.GetScheduleForDate, so an empty set matches no date. `?? []` on an omitted array therefore returned HTTP 200 while storing an alternate-schedule or template item that could never apply, silently -- while the read side (#823) already read a NULL column as the All*() sets. Absent and explicitly-empty are two different requests and get two answers: ABSENT (missing, or explicit null) normalizes to AlternateScheduleSelector.All*(), the same symbols the read side substitutes; EXPLICIT [] is rejected with a 422 naming the consequence, via RecurrenceSetBounds called from both replace handlers. The rejection lives in the handlers, not the controller, because api.ffmpeg-profile-numeric-bounds' "accept an UNCHANGED bad value" rule binds hardest here: both PUT paths are whole-list replaces, so rejecting a pre-existing empty set would make every OTHER item in the list uneditable. That comparison needs the stored row. The validated set is derived from `incoming`, so the highest-Index catch-all -- whose recurrence the handler discards -- is excluded by construction. Verified: full ErsatzTV.Tests suite green; three mutation proofs with disjoint reddened sets; live-E2E against a real instance confirmed an OMITTED property round-trips as unrestricted (the Newtonsoft missing-property chain unit tests cannot reach), an explicit [] returns the 422, and [] on the catch-all is accepted. Cross-family cold review BLOCKED the first implementation with 3 findings, all real and all fixed; re-review returned MERGEABLE. Follow-up #894 filed: the SPA can still build the empty state the server rejects. fixes #880 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
691a14d039 |
fix(849): round 8 — a green main does not post, and two round-7 fixes that overshot
The first cross-family review in five rounds (Codex/GPT-5.6, once its quota reset). It found
a Blocker four same-family rounds had missed, and REVERSED two of round 7's fixes — which is
the more useful result, because both were made in response to a review and both overshot in
the direction the finding pointed.
## The Blocker: dropping unreadable elements became "no verdict exists"
Round 3 added `select(type == "object")` so a malformed NEIGHBOUR could not kill the step.
When it drops EVERY element, `first // {}` yields `{}`, all `ex_*` read empty, and the job
concludes no verdict exists — so a docs-only PR walks straight to the exemption. Measured:
`{"total_count":1,"statuses":[7]}` posts `Exempt: docs-only change` here and posted NOTHING
on `origin/main`, which raised jq error 5 and aborted under `set -e` before any write. An
input on which this branch greens a head that `main` fails closed on, and if that scalar is a
mangled rendering of the head's human `failure`, the rejection is what gets greened.
The asymmetry is now the rule: a malformed row BESIDE one we did read is noise; a malformed
row where we found NOTHING is the only evidence there was. The absence conclusion has to be
earned over a list with no unreadable elements in it.
## Two round-7 fixes that overshot
- **The arms judged both snapshots.** Round 6's review said they judged `$pre_*` while the
POST replaces `$ex_*`; I made both veto, which is the mirror defect — an opening row since
REPLACED by a machine `success` still vetoed, so the arm left that success gating the head.
They judge the current row alone now. The opening snapshot keeps exactly one job: it can
make the write STRONGER, never suppress it.
- **The "this job's own output" exclusion keyed on the DESCRIPTION.** A description is not
provenance. Any workflow with `code: write` can POST a `creator: null` row and any
repository writer can POST one with a creator, either wearing this job's text — so masking
a human `failure` with a lookalike `pending` bought an abstention, and the successor
re-derived it as ordinary machine output with the rejection below its own mark. Removed;
the attempt is recorded because it is the tempting one, and there is no issuer field that
could make it safe.
## A guard that could not be reached, folded into the one that can
The repair veto turned out unreachable: an `$ex_desc` of `$REPAIR_DESC` with a different
`$desc` is caught by the mid-run sentinel guard long before an arm runs, and when `$desc` IS
`$REPAIR_DESC` the promotion writes the same string. Rather than keep a guard no fixture can
reach — or delete it on the strength of a check three hundred lines away — the invariant is
enforced where it is local and provable: the mark carries the strongest description any
snapshot shows, then declines to write what is already there.
`ci.exemption-provenance` still called the post-final-count window a PERMANENT forged green
in its `rule:` frontmatter and body; the post-POST re-count made it transient two rounds ago.
refs #849
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
|
||
|
|
72cca9a280 |
fix(849): round 7 — the arms judged the wrong snapshot, and two comments that invited a bug
A fourth cold review of the tip. No Blockers, no High: it enumerated every POST site and
every exit and could not construct an input where this branch writes a `success` that
`origin/main` would not.
## The arms judged the wrong snapshot
`mark_declined_row_if_any`'s three refusals all read `$pre_*` — the FIRST read — while the
POST replaces whatever row is CURRENT. So a reviewer's verdict arriving between the two
reads slipped past every refusal written to protect it: the base mismatch clears
`ex_attributable` so the mid-run abstain declines, `pre_creator` is empty so the allow-list
loop declines, and the arm marks a row nobody evaluated. Executed trace, control and case.
Both snapshots are consulted now, and either one vetoes.
Recovery was not free, which is why it mattered: the next run's reconciliation counts that
`Review-verdict:` row as buried and upgrades to the human-only sentinel — exactly the cost
the refusal exists to avoid.
The arm also marked this job's OWN ordinary machine `pending`. Every PR past its first run
carries one, so "kept off the commonest path in this job" was true only of a head with no
status at all. Scoped on the DESCRIPTION rather than on `creator: null`, which would also
exclude a machine `success` from another workflow — the row this marking exists for.
## Two comments that invited a bug
- One still described the round-4 REGRESSION as the intended behaviour ("a malformed row
reads as no creator, hence re-derived"), two lines below the block recording that it was
fixed. Adjacent comments giving contradictory accounts of one line, and the stale one
licenses reinstating it.
- The fault token's justification said "no Gitea status field contains a NUL". The token is
SOH (0x01). That is not pedantry: `$'\000…'` is the EMPTY STRING in bash, so an editor
correcting the code to match the comment would make every legitimately-absent field
compare equal to the token and send every clean head down the fail-closed route — the gate
would stall every PR.
## Docs
The record quoted a predicate that no longer exists (`[ "$ex_desc" != "$pre_desc" ]`, now
`$row_replaced`); `docs/ci-cd.md` stated the reconciliation witness unconditionally when the
code degrades to a description match where the server omits `id`; one of the six unproven
clauses carried a wrong `because` (the conclusion holds via `(.id | numbers) // -1` over a
validated array, not via the schema-fault route, which governs a different endpoint's row);
and the record's own counts read as a contradiction cold — 20 surviving MUTANTS collapse
onto 6 distinct CLAUSES, several clauses admitting more than one disarming edit. The
run-by-run provenance moved to the issue, where `docs.no-session-narrative` says it belongs.
Two existing mutation proofs lost their binding to the reworded clauses and failed loudly
rather than measuring the unmutated body, which is what that count assertion is for. Rebound.
refs #849
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
|
||
|
|
6fdf48de2d |
docs(849): drop a duplicated clause from the record, and record the sweep's yield
The `mechanics:` field ended with the same sentence twice — the enumeration of the unreachable clauses was appended without removing the tail it replaced. Found by the mutation-sweep agent while reading the record it was checking its own results against. In its place, the number that makes the technique worth its cost: 60 mutants, 40 red, 20 survivors, on a tree that had already been through three per-finding review rounds by two model families. refs #849 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF |
||
|
|
bbaf1d76f8 |
fix(849): round 5+6 — the survivors a mutation SWEEP found, and the six that cannot be reached
Codex was unavailable for this round (usage quota), so the cross-family reviewer was replaced by a same-family agent doing one mechanical job: enumerate every security-bearing clause the diff adds, disarm each, and run the WHOLE suite per mutant. 60 mutants, 40 red, 20 survivors — a yield no per-finding review in this series came close to, because a review looks at what the diff says it does and a sweep looks at what the tests actually pin. ## Proved (nine) - the description type test in the RECONCILIATION `buried` filter — exact twin of the post-write one, which had a proof; without it a numeric description hard-errors `startswith`, the count comes back unusable, and the genuine verdict on the next row is lost with it; - the `.status` / `.description` / `.id` type tests, parametrised over all four consumed fields so a fifth cannot be added without a case (`.creator`'s was the only one proved); - both retry loops — the combined read and `repair_status_to`'s second POST. Against a stub that fails EVERY attempt a retrying reader and a one-shot reader are indistinguishable, which is how a retry ships unexercised; the fixtures now fail only the first attempt; - the mid-run guard's self-exemption, which is what stops a sentinel-writing run abstaining on the row it was about to replace with an equivalent one; - both repair-write failure paths (the repair and the post-POST replacement), reachable only with a stub that lets the FIRST post through and fails the rest — with every post failing the job dies on its own classification write and never reaches them; - the two `state=pending` updates after a repair. The first is load-bearing beyond tidiness: without it a repaired head re-enters the post-POST check and, on a retarget it then observes, replaces `$REPAIR_DESC` with the weaker reconcilable sentinel — the same ordering inversion the floor beside it exists to prevent, reached by another route. ## Declared unreachable (six), enumerated rather than counted The path-predicate failure branch; the empty-`row` refusal; page 2's non-numeric length; the `$witness` normalisation; and the two unusable-count arms. Each is defence in depth behind a filter that makes its input well-formed for every case a fixture can pose — the same standing exception the post-write unusable-count arm already carried. That set has gone two -> five -> six across three rounds as the sweep widened. Naming them is the point: an inventory that undercounts reads as a checked claim and talks the next reader out of verifying, which is the same defect as inventing coverage — and this branch has already had to correct that twice. refs #849 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF |
||
|
|
28e82fcb59 |
fix(849): round 4 — a regression round 3 introduced, and the clauses it left unproven
A third cold review, which ran the mutants itself, found one measured direction regression against `origin/main`, one ordering inversion, and four clauses this branch claims as fixes that survived mutation of their own text. ## The regression Round 3 type-tested the four consumed fields of the existing `h10` row and resolved a failure to `""`. For `.creator` that means "no creator" — unattributable — which is a LICENCE TO RE-DERIVE. Measured, same fixture, both bodies: a head carrying `h10=failure` with `"creator": 7` posts `Exempt: docs-only change` here and posted NOTHING on `main`, which died on `.creator.login` before any write. Fail-closed became fail-open. The rationale that produced it came from #763, whose site is the POST-WRITE filter: there, dying leaves a green already published, so dropping the row is the safe direction. Here the alternative is dying BEFORE any write. The deferral rationale did not transfer — which is the shape this repo has a record for. A wrong TYPE is now distinguished from a legitimately ABSENT value: `null` is the machine creator, an unset description and every field of the `{}` no-verdict row; anything else is unknown state and takes the route an unreadable ELEMENT already took. ## The ordering inversion `mark_declined_row_if_any` was scoped to "the head carries any row", so it fired on a head carrying `$REPAIR_DESC` and replaced the human-only marker with the machine-clearable one — inverting the ordering the SAME commit added a floor to protect at the repair site. One mechanism, three writers, and only two had the rule. It also buried a verdict an ALLOW-LISTED reviewer wrote for another base. "Declined" is decided against this event's `$BASE_REF`, so such a row is still the right answer for the base it names and the successor run for that base short-circuits on it; burying it costs a manual re-post on an ordinary retarget-onto-the-reviewed-base flow. Membership is tested on the raw creator, not on `ex_human`, which the base check has already cleared — the question is who wrote the row, not whether it governs this diff. ## The unproven clauses Four claims survived mutation, including the headline one. The witness fixture had been designed AROUND its own discriminator — its comment said a seed with an unrelated id "would make this run carry the sentinel forward … and the guard under test would never be reached", which is a description of the test not reaching it. Eleven proofs added, covering the witness-by-id, the head arm's own call site (two callers of one helper, one fixture), the mark helper's result propagation, and the round-4 behaviour above. `raced_why`'s human value is a named constant now: it is the one such value that is also a PREDICATE, compared twice, and a drift in either copy silently downgrades the human `::error::` — the only message that tells a reviewer their verdict was buried. ## Docs The renamed sentinel literal in two places; three documents still asserting the fence "writes NOTHING"; the record's `mechanics:` still describing round 2's witness; the replacement-site list, which had grown by four; a residual pointing "below" at something above it; and `CLAUDE.md`'s "closed", which is stronger than the record it points at — that record lists six residuals including both endpoints failing at once. The proof inventory is stated as an invariant (every clause with a predecessor is mutated back to it) rather than a count that rots. refs #849 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF |
||
|
|
168fe21088 |
fix(849): round 3 — replace every unknown state, and prove the clauses that claim to
Two more cold reviews — cross-family (Codex/GPT-5.6) and a cold Claude reviewer that ran
the mutants itself — converged on two separate things: a remaining class of paths that
still left an unknown state standing, and, more importantly, that several clauses this
branch claimed as fixes SURVIVED mutation of the exact text they name.
## Behaviour
1. The reconciliation witness matches the CURRENT row's `id`, not merely a row with the
sentinel's description. Description alone is satisfied by an OLDER identical sentinel —
which is what a fixed point produces — so a read carrying only the earlier row cleared
the sentinel while the verdict buried under the current one ended up below the fresh
mark. Falls back to the description where the server omits `id`.
2. The two OBSERVED-mutation arms mark a head that carries a row this run declined, instead
of only abstaining. They are still right not to post their CLASSIFICATION — computed
against a base or head the PR may no longer have — but a declined row must not stay
authoritative for the whole window until a successor finishes, and for a PR's FIRST push
no successor is queued at all. Scoped to `pre_state` being non-empty, so the common path
stays quiet.
3. `replace_unknown_state` RETURNS a status. Its first version ended the failure arm with a
successful `echo`, so it reported 0 after both POSTs failed and the fence caller's
`exit 0` reported an abstention that had not happened.
4. An `id` difference counts only when BOTH reads supplied one. A response that omits `id`
beside one that includes it otherwise reads as a replacement, and this guard's reaction
is to abstain — over a row the classification had already declined.
5. Every element and every consumed field of the combined response is type-checked before
extraction, and a schema failure routes to the replacement. `.statuses` being an array
was checked; its ELEMENTS were not, so one scalar made `select(.context == $c)`
hard-error and `set -e` took the step down before any path could mark the head.
6. The path-predicate failure replaces rather than merely exiting, for the same reason.
7. `$UNVERIFIED_DESC` says "Status write", not "Exemption write". It is now written on paths
that grant no exemption at all, and it is the operator-facing text of a required check.
8. The no-op-repair skip keeps the human `::error::`. Skipping the WRITE is right — the head
already carries the strongest marker — but that message is the only place a reviewer is
told their verdict was buried. `raced_why` is a sentence now, not the token `human`.
## Proof
The cold reviewer measured three of the six round-2 claims surviving mutation of their own
clause, one against the verbatim predecessor from the previous commit. Nine proofs added:
the no-mark downgrade's SCOPE (not just the description it writes), the page-2 refusals, the
untrusted-fence write, the row-`id` comparison, the repair floor, the no-op skip, both `$own`
exclusions, the write-result return, and the both-ids-present rule.
Two of those needed the test double to grow: the combined-status stub emitted no `id` at
all, so the `ex_id` clause had never once run with a non-empty value; and POSTs always
succeeded, so both write helpers' failure arms were unreachable.
The `$own` exclusions and the no-op skip are OUTCOME-redundant — mutating either alone leaves
the post sequence unchanged, which is how duplicate guards hide each other. Their proofs
assert the LOG, because what the exclusions alone decide is whether the job reports a race
against its own row. One clause is left deliberately unproven and named as such in the record
and the guard inventory rather than counted: the path-predicate failure branch has no fixture
that can reach it.
## Also
Round 2 left two comment paragraphs duplicated verbatim and a block header narrower than its
block; both fixed. Stale prose corrected in the workflow ("dies WITHOUT posting", "post-write
verification never runs for it", "this block only runs after a `success`"), `docs/ci-cd.md`
("the fence never re-counts", "the history is read twice" — it is three now),
`ci.exemption-provenance` and `docs/guard-inventory.md`.
refs #849
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
|
||
|
|
957a328f33 |
fix(849): round 2 — the uncertainty paths that still resolved toward success
Two independent cold reviews (Codex/GPT-5.6 cross-family, and a cold Claude reviewer in
its own worktree) converged on the same class: paths where "this job cannot establish
what is on the head" still resolved by leaving the head alone, which protects a real
verdict and leaves a forged one.
Behaviour:
1. The four page-2 completeness refusals now replace the unknown state too. They were
excluded on the reasoning that the probe fires when NO row for this context was on page
1, so there is no green of any provenance to leave standing — self-contradictory, since
the only reason page 2 is read is that the row may be beyond page 1, which the probe's
own message says. Accepted cost, stated in the record: a head with more CONTEXTS than
the 50-row cap stalls every run; measured 2026-08-29, this repo puts 8 on a `main` head,
and that case already stalled with an ABSENT check.
2. The no-mark downgrade covers every re-derivable write, not only `success`. Restricting
it analysed the wrong PR: the damaging case is one that IS exemptible and got the
generic `pending` only from a transient enumeration failure. That description carries no
marker, nothing verifies it without a mark, and the next run re-derives it into the
exemption with the human row below its own mark — route 2's damage through route 1's
condition. `$REPAIR_DESC` stays exempt, being stronger and not re-derivable.
3. The fence branch that cannot trust its retarget count while holding a derived `success`
writes the sentinel instead of abstaining. It is reached only after the classification
DECLINED to inherit the row the head carries, so posting nothing left that row current;
the message said the context "stays absent", true only of a head that had none.
4. Reconciliation needs a WITNESS: it may clear only over a complete history containing the
sentinel's own row. `ex_unverified` means the combined endpoint just returned that row
and `/statuses/{sha}` keeps one per POST, so a complete-but-empty history contradicts a
write that demonstrably happened — and `page_statuses` accepts an empty page 1 as
complete, which is what made it reachable. Both reviewers reproduced the clear-then-exempt
outcome. The shipped positive test used exactly that impossible fixture, so it was
pinning the defect; it now seeds the sentinel row, and an impossible-empty negative plus
a witness mutation proof were added.
5. The mid-run "did this row change" comparison now includes the row ID. The two sentinels
are byte-identical by design, so a mid-run replacement of one by another was invisible to
a state/creator/description triple. Measured 2026-08-29 (Gitea 1.27.1, head
|
||
|
|
e30702111f |
fix(849): verify every write, and mark a head nothing could verify
The gate's post-write verification had five routes that all ended the same way — an exemption `success`, or a generic `pending` a later run turns into one, standing over a human `failure`. Two of these were attempted inside #742 and withdrawn, and the withdrawal is what shaped this change. That attempt withheld the exemption by writing a GENERIC `pending`, which is exactly what a later run re-derives into `success` — it moved which run posted the forged green rather than stopping it — and it had no retry path, because this workflow triggers only on `pull_request_target` types, so a transient failure on a PR's last event stalled an exempt PR until a human nudged it. The fix therefore needs two properties at once: sticky, so a later run cannot re-derive it, and reconcilable, so a blip does not cost a head its exemption permanently. Neither the repair sentinel nor a generic `pending` has both, which is why there is now a second sentinel rather than a reuse of the first. What changed: 1. No high-water mark => the exemption is WITHHELD before the POST and the head is marked with the new `UNVERIFIED_DESC` sentinel. Withholding before the write rather than posting and repairing matters because the defect is known in advance: publishing a green to take it back opens a window branch protection, and an already-scheduled auto-merge, can see. 2. Post-write verification runs after EVERY write, not only `success`. A generic `pending` masks a rejection landing in its own write window just as well, and carries no marker, so the next run re-derives it with the human's row now below THAT run's mark. 3. `.description` is type-tested before `startswith`. `(.description // "")` does not replace a NUMBER, so `startswith` hard-errors on one, killing the whole count — the genuine verdict beside the malformed row is lost with it. 4. The retarget count is re-taken AFTER the POST on the exemption path, closing the PERMANENT forged green `ci.verdict-write-retarget-fence` listed as its residual 1. The retarget axis only: a push after the POST moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with the sha unchanged. 5. An unreadable combined-status read retries once and then REPLACES the unknown state instead of declining to write. Declining protects a real verdict and leaves a FORGED one — an off-list `success` is the row #742 exists to revoke, revocation happens by re-deriving it, and the job then went red on a status branch protection does not read. One defect this introduced and fixed on the way: widening the post-write gate to every write made the job match its OWN row, because the machine-sentinel arm selects on a null creator. A run taking the carry-forward path POSTed `$REPAIR_DESC`, then found "a sentinel above the mark", then repaired to the identical description. `--arg own "$desc"` excludes it, by description rather than by id — the id of the row just written is not knowable there. Reconciliation is what bounds the stall: a later run pages `/statuses/{sha}` in full and either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it upgrades to the repair sentinel, clearable only by a human — or finds none and clears it. It is sound because the two endpoints disagree: a masked verdict is invisible on the combined endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. Tests: each fix is paired with a `test_MUTATION_…` proof that restores the exact predecessor text through a new `_run_classify(mutate=…)` knob, whose count assertion is the binding — a clause that has since moved substitutes zero times and fails loudly rather than measuring the unmutated body. Two CHAINED tests feed run N's real output into run N+1, because both sentinels are fixed points and a single hop cannot assert a fixed point: the raced-`pending` repair must survive the run that would otherwise grant the exemption, and the unverified sentinel must not decay while it cannot be reconciled. Docs: new record `ci.verdict-unverified-write-sentinel`; the now-false guarantee prose in `ci.verdict-write-retarget-fence` (its `rule:` frontmatter, the "resolves it" opener, "the fence above closes", the truncating-block claim and residual 1), `ci.exemption-provenance`, `docs/ci-cd.md`, `docs/remote-state-inventory.md` and `CLAUDE.md` corrected by concept rather than by phrase, per the scope boundary recorded on the issue. fixes #849 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF |
||
|
|
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> |
||
|
|
1d50241833 |
docs(853): workflow_dispatch can't be ref-restricted at 1.27.1 — and restricting it would close nothing (#888)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 12m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 8m19s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 8m10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 21s
Probed every reachable surface rather than stopping at the endpoint #853 already knew 404s: the dispatch API takes `ref` as a required free-form string with no allow-list; protected environments do not exist at 1.27.1 (0 of 308 documented paths mention "environment", secrets are org/repo/user-scoped only); the loaded `app.ini` sets two `[actions]` keys; and the CLI's sole Actions subcommand is `generate-runner-token`. So option 3 is unavailable. Accepted on a different ground than the issue proposed. "Anyone with repository write can already do worse" is unfalsifiable and hides the cheaper route. The operative reason is that dispatch is not the cheapest path: `docker-build.yml`'s head-resolved `pull_request:` runs attacker-authored YAML, which reaches every secret in the store — six of its jobs hold `REGISTRY_PASSWORD` on that route and two are branch-protection required contexts. "Push a branch, open a PR" costs no act outside the ordinary contribution flow, where a dispatch costs one. Corrections to #853's own table, verified against the tree: `dependency-scan.yml` references no secrets at all; the "four workflows" count is right. Deliberately not applied: a `v*` tag protection (`tag_protections` is empty and 1.27.1 supports it) — protection-class config whose failure mode is a broken release cut, so it needs its own change and verification. Tracked with the `pull_request:` residual in #885. The web UI was not swept, and the record says so explicitly rather than claiming exhaustiveness — a Gitea Actions control can exist with no API surface at all. fixes #853 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
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) <noreply@anthropic.com> Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
e8f80c42ce |
fix(820): derive where Complete<T> 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> |
||
|
|
736649b3b7 |
fix(812): classify the narrative sites by who-benefits; keep the detector's reach (#882)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 22s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m33s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Skipped
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 4m52s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
9685132ee0 |
fix(823): three cleanups from the coherence pass — a wrong witness, a wrong because, a duplicated paths:
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 14s
review-verdict/h10 Review-verdict: MERGEABLE @ 9685132 (base: main)
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 12s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 43s
Review verdict / Set review-verdict status (pull_request_target) Successful in 9s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 11m28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 14m32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m10s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 9m23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 10s
Round-five review confirmed the decision record is coherent with no third survivor of the empty reading, and returned three LOW findings. All are in prose I wrote in the last two commits. - The comment defending `(IsAbstract && !IsSealed)` cited AlternateScheduleSelectorTests as an in-repo static-fixture witness. That class IS static, but it merely NESTS its [TestFixture]es and declares no test of its own, so it would fail the sibling "declares no runnable test" assertion rather than demonstrating the point. The rule is right and the witness was wrong, which is the worse of the two failures because a wrong example is what a reader checks the rule against. No witness is cited now, and why is stated. - A mis-bound `because` in `rule:`: "assigning a null and calling SaveChanges SUCCEEDS ... because only the HTTP request records normalize with `?? []`". The `?? []` clause explains how a null could REACH the entity; what makes the save succeed is the column being nullable. A right observation with a wrong cause attached. Split into the two claims. - `signals:` carried the literal token `paths:` twice, an artifact of appending the #823 path list to the existing one. It degrades the field the discovery surface parses. Also recorded from that review, and NOT changed: `MonthsOfYear ?? AllDaysOfMonth()` survives the selector fixture and no date can kill it -- 1..31 contains every valid month, so it is an EQUIVALENT mutant there rather than a coverage gap. Its non-equivalent twin at the DTO boundary is pinned per-dimension by RecurrenceLimitsMapperNullTests. Left alone deliberately: chasing an equivalent mutant with a contrived date would buy nothing and cost the fixture's readability. Local gate: ErsatzTV.Tests 2091 passed / 6 skipped, Core.Tests 697/1 -- 0 failures. Format clean, no BOM. decisions_validate OK. Refs #823 Refs #824 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW |
||
|
|
3951fcf516 |
fix(823): stop patching the record by grep — a second ?? [] survived, and my "swept it" claim was false
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 18s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 9s
PR Gates / decisions lifecycle (pull_request) Successful in 14s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
review-verdict/h10 Awaiting review verdict for 3951fcf
Review verdict / Set review-verdict status (pull_request_target) Successful in 16s
PR Gates / Docs update reminder (pull_request) Successful in 11s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 7m52s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Canceled after 0s
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
Round-four review. One HIGH, again in the decision record, and the previous commit message asserted this exact class was cleared. It was not. THE HIGH, and the reason it recurred. A second sentence still described the rejected reading: "The guard form is `?? []` into a local rather than this record's Optional(x).Flatten(), a STATED deviation". The shipped guard is `?? AllDaysOfWeek()`. That sentence is the one that dictates guard FORM to the next implementer, so it would have taught the `[]` reading the same record spends a paragraph calling data corruption -- and it had already propagated into docs/decisions/README.md, the mandated entry point, which carries `rule:` verbatim. The mechanism, not the sentence, is the defect. I swept with a regex keyed on "null" plus a reading word; this sentence talks about guard FORM and contains neither, so it could not match. That is grepping the retracted WORDING instead of sweeping the CONCEPT, which is exactly what this corpus warns about -- and three rounds in a row have now found a defect introduced by the previous round's targeted string edit. So the fix is not another targeted edit: the whole `rule:` field was split into its 39 sentences and read back one by one against the code. Everything below came out of that pass rather than a grep. Its secondary damage is worth recording because it is the shape of a rationale that outlives its claim: the deviation was justified by ".ToList() allocates for nothing", which is now BOTH irrelevant to the choice AND false about the shipped code, since AllDaysOfMonth()/AllMonthsOfYear() are themselves Enumerable.Range(...).ToList() on exactly the null path it describes. - The opening sentence of `rule:` prescribed Optional(x).Flatten() as THE read-site form. It is the sentence most likely to be read in isolation, and it is wrong for six of the eight columns. It now separates the universal half (a LOCAL, never assigned back) from the half that is not (the substituted value), and names where each applies. - `signals:` had never been touched, so roughly 60% of `rule:` was unreachable by the discovery surface built for it -- no AlternateScheduleSelector, no mapper, no "unrestricted", and its paths: list named none of the files this work touched. It also advertised "Optional Flatten hoisted local" as the form, which is precisely what the six do NOT use. - The body prose was still entirely about SongMetadata while `rule:` had grown a whole second subject. Added the two results that contradicted the prior reasoning, in prose, where a reader meets them. A REAL BUG in my own guard, not just prose: fixture.IsAbstract.ShouldBeFalse(...) A C# `static class` compiles to `abstract sealed`, and NUnit runs tests declared in one -- this repo already has such a fixture (AlternateScheduleSelectorTests is `public static class`). So the check I added one commit ago to reject an un-runnable fixture would have falsely reddened a perfectly good static one. Now rejects an abstract BASE (abstract and NOT sealed), which is the case NUnit actually cannot instantiate. A SURVIVING MUTANT the added controls did not kill: AnyDate was 2024-03-06. With a day <= 12 a CROSS-WIRED substitution survives the whole fixture -- `DaysOfMonth ?? AllMonthsOfYear()` hands back 1..12, which still contains day 6, so every assertion passes while the guard substitutes the wrong set. Moved to 2024-03-20, still a Wednesday in March, outside 1..12. Measured both ways rather than reasoned: the cross-wire mutant passes the old fixture and FAILS 2 of 11 on the new one. Also re-witnessed, because I had modified that file and never re-proved it: restoring `??=` in LuceneSearchIndex reddens the LUCENE fixture (1 red, 1 green) -- the exact mirror of the Elastic mutation. Extracting ThrowOnWarningLogger did not cost #701 its proof, and the two fixtures are independently load-bearing in both directions. The record is now 73 prose lines, over the 60-line WARNING ceiling. Stated rather than trimmed: it is 42nd of 42 records over that line, and the added content is distinct findings (a second subject, a migration analysis and three residuals), not redundancy against a sibling. Local gate: ErsatzTV.Tests 2091 passed / 6 skipped (the three fixtures' MySQL halves), Core.Tests 697/1 -- 0 failures. Format clean, no BOM. decisions validate OK. Refs #823 Refs #824 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW |