87d90f330ccc3cf1faa6097bec3d60bcc1d45e48
897
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
87d90f330c |
docs(901): the hook-preamble fault does not name a constant — correct the mechanics claim
The record asserted both worked pins fail with a message naming the constant to update and asking for a reason. True of `test_image_build_delegates_the_spa_suite.py`'s three pins; false of `test_hook_fire_log.py`, whose byte-identity fault reports the divergent `mentions` list and names no constant. Verified against both files rather than inferred from the neighbouring one. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
0f4552d093 |
docs(901): pin the artifact whole is the DEFAULT for a predicate over a grammar it does not implement
Two predicates over artifacts with a real grammar were each defeated by successive spellings and withdrawn in favour of pinning the artifact whole: #887's shell parse (nine defects from one mechanism, then seven more against a partial match of web/vite.config.ts) and #891's lexical rule over the hook preamble (five spellings). Both incidents carry a record; neither is resolvable by topic before round three, which is what this class-level record adds. Decides the two questions #901 left open: - DEFAULT, not remedy. A shape-matcher's failure is a false GREEN, so the defeat that would trigger a remedy policy is found by a reviewer or an incident and never by the guard: "not defeated yet" measures who has looked. Rejected: write the matcher and pin after the first defeat — it also understates its bill, since a withdrawal costs the rounds spent AND the proofs calibrated against the narrow clause. - The exception argument carries FOUR things: the grammar and its parser; the input space as a closed enumeration with the reason it is closed; the fail direction measured as a declared, executed mutation; and what it buys priced in a cost the pin charges. Rejected: a numeric "survives N spellings" bar (measures the reviewer's imagination) and a reviewer sign-off bar (depends on the signal that arrives late). Records both riders (a pin assumes it pins the artifact that still DECIDES; widening a clause turns a survived-clause canary into a tautology) and states the threshold as the moment the NEXT spelling is found by the reviewer rather than the author. docs/README.md's guard-convention task-signal row points at the record; catalog regenerated; 59 prose lines, under the 60-line advisory ceiling. fixes #901 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
61ed6a7955 |
test(885): exercise REGISTRY_PASSWORD at both if: levels, re-confirm the layer measurement
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 16s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
PR Gates / Docs update reminder (pull_request) Successful in 23s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
Review verdict / Set review-verdict status (pull_request_target) Successful in 15s
review-verdict/h10 Review-verdict: MERGEABLE @ 61ed6a7 (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m35s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
Round-nine's bare-if fix table paired each secret name with only one level (job for REGISTRY_PASSWORD, step for RENOVATE_TOKEN), so the exact REGISTRY_PASSWORD-at-step-level and RENOVATE_TOKEN-at-job-level cases the finding named were never driven. All four combinations now run. The decision record's anonymous-layer-download closure read as reporting a past run without saying who ran it. Re-measured directly this session (2026-09-05, no stored credential): anonymous token -> pinned manifest's first layer -> 200/32991280 bytes, same GET with no token -> 401. Record updated to say the leg was re-confirmed, not merely "measured...since". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
d793342f50 |
docs(885): a condition NAMES a secret without exporting it, and is faulted for naming it
The `if:` clause reads the one site whose reference the evaluator resolves without materialising anything into the job environment, so a reader can reasonably ask why it faults. Both the function and the record now say: the predicate is "names a stored secret", never "exports one" — on the head-authored route the contributor picks the comparison, which makes a condition an oracle over the value, and a predicate about exposure would have to model what each site does with its reference and give up the structure-blindness that saw `toolchain-preflight`'s step `env:` when a `container:`-shaped predicate did not. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
786d1c3c4b |
docs(885): an unmeasured failure TIME, replaced by the structural fact it stood for
`scripts/ci-toolchain-image-resolves.sh` and `docs/ci-cd.md` both stated that the #772 container jobs "died after 1-2s", and the header used the same number to argue the preflight needs no `needs:` gate. Nobody measured it, and it cannot be measured from a working session without reproducing a deleted-tag incident. What the number stood for is structural and IS known: a container job that cannot pull its image fails AT the pull, before it runs a step, so it wastes no work waiting to be told and the argument against serialising the five jobs survives intact. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
47a5582443 |
docs(885): the record names the spelling the scoping costs, and the layer leg is measured
Two corrections to `ci.pr-route-carries-no-stored-credential`, both about claims that read
as checked and were not.
The `rule:` said the detector reads every spelling "only inside a `${{ }}` span", and the
body enumerated `secrets: inherit` as the ONE shape left uncovered. An unwrapped `if:` is a
second, and it is a shape this repo writes: both now name it, and say the value of an `if:`
is read whole.
`mechanics:` listed an anonymous LAYER download among two things the daemon probe did not
exercise. Measured 2026-09-05 from a workstation holding no registry credential: the
anonymous pull token reads the pinned manifest's first layer
`sha256:179c68a720750ab4d354f6b55c0a9f551d4fd7bde93606dd0be79ba16493a39e` -> HTTP 200,
32991280 bytes, and the same GET with no token -> 401. act_runner's own pull call path is
the one leg still unexercised.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
d4c00f7567 |
docs(885): the handover clause states its own unprobed reachability, where the rule is read
The code banner and the test docstring say that whether act_runner on this instance resolves `workflow_call` + `secrets: inherit` was not probed, and why that is acceptable — it governs reachability today, not the guard's silence. The record stated the clause without that bound, so a reader who meets the rule through the catalog rather than through the file met a confidence claim the source deliberately does not make. Refs #885 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
c325cd6ee4 |
fix(885): a handover written in the YAML grammar names no secret, so the guard reads the KEY too
The detector was exhaustive over the `${{ }}` expression grammar and blind to
`jobs.<id>.secrets: inherit` on a `uses:` job, which passes the caller's whole
store to the called workflow while naming nothing. `secret_refs` reads only
inside expression spans — correctly, since outside one `secrets.` is a full stop
— and `inherit` is a plain scalar, so such a job was put in the derived
population by `pull_request_jobs`, walked, and reported CLEAN. Measured against
the predecessor:
stored_secret_faults('synthetic.yml', {True: {'pull_request': None},
'jobs': {'reused': {'uses': './.gitea/workflows/reusable.yml',
'secrets': 'inherit'}}}) -> []
... the same job with secrets: {TOK: '${{ secrets.RENOVATE_TOKEN }}'} -> 1 fault
so the miss was specific to the VALUE SHAPE, not the key. That is the failure the
done-condition names — a new job joining the population unprotected without
reddening anything — in a guard whose stated selling point is exhaustiveness over
the grammar and no exemption list.
`opaque_secret_handovers` now faults a `secrets:` key whose value is not a mapping
of names, under the existing `secrets.*` whole-context sentinel, and both fault
sites read through one `held_secret_names` so the workflow scope and the job
subtree cannot drift on which references are forgiven. The test is on the value
shape and not on the word `inherit`, for the reason the residue counter is not a
match on `toJSON`: any non-mapping value hands over a set the guard cannot
enumerate, a spelling act_runner grows later included.
Both halves of the predecessor measurement are re-derived every run rather than
left as prose: the new test asserts `secret_names(job) - INJECTED_SECRETS` — the
collector verbatim as it read before this clause — empty on the same fixtures it
asserts the fault on, and asserts the job is in the population. Reverting
`held_secret_names` to that expression reddens that test and only that test
(measured: 1 failed, 18 passed).
The clause reads the DOCUMENT only and the text-versus-walk cross-check cannot
cover it — there is no expression for its half to match, which is a stronger
reason than the shared-blind-spot one the cross-check already discloses. Said at
the definition, in the cross-check's "STRUCTURALLY CANNOT REPORT" paragraph, and
in the record, rather than left to be discovered; it does not redden the
cross-check either, since the clause feeds the fault collector and not
`secret_name_counts`.
Whether Gitea 1.27.1 / act_runner resolves `workflow_call` + `secrets: inherit`
on this instance was NOT probed — that affects reachability today, not the
guard's silence, and the direction is the one the spelling rows already take.
No tracked workflow uses a job-level `uses:`, so nothing reddens.
Refs #885
Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
fe05526ef4 |
fix(885): the tag rule this branch added is dated where it is asserted
`release.main-direct-push-disabled` was edited on this branch to note the new `v*` tag protection, and picked up two of the defects the round was hunting elsewhere. Its `rule:` said the `renovate` bot "can no longer push a tag that publishes `:prod`" as settled fact, while `release.tag-protection-v-star` records that exact claim as NOT VERIFIED and `docs/ci-cd.md` was already corrected to EXPECTED, UNVERIFIED. Only the `timothy` credential exists in a working session, so neither a real release cut nor a refused bot push has been exercised; all three now agree on confidence. Its `mechanics:` still read "`GET .../tag_protections` returns `[]`" in the present tense — the one fact this branch changed, and the one site an otherwise complete sweep left behind. Read back live today the endpoint returns one rule, `v*` whitelisted to `timothy`. The clause is now past tense and bound to its probe date, with the current state named. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
fa06da21f3 |
fix(885): the guard states both invariants, and the second one is executed
The two `docs/guard-inventory.md` rows and the module docstring described `test_workflow_persist_credentials.py` as the `actions/checkout` guard only. The deferral to #909 rested on `docs/guard-inventory.md` being held by the session working #881; that issue is closed and its PR is the commit this branch is rebased onto, so the file is free and the edit belongs here under docs-update-is-part-of-done. `MUTATIONS` keys at most one declared clause mutation per guard FILE (`test_the_manifest_covers_exactly_the_MUTATION_rows` asserts `len(MUTATIONS) == len(declared)`), and the grading row's proof-ref column is compared against it, so the route invariant cannot take a second `MUTATIONS` row. It takes a `CLAIMS` entry instead — the population #881 widened this file to carry — bound to the inventory sentence that states it: deleting `build`'s `if: github.event_name != 'pull_request'` from the shipped `docker-build.yml` is applied to a sandbox copy every run and the named proof is required to redden with the collector's own wording. That grows the `CLAIMS` population from three entries to four, which invalidates the cost span `testing.mutation-claims-are-executed` measured over three. Re-taking it here produced 54.3s/149.5s, 81.6s/78.7s and 114.3s/84.2s across three A/B pairs with other builds on the host — two inverted, so the load dominates the signal. The record now says the span is a lower bound and that a re-measurement is owed on a quiet machine, rather than carrying a scaled or invented number. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
e656583247 |
style(885): the tag-protection paragraph reads as unverified in its opening clause too
"unaffected for the release operator" asserted the outcome the rest of the paragraph then marks unverified. Both places now say what is intended and what is measured, and the blockquote is rewrapped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
af9c2349a7 |
fix(885): three claims this branch added, dated to what was actually measured
`review-verdict.yml`'s residual list said the injected `GITEA_TOKEN` on the `pull_request` route is BOUNDED by `docker-build.yml`'s workflow-level `permissions: code: read`. That block lives in the head-supplied file on exactly that route: a PR author deletes it, and with the owner-level Actions default at `permissive` that alone yields a write-capable token. It is NARROWED for the committed file, and it stays in the residual set the paragraph exists to enumerate — which is what `release.verdict-status-check` and `test_pr_changed_files.py` already say. The same reword lands in `ci.pr-route-carries-no-stored-credential`, where the allow-list reason is now the store the token is not in rather than a bound. The "dies at image pull in 1-2s" figure was never measured on this branch — the 1-2s in `ci-toolchain-image-resolves.sh`'s header is an observation from the #772 incident, not a property of this change. The loud/silent asymmetry is what carries the argument, so the claim is now that a container job dies at image pull before it runs a step, which is true by construction. `ci.actions-credential-scoping`'s reworded `mechanics:` said "all three are now confined to the `build` job". `build` declares no `container:` at all; the buildcache write and the base-image pull are what it confines, and the `container:` pull is credential-free everywhere. `docs/ci-cd.md` asserted the `renovate` bot can no longer push a `v*` tag while `release.tag-protection-v-star` records that as NOT VERIFIED. The rule is read back live and real; what is unmeasured is Gitea honouring it against an account only the operator can test. Both docs now say expected, unverified. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
dd836c1f46 |
fix(885): the swept symbol's last live-voice site in the dispatch record
The same-subject sweep that past-tensed `ETV_STATUS_AUTH` and this record's own `rule:` field left the record BODY saying `toolchain-preflight` takes the registry credential "via `ETV_REGISTRY_AUTH`" in the present tense — a symbol this branch removes from every workflow, so the body contradicted the `rule:` field of the same record. Container-free and `runs-on: small` are still true today and stay in the present tense; only the credential clause moves to the past, matching the `rule:` field's "took the credential through `ETV_REGISTRY_AUTH`". Body-only, so the generated catalog is unchanged (regenerated to confirm). Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
23cef5c9d1 |
fix(885): two dependencies with opposite failure directions, and a token leg that got zero retries
The header comment and the record's `rule:` said the registry reads and the commit-status reads "both depend on `timothy/ersatztv` and its `ersatztv-ci` package staying PUBLIC; making either private fails those jobs loudly at image pull, never silently". Wrong in both clauses for the repo half, and this branch has already been sent back twice for exactly this shape of mechanism claim. Measured 2026-09-05: the `ersatztv-ci` package is linked to no repository (every version reports `"repository": null`), so the repo's visibility does not gate the anonymous pull token at all; and the only thing it does gate — the combined-status GET — fails in the opposite direction, because `ci-detect-already-validated.sh` answers a failed `curl -sf` with `emit false; exit 0`. That job stays GREEN and the #420 cross-run skip silently stops firing. So the two dependencies are now stated apart, each with its own failure direction, in `docker-build.yml`, in the preflight's header, in the record and in the `ci-cd.md` outcome table; the preflight's own 401/403 messages stop sending an operator to the repo's visibility when it is the package's. `token_leg_done` was set once per RUN, before the attempt, so a token endpoint that could not be reached failed the preflight with no retry while an identical blip on the manifest read got three. The stated reason — "a registry genuinely refusing anonymous reads is asked once rather than once per pin" — is a per-pin argument that never covered the per-attempt axis. It is now sorted by what the endpoint SAID rather than by which leg it happened on: an answer (no token in the body, a challenge naming no realm, no challenge at all) settles the question and is asked once per run; an endpoint that could not be reached, or that answered 5xx, settled nothing and is retried on the same `ETV_CI_ATTEMPTS` budget as the manifest read, because a red here denies a merge (the consent hook reads the COMBINED status, #598) and the two legs of one read must not have opposite flake tolerances. The token-leg message now reports the attempts it actually made. Driven against the SHIPPED predecessor rather than a hand-written mutant: the three new behavioural assertions are red on it (1 token call where 3 are required, and a blip shorter than the budget failing the run), while the two that pin the property the retry must not cost pass on both. Refs #885 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
8885fdd9d2 |
fix(885): the swept symbol's last live-voice site, and a caveat that is now a measurement
`ETV_STATUS_AUTH` is gone from `test`, `migrations` and `functional-e2e`, and three same-subject sites were reworded to match. `review-verdict.yml`'s comment was the fourth and still named the symbol as a live thing; it now names the credential by what it is, and says the PR route materialises none to refuse. `docs/remote-state-inventory.md`'s row for `ci-toolchain-image-resolves.sh` listed "an unusable credential" among the shapes that fail the job — that script holds no credential any more. The row names the three refused-anonymous-read shapes the shipped script actually has instead, and re-confirms the `UNSAFE-KNOWN` grade against the anonymous script: the tag it reads is mutable either way. That is #909's first half; its other half, `docs/guard-inventory.md`, stays with the session holding that file. `ci.pr-route-carries-no-stored-credential`'s `mechanics:` carried one self-declared unmeasured claim — whether act_runner's daemon performs the credential-free `container:` pull. Measured 2026-09-05 on the runner host 192.168.1.99, which runs both act_runner containers and creates every job container on its own docker socket: a `docker pull` of the pinned tag with a scratch docker config holding only `{}` exits 0. The two things that run did not exercise — an anonymous layer download, and act_runner's own pull call path — replace the open unknown rather than being dropped, and `docs/ci-cd.md` cites both measurements. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
383171dfa4 |
fix(885): a detector that knows ONE spelling is one an added job writes around
`secrets['REGISTRY_PASSWORD']` is the same reference to the expression evaluator as
`secrets.REGISTRY_PASSWORD`, and the guard shipped here could see only the dot form. Measured
2026-09-05 against the predecessor of this commit, a `pull_request`-route job whose `env:` read
`"${{ secrets['REGISTRY_PASSWORD'] }}"` produced `stored_secret_faults(...) == []` AND
`walk_versus_text_faults(...) == []` — the text cross-check cannot report the gap, because both of
its halves resolve references through the one pattern, so a spelling it does not know is a shared
blind spot they agree at zero on rather than a disagreement they name
(`proof-sharing-with-subject-proves-nothing`).
The file enumerated four other blind spots it has — composite actions, reusable workflows, nested
directories, both directions of the comment strip — and not this one, which is what made the
omission read as coverage.
`secret_refs` is now the single entry point for both halves, and it matches the dot form, both index
forms and a case-varied context, then counts the RESIDUE: any `secrets` token inside a `${{ }}` span
that yielded no literal name is reported under the sentinel `secrets.*`. Counting the residue rather
than pattern-matching `toJSON(secrets)` and a computed index is what makes it exhaustive over the
grammar — a spelling nobody has written yet still faults, in the fail-closed direction. The bare word
is read as the context only inside an expression, because in prose it is ordinary English; this file
and four workflows discuss "secrets" in comments.
`test_the_collector_sees_every_SPELLING_of_a_secret_reference` drives the six spellings through the
collector and the cross-check and asserts each is invisible to the real predecessor, so reverting the
widening reddens it. Whether act_runner resolves each spelling against this instance was not probed
from here (that needs a live run); the direction makes that acceptable — a spelling the runner does
not support costs a spurious demand on a job nobody has written, the omission cost a live
write-capable credential on the head-authored route.
Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
ccc604aef7 |
fix(885): the never-asked message reports what the RUN did, not what the registry sent
"WITHOUT issuing a Bearer challenge" is a claim about the registry's response that this script never checks. `probe` enters the token leg on a `401` only, so a `403` carrying a perfectly good `Www-Authenticate` would be refused with that sentence having never looked at the header — the same defect one branch over, in the message written to fix it. It now says NO TOKEN WAS EVER REQUESTED, which is a fact about the run: the token leg was not entered, and this answer was never followed as a challenge. The assertion and the outcome-table row move with it, and the comment says why the weaker claim is the honest one. Refs #885 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
985a6e1505 |
fix(885): a challenge-less 401 lands in the TOKEN-LEG arm, not the never-asked one — the table and the comment said otherwise
The row added for the third refusal shape was written as "401 / 403 carrying NO `Www-Authenticate` challenge at all", and the script comment beside it made the same binding. Both are wrong for the 401 half: `probe` enters the token leg on a 401, so a challenge-less 401 DOES call `acquire_token`, which sets `token_leg_done=1` and abandons for want of a realm — it reports `could NOT OBTAIN an anonymous pull token`, the row above. Only a FIRST-READ 403 reaches the never-asked arm. The parametrised test already drives both codes and asserts exactly that split; the prose beside them did not match it. The three rows now bind one shape each: a refusal surviving a bearer the run really obtained, a 401 whose token leg yielded none (no challenge header, no realm, or no token in the answer), and a first-read 403 that asked for nothing. Prose between arms regenerates mis-bindings — which is why the arms are stated as one self-binding row apiece rather than as a category sentence covering two. Refs #885 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
fba895852c |
fix(885): the two named records state the invariant this branch actually ships, not the job-only half
`ci.workflow-dispatch-ref-unrestricted` and `ci.actions-credential-scoping` are the two records the issue requires be updated to match, and both restated the invariant as "every job of a `pull_request`-triggered workflow that names a `secrets.*`". That was the shipped predicate when they were written and is now narrower than what the guard holds: the workflow scope outside `jobs:` is judged too, because a root `env:` or `defaults:` is materialised into every job and no job-level `if:` reaches it. A record that understates its own guard is the failure this repo grades worst — it reads as a checked description and stops the next reader looking. `ci.pr-route-carries-no-stored-credential` also names the two inventory rows that this issue made incomplete and did not edit, because both files are held by concurrent changes: `docs/remote-state-inventory.md` still lists "an unusable credential" among the shapes that fail the preflight, and `docs/guard-inventory.md` still describes `test_workflow_persist_credentials.py` as the `actions/checkout` guard alone. Neither goes red — both suites assert set equality over FILES and both files were already listed — so the carry is tracked as #909 rather than left to be discovered. Refs #885, #909 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
2f661be684 |
fix(885): the outcome table gains the third refusal arm, and the collector stops keeping a second copy of its own walk
Two follow-ons from the fixes in this branch, both of the class the branch is about. `docs/ci-cd.md`'s preflight outcome table listed two token-leg rows and now needs three: a `401`/`403` carrying no `Www-Authenticate` at all never reaches the token leg, and the table is what an operator reads to decide where a red preflight sends them. The paragraph after it named "the two token-leg rows" and now says why the three are worded apart at all — a message naming a step the run skipped is evidence for a diagnosis nobody performed. `secret_name_counts` was added beside `secret_names` as a second traversal with a different accumulator. That is a copy of a mechanism, free to drift from the one the assertion runs on — the guard reproducing, inside itself, the defect it was just widened to catch. There is now ONE walk: the counting one, with `secret_names` derived from it, which is the lossless direction. Re-witnessed after the refactor — the workflow-scope hoist into the shipped `docker-build.yml` still reports 3 failed, the clean tree 17 passed. Refs #885 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
bbd919bfe3 |
fix(885): a refusal carrying NO challenge never ran the token leg, so it stops claiming one
`probe` enters `acquire_token` on a `401` only. A registry answering `403` on the first read — or a `401` with no `Www-Authenticate` — therefore leaves `token_leg_done=0` and `token=""`, the guard at the `401|403` arm is false, and the run fell through to the message that says the read was refused "even after a Bearer token was obtained". Probed 2026-09-05 with a curl shim answering `403` and dumping only `HTTP/1.1 403 Forbidden`: that message is printed, EXIT=1, and no token was ever requested. The fail direction was safe; the diagnosis was not. It sends an operator to package visibility on evidence that does not exist (`dont-narrate-mechanisms-you-didnt-measure`) — in a script whose whole design is that its refusal messages are worded apart on purpose. The arm now branches on what actually ran, `token` first so the never-asked case cannot borrow either other mechanism: * `token` non-empty -> refused after a GOOD bearer (an answer about the PACKAGE) * token leg attempted -> challenged but produced no token (about the TOKEN ENDPOINT) * neither -> refused with no challenge at all (about ACCESS) The pre-existing `403` test could not reach this: `CURL_SHIM` answered `401` + a challenge to every unauthenticated read regardless of the configured code, so the `403` parameter was only ever observable AFTER the token leg. The shim grew a challenge-less behaviour (`CHALLENGE=none`, `REFUSAL=403|401`) rather than the assertion being written against the old one, and both codes are driven because they take different paths — the challenge-less `401` still enters and abandons the token leg. Witnessed red on the predecessor script (2 failed) and green on the fix. `docs/ci-cd.md`'s "Cutting a release" runbook — the section an operator reads at cut time — gains the `v*` tag protection, the account it whitelists, the fact that its positive half is unverified, and the `DELETE .../tag_protections/1` unblock. The tag-protection note already in this file sits inside the `main`-direct-push discussion, which is not where a release cut is driven from, and `release.tag-protection-v-star` names its own failure mode as a cut that will not push. `ci.pr-route-carries-no-stored-credential` records that `docs/remote-state-inventory.md`'s row for the preflight still lists "an unusable credential" among the shapes that fail the job, which this issue deleted. That file is held by a concurrent change, so the one-clause edit is tracked as #909 rather than made here. Refs #885, #909 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
b71bf4d7b2 |
fix(885): a name SET cannot see a second copy, so the PR-route guard judges the workflow scope and the cross-check COUNTS
The shipped guard walked `jobs.<id>` only and leaned on a text-versus-walk
cross-check to catch anything the walk could not reach. That cross-check compared
per-file NAME SETS, and the two halves cancelled on the one file the invariant is
about: measured 2026-09-05 at 59003d5a3, hoisting
env:
ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
into `.gitea/workflows/docker-build.yml`'s root `env:` — which materialises into
EVERY job on the head-authored PR route — left `pytest
scripts/tests/test_workflow_persist_credentials.py -q` at `14 passed`, rc=0. The
same hoist in `pr-checks.yml` reddened, because no job there already names those
secrets. The guard could only ever see a name NO job used; a second copy of a
reference `build` legitimately keeps naming changed no set. That is
`dont-keep-a-copy-of-a-set` / `proof-sharing-with-subject-proves-nothing`: the
proof shared its accumulator with its subject and cancelled.
Two changes, because the cross-check was being asked to do the assertion's job:
* the workflow scope (everything outside `jobs:`) is now judged in its own right
by the same structure-blind collector — it is a second entry site on equal
footing with the job subtree, not an edge case, since no job-level `if:` can
take a root `env:`/`defaults:` off the route;
* the cross-check walks the whole document and compares occurrence COUNTS. A
duplicate at an unreachable location now reddens: probed 2026-09-05, a trailing
`# ${{ secrets.REGISTRY_PASSWORD }}` on a root `env:` line reports `walk
[('REGISTRY_PASSWORD', 1)] vs text [('REGISTRY_PASSWORD', 2)]` where the set
version agreed. Under counting the comment strip becomes load-bearing rather
than the no-op the old docstring admitted it was.
Driven by a mutation on the SHIPPED `docker-build.yml`, the way the `build`-loses-
its-`if:` mutation already is, plus a direct assertion on the two collectors that
a duplicated reference changes the count and not the names. Witnessed red with the
hoist in the tree (3 failed) and green without it (17 passed).
The decision record's own claims were false in the same way and are corrected:
`rule:` said "NO job ... may name a stored secret" (a root `env:` is not a job) and
the prose said "a text-versus-walk cross-check reports any reference the walk
cannot reach".
Refs #885
Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
7355873c39 |
fix(885): a head-authored run may hold no stored secret, so the PR route reads the registry and the status API anonymously
`docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that
run executes contributor-authored YAML and every `secrets.*` it names is materialised into it.
Six jobs held `REGISTRY_PASSWORD` that way — `toolchain-preflight`, `test`, `migrations`,
`functional-e2e`, `api-docs`, `format` — two of them branch-protection required contexts.
The read-only pull PAT the issue asked to cost first was REJECTED, and the measurement is the
reason: this registry already issues an anonymous pull token for `timothy/ersatztv-ci`
(`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200), that token reads the pinned
manifest and its config blob (200/200), and the combined-status GET answers 200 unauthenticated.
A read-only PAT would grant exactly what anonymity grants while adding one more credential to the
store head-supplied YAML reaches. So the stronger form was implemented instead: no PR-route job
names a stored secret at all.
- `.gitea/workflows/docker-build.yml`: the five `container: credentials:` blocks, the
`ETV_REGISTRY_AUTH` step env and the three `ETV_STATUS_AUTH` step envs are gone. `build` keeps
the PAT; it is gated `if: github.event_name != 'pull_request'`.
- `scripts/ci-toolchain-image-resolves.sh`: reads `realm` out of the `Www-Authenticate` challenge,
exchanges it once per run for an anonymous pull token, retries with the bearer. Every refusal
direction is preserved — a 401/403 after the token leg, a token endpoint yielding no token, and
one that cannot be reached all `fail` rather than degrading to could-not-tell — and the message
now names the cause an operator can act on (the repo or package has stopped being public).
- `scripts/ci-detect-already-validated.sh`: the status GET is anonymous. No credential override is
kept: the URL names one instance, that instance is public, and an unusable `":"` would draw a 401
and turn a working read into a permanent skip=false.
- `scripts/tests/test_workflow_persist_credentials.py`: the invariant, derived from the git index by
"every job of a `pull_request`-triggered workflow that names a `secrets.*`" — never the six-name
list, and never "every `container:` job", which names five of six because `toolchain-preflight` is
container-free. Witnessed red against the unfixed workflow naming all six jobs; green after.
Live tag protection applied and read back: `POST /repos/timothy/ersatztv/tag_protections`
`{"name_pattern": "v*", "whitelist_usernames": ["timothy"]}` -> id 1. A non-`v*` probe tag pushed
and deleted proves tag pushes still work at all. The POSITIVE release-cut verification is DEFERRED
to the operator's next real cut: pushing a `v*` tag publishes the `:prod` image, which is a release,
not a verification step.
What this does not close, stated so the records are not cited as a boundary: `REGISTRY_PASSWORD`
stays in the Actions store for `build`, and head YAML can still name it, `RENOVATE_TOKEN` or
`SERVERMGMT_DEPLOY_KEY`. Blast radius, not the route.
New records `ci.pr-route-carries-no-stored-credential` and `release.tag-protection-v-star`;
`ci.workflow-dispatch-ref-unrestricted`, `ci.actions-credential-scoping` and
`release.main-direct-push-disabled` updated to match; catalog regenerated. Closes #885.
Decisions-Edit: yes
Proves: scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
b1d5fbefcb |
fix(916): cross-family state is per round, and the orchestrator reads cross_family before a verdict
A substitute that failed in round one said nothing about the tree that lands after round two, yet the flag was sticky and doomed the run; the xfamily string was never reset either, so clearing the stickiness alone would have let a stale "substitute ALSO failed" sentence into the PR body. Both reset at the top of review(). The harness runner is round-aware (ran per round, its own counter reset) and a two-round case pins the fix; restoring the sticky flag reddens it in both scripts. Step 4 of the mechanics page tells the referee to read cross_family, not only error, before posting on a rubric-class PR. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
595c819de1 |
fix(916): a failed substitute is an error, not a landed claim; head_sha described in both schemas; the issue's box says what the code does
The runner-beside-the-lenses design is now what Done-when box 1 asks for (body amended). A rubric round whose runner and worktree fallback both fail returns an error before the push instead of landing a PR whose body claims a substitute reviewed it. The harness records lens count at the runner's start too (expects 0, so a re-serialised runner reddens), resets its counter per round, and has a case for the double failure. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
929dff835a |
fix(916): the Codex runner runs beside the lenses, only the fallback waits; head_sha described everywhere; the docs keep their thresholds
The runner builds nothing, so serialising it only added its wait to the critical path; the worktree-isolated fallback is what must follow the lenses, and the harness case now records lens count at the FALLBACK's start alone. setTimeout in the harness is globalThis.setTimeout (the .mjs lint config has ES builtins only). head_sha carries the same description in both scripts and every fixer/implementer prompt asks for the worktree HEAD, not a PR head. The mechanics page says why the cap stays at one after the serialisation and restores the 20%/10% RAM thresholds by key; the record says "several", not "three". Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
f7a0da4051 |
fix(916): the cross-family review step follows the lenses instead of running beside them, and fix commits are a sha range
One .NET slot's review round ran two worktree-isolated reviewers at once — the correctness lens and, on a rubric change, the Codex fallback — and took swap from 6.8 GB to 10.8 GB in three minutes on the 16 GB host; three slots reached load 82. review() now awaits the lenses, then the Codex runner, then its fallback. The finisher's fix attribution is the sha range the fixer's report head advances (head_sha is required on every report), replacing a line-set difference over free text that listed all eleven #563 commits as fixes. The harness gains a case that records how many lenses were still in flight when the cross-family agents started (must be zero); moving the fallback back into the parallel batch reddens it in both scripts. The mechanics page and the standing prompt state the measured cap: one .NET-building slot at a time. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
4454f1adfc |
docs(563): anchor the measurements to the issue, which a squash merge cannot erase
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 12s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 22s
PR Gates / Docs update reminder (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 16s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
Review verdict / Set review-verdict status (pull_request_target) Successful in 20s
review-verdict/h10 Review-verdict: MERGEABLE @ 4454f1a (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m34s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 17m42s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
The paragraph added a commit ago pointed at "the commit messages that ran them" as the home of the binder and `trim` mutant outcomes. A squash merge writes its own message and drops the bodies it squashes, so that pointer can go stale the moment this branch lands. The issue and its pull request survive it, and #563 is where the round-by-round measurements already are. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
3df98d247f |
docs(563): state each binder test's mechanism instead of its mutant colour, per the amended CLAIMS rule
`testing.mutation-claims-are-executed` was amended on main while this branch was in review (#881, merged as #914): a sentence asserting that a specific mutation reddens — or does not redden — a named test is now either a `CLAIMS` entry in `scripts/tests/mutation_manifest.py` that executes every run, or it is not written. This branch carried six such sentences and none of them can be declared: `Claim.node_id` resolves a proof to `scripts/tests/<node id>` and `run_pytest` invokes pytest, so an NUnit proof has no representation in that harness at all. Durable prose now states the mechanism each test is built on — which serializer difference, which engine branch — which a reader re-checks by reading the code rather than by trusting a remembered outcome. The record says that in one paragraph, so the limit is stated rather than papered over. The outcomes themselves are here. Re-measured 2026-09-05 on this branch's tree (the commit before this one), each mutant applied to the working tree and restored from the index between runs, tree verified clean afterwards: positive control ScriptedScheduleControllerTests Passed: 9, Failed: 0 OpenApiSerializerContractTests Passed: 4, Failed: 0 Bind<T> -> System.Text.Json with JsonSerializerDefaults.Web Failed: 2, Passed: 7 — Production_Body_Binder_Ignores_Required_Members, Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null BodyBinderSettings = ApiJsonSettings.Create() -> new JsonSerializerSettings() Failed: 1, Passed: 8 — Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null OpenApiSerializerContractTests RuntimeSettings -> new JsonSerializerSettings() Failed: 4, Passed: 0 — all four cases, on PascalCase keys ScriptedScheduleController AddDuration(..., request.Trim, ...) -> false Failed: 1, Passed: 8 — Committed_Script_Fixture_Produces_The_Pinned_Snapshot ScriptedScheduleController PadUntilExact(..., request.Trim, ...) -> false Failed: 1, Passed: 8 — Committed_Script_Fixture_Produces_The_Pinned_Snapshot The last one is the round-two finding closed and re-witnessed: before the fixture's pad target moved off the content boundary, that mutant left all nine green. A squash merge writes its own message, so these figures also belong in the PR description. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
d6e4426aa9 |
docs(563): retire the forward-references the supersession falsified, and trim the record to what only it says
Two pointers still named #563 as the open reason scripted playout has no coverage. The ContentEnumeratorBuilderTests header now points at the successor record and docs/testing.md instead of the issue this branch closes. docs/decisions.md carried an orphaned fragment, "external-process pipeline remains #563's", in the residual block under ## Index -- with the pipeline now permanently outside the automated suite rather than deferred, the fragment states something false and has no recoverable subject to rewrite it around, so it goes. The record's rule gains the two things measurement settled: that ApiJsonSettings shares production's configuration and never MVC's settings object (MaxDepth 32, the two ProblemDetails converters, pinned by ApiJsonSettingsTests), and that a fixture must aim every trimming instruction between two content boundaries or that action's trim argument is witnessed by nothing. Its body loses the mutant table and the extraction paragraph, which the mechanics doc its own frontmatter points at carries verbatim; what remains is the conclusion plus the reasoning that exists nowhere else. 70 prose lines to 56, under the advisory ceiling without dropping a distinct finding. Refs #563 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
053982180d |
test(563): witness the trim flag on both trimming actions, and stop the binder claiming parity with MVC's
The fixture's pad_until_exact targeted 10:00, which the 15- and 30-minute items reached exactly, so the engine's trim branch never ran and mutating `engine.PadUntilExact(..., request.Trim, ...)` to `false` left every controller test green -- measured on the pre-change fixture, `Passed! - Failed: 0, Passed: 9`. The target moves to 09:55, off every content boundary: Movie 01 is now trimmed from 30 minutes to 25, the snapshot is re-pinned around it, and the same mutant fails Committed_Script_Fixture_Produces_The_Pinned_Snapshot while the sibling add_duration mutant still does. The trimmed span and OutPoint are asserted directly rather than resting on the snapshot alone, and the fixture and the snapshot comment both record that landing a trimming instruction on a content boundary is what silences its trim flag. ApiJsonSettings.Create() was documented as a standalone serializer configured the way MVC's is, which measurement refutes: Apply runs against a bare JsonSerializerSettings rather than the one MvcNewtonsoftJsonOptions pre-configures, so MaxDepth stays at Newtonsoft's 64 instead of MVC's 32 and ProblemDetailsConverter and ValidationProblemDetailsConverter are absent (MissingMemberHandling, TypeNameHandling and DateParseHandling do match). Neither gap can reach a scripted request body -- two levels of nesting, never a ProblemDetails -- so this was overstated prose, not a broken test. Restating the delta everywhere parity was claimed would leave four copies to rot, so ApiJsonSettingsTests pins it in both directions and the prose points at the pin. Also clears the three nullable warnings the replayer helpers introduced (CS8600/CS8604 on the action string, CS8603 on Bind<T>) and corrects the ExpectedSnapshot comment, whose last column is built from MediaItemId rather than looked up from the seeded title. Refs #563 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
c0a70d724a |
test(563): witness the Newtonsoft half of the binder claim, and narrow the prose to what reddens
The branch asserted that binding fixture bodies through ApiJsonSettings makes "a swap to a
lookalike serializer" redden. Measured, only half of that was true: replacing
ScriptedScheduleControllerTests' BodyBinderSettings with a plain `new JsonSerializerSettings()`
-- a Newtonsoft lookalike that has lost the production configuration -- left all 8 tests green.
Only the System.Text.Json swap reddened. So the production edits the branch makes for that
coupling (ErsatzTV/Serialization/ApiJsonSettings.cs and the Startup rewrite) were justified in
four places by a hazard no test could see.
Both halves are now real. Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null
binds `{"order": null}` and posts it to AddCollection: NullValueHandling.Ignore keeps
ContentCollection.Order at its declared "shuffle" and the call is a 200, where Newtonsoft's own
Include default writes the null through and AddCollection's Enum.TryParse returns a 400. That is
a behaviour difference a script would see, not a settings-shape assertion, so it is not a second
copy of the settings list.
Mutants, run 2026-09-05 over the 9-test fixture:
Bind -> System.Text.Json web defaults 2 red
BodyBinderSettings -> new() 1 red (was 0 before this commit)
OpenApi RuntimeSettings -> new() 4 red (write side, naming strategy)
What still nothing observes is Startup.ConfigureServices itself: re-inlining the
AddNewtonsoftJson lambda as a hand-copy of Apply reddens no test, because a byte-equal mirror is
behaviourally indistinguishable. ApiJsonSettings removes the duplicate rather than detecting its
drift, and docs/testing.md, the decision record and all four docstrings now say that instead of
claiming a detector. Drift confined to ReferenceLoopHandling or the StringEnumConverter is
witnessed by neither suite; that is stated rather than left implied.
Also files the 401 blind spot the record had described as "tracked separately" while nothing
tracked it. ersatztv#913 records the chain, verified from source: the filter is registered
globally, EndpointRequiresKey fail-closes every mutating verb, ScriptedScheduleController carries
no [SkipApiAuthorization], and neither ScriptedPlayoutBuilder nor entrypoint.py supplies a
credential.
Refs ersatztv#563 and ersatztv#913.
Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
4bd5cf9e91 |
docs(563): scope the required-member claim to the serializer, and name model validation as residue
The previous commit said a body omitting a `required` member "reaches the action" in production. That overreaches what was measured: MVC adds an implicit required check for non-nullable reference types (ErsatzTV.Core.Nullable has <Nullable>enable</Nullable>, and Startup configures no ApiBehaviorOptions, so the [ApiController] automatic 400 is live), which would very likely reject that body before the action. What is measured is the SERIALIZER: Newtonsoft deserializes it to a default, System.Text.Json throws. The prose in the test, ApiJsonSettings, the record and docs/testing.md now stops there and puts MVC model validation on the uncovered-wrapper list where it belongs. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
16a6e2790f |
test(563): bind the fixture with the production body binder, and name the wrapper that is left
The docstring, the decision record and docs/testing.md all claimed the replay covered everything
except two hops. MVC model binding was a third: production binds /api/* bodies with Newtonsoft
(Startup -> AddNewtonsoftJson -> CustomContractResolver + StringEnumConverter) while the replay
deserialized with System.Text.Json. Measured on this tree: for the fixture's own bodies the two
agree, but for a body omitting the `required` member "collection" they diverge -- System.Text.Json
throws, Newtonsoft binds Collection = null and the action runs. So the fixture's stated purpose
("field names and casing match what the HTTP body binder accepts") was asserted by nothing, and a
fixture production would bind differently could still go green.
Rather than only widening the residue list, bind the way production binds. The registration moves
into ErsatzTV/Serialization/ApiJsonSettings.cs, Startup applies it from there, and both
OpenApiSerializerContractTests (which had its own mirror of the settings) and the scripted replay
now call that same function -- one definition, no copies to drift.
Production_Body_Binder_Ignores_Required_Members asserts both halves of the divergence THROUGH the
replay's own Bind helper, so pointing the replayer at another serializer reddens; the fixture's own
bodies cannot witness that swap.
The residue is now named honestly in all four places: the binding WRAPPER (input formatter, the
[ApiController] automatic 400 before an action runs) is uncovered, the serializer inside it is not.
Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
0d2cd89782 |
docs(563): supersede the scripted-golden deferral with the in-process coverage rule
The deferral record described ScriptedScheduleController as a "1:1 pass-through" to SchedulingEngine. It is not: an unparseable playback order is a 400, an unparseable filler kind SILENTLY degrades to FillerKind.None, an unknown build id is a 404, and the engine's no-progress InvalidOperationException is translated to a 400. Carrying that wording forward would have shipped a false statement, so the successor states a thin adapter with named mappings, each pinned by a test. - new record testing.scripted-engine-in-process-net (active, since 2026-09-05) - predecessor testing.scripted-playout-golden-deferred git mv'd to docs/decisions/archive/testing/ with frontmatter retargeted only; body prose byte-identical, so no Decisions-Edit trailer - docs/decisions.md Index line retargeted to the archive path plus a new dated line for the successor - catalog regenerated with scripts/build_decisions_catalog.py - docs/testing.md: the Golden-file nets paragraph now points at the new coverage instead of "tracked in ersatztv#563"; a new "Scripted playout coverage" section states what is covered where and what is deliberately not covered (Cli.Wrap launch, Kestrel + Startup middleware, ApiAuthorizationFilter), dated 2026-09-05; Timezone independence records the per-call TZ audit that decided which engine instructions the fixtures may use. Refs #563 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
0f8e73ab05 |
fix(881): the replacement clause asserted an absolute I had not measured
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 17s
PR Gates / decisions lifecycle (pull_request) Successful in 23s
PR Gates / Docs update reminder (pull_request) Successful in 14s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 11s
Review verdict / Set review-verdict status (pull_request_target) Successful in 37s
review-verdict/h10 Review-verdict: MERGEABLE @ 0f8e73a (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m41s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m47s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
The previous commit replaced "the order is load-bearing" with "and that is the whole of what the order buys". What was measured is narrower: with the relevance gate moved first, the three gates are each still witnessed refusing alone. That does not establish the order buys nothing else - the reset placement is a second candidate, unmeasured either way - so the absolute is gone from both sites and what stays is the cost reason, which is readable from the control flow. refs #881 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
f34828806d |
fix(881): the gate order I called load-bearing is a cost choice, measured
`verify_claim`'s docstring and the record's `mechanics` both said the relevance
gate must run LAST or the status and vacuity gates could never be witnessed
failing alone. Executed at
|
||
|
|
91a9fbf81a |
docs(881): name the reach mutation the way the rule field names it
The residual paragraph called it "a stronger mutation", which is a judgement about size; what the mechanism requires is a second mutation of the same clause, declared and required to redden the proof. One vocabulary across the rule field, the manifest and the body, so a reader does not have to decide whether two descriptions are the same thing. Refs #881 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
a956cc6b8d |
fix(881): the same outcome claim, restated in the proof's own docstring
The claim the shipped GREEN entry binds — what removing the `+++ /dev/null` arm does — was written twice: in `check-doc-narrative.py`, where the entry binds it, and again in `test_a_DELETED_doc_is_not_reported_as_added_content`'s docstring, where nothing does. That is the copy-of-an-outcome shape this rule forbids, in a site class the rule names, found while reading the proof for the residual below. The docstring now points at the manifest entry and keeps its rationale (a deletion yields no `+` lines either way), which is the half the carve-out protects. The residual paragraph is also made exact rather than general. The reach mutation proves the proof depends on the clause through the `b/` stripping every scanned header goes through, not through the `/dev/null` arm itself, so in general such a green cannot separate "no test feeds that input" from "the arm changes nothing". For this entry it can, by reading the proof: the deleted-doc test deletes a tracked file, and a deletion diff under the flags `run_diff` pins carries a `+++ /dev/null` header — probed rather than reasoned. Refs #881 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
a9341d8415 |
fix(881): a GREEN claim is only readable if its proof REACHES the clause
Round three found the one half of the new mechanism with no relevance gate. `verify_claim`'s GREEN path read exactly two things — the run exited 0, and something PASSED — and both are satisfied by a proof that never touches the mutated file at all. Reproduced before fixing: retargeting the shipped GREEN entry's proof from `test_check_doc_narrative.py` to `test_bom_guard_detection.py` changed nothing, and the entry still reported verified. The RED direction never had this hole, because a proof that ignores the mutation stays green and is refused as "the clause is not load-bearing". So a GREEN entry now declares a `reach_replacement` and its `reach_expect`: a SECOND mutation of the SAME clause, required to REDDEN the same proof, executed through `verify_mutation` so its red is read through the diagnostic gate rather than on exit status. The shipped entry declares `path = p` — dropping the `b/` stripping every scanned diff header goes through — and the run then scans NOTHING, which is what the declared diagnostic reads. The same retarget now fails, naming the reach verdict. The gate runs LAST of the three: run first it would refuse before the status and vacuity gates were read and neither could be witnessed failing alone (#685), and the sandbox is reset between a claim's two proof runs for the reason it is reset between mutations. It has its own disarm proof, and the two synthetic claim sandboxes are now real git repositories so `reset_sandbox` has a baseline; `_lib_with` shares the baseline registry, since a copied module's own starts empty. Also from that round: - The record no longer counts the mutation-outcome claims in the pinned proposal-3 scan. A third of the same shape sits in the same result set (`test_a_verdict_BEYOND_A_SHORT_PAGE_is_still_found`), and which side of the line a sentence falls on is a judgement, so an exact count is a figure the next reader re-derives differently — the failure this record is about. - The calibration paragraph no longer restates the post-review-verdict outcome as a dated witnessing. It points at the `CLAIMS` entry that executes it, which is the form the rewritten shell comment beside it demands. - The comment in `check-doc-narrative.py` claimed a universal ("reddens no test") while one file is executed. It now names that file, so the quote binds an outcome no wider than what is checked. - Proposal 4 from the issue is dispositioned explicitly: rejected as a rule here, on the issue's own argument that an exhortation does not fire at the moment of least slack. - `docs/README.md`'s task-signal parenthetical now names the `CLAIMS` population; the file was owned by another slot when this branch started. Cost re-measured 2026-09-05, three baseline/branch pairs: the `CLAIMS` half adds 31.7-43.6%, up from the 12.7-16.6% measured before the gate existed. The old figure is retired rather than scaled — growing the population invalidates the measurement that described it. Refs #881 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
7dba36cd58 |
fix(881): my own paragraph asserted the scan found no mutation-outcome claims - it found two
Self-review of the previous commit, on the same class it fixes. "none of them asserts a mutation outcome", written about the 58 lines the proposal-3 scan returns, is false. Two do: the mutation table at `docs/decisions/records/ffmpeg/watermark-resolution-unified.md` line 104, which names a dropped discriminator and the single test that catches it, and `web/src/screens/AutoTuneScreen.test.tsx` line 174, which says what a revert to the old flex row can redden. Both read in full at `efadbec29` rather than from the truncated grep line - the truncation is how the first pass missed them. Three kinds of sentence under one pattern is a better argument than the one the false claim was making: it is not that the pattern finds only rationale, it is that it finds rationale, state anchors and mutation-outcome claims side by side and nothing in the string tells them apart. Two smaller ones in the same commit. The new test's docstring said the gate is "the one gate the others cannot cover" and the manifest said "the one PRE-FLIGHT refusal a red proof cannot be told apart from" - both assert uniqueness among the pre-flight refusals that neither measured, and a clause occurring zero times also leaves the text identical. Narrowed to what the mutant demonstrates: no later gate stands in for it. And the fixture comment glossed `verify_claim`'s GREEN refusal in quote marks, which under this record's own proposal-2 clause reads as a quotation of the library; it is not one, so the marks are gone. refs #881 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
99966f8bd7 |
fix(881): a control's purpose stated backwards, and an enumeration that grew past what it could carry
Four review findings, all of the class this branch is about - prose asserting what a mechanism does, with nothing binding it to the mechanism. The record's `mechanics:` said a GREEN claim over an already-red proof "would otherwise be satisfied by the redness it is supposed to rule out". Inverted: `verify_claim`'s GREEN branch REFUSES any non-zero exit, so redness refutes a GREEN claim and can never satisfy one - which is what test_MUTATION_disarming_the_GREEN_EXIT_STATUS_gate_accepts_a_proof_that_WENT_RED asserts. The hazard the control removes is the same one it removes for the rows, and it runs in both directions: an already-red proof satisfies a RED claim with redness its mutation did not cause, and refuses a GREEN one for a reason unrelated to its mutation. Both the record and the fixture comment now say that, and both say what the control CANNOT do - its assertions are over the aggregate of every proof ref, so a single ref collecting nothing is invisible to it and is caught per-claim by the vacuity gate instead. The manifest's `why` had widened a scoped sentence into "THE OTHER GATES EACH CARRY THEIR OWN PROOF" and then enumerated them, which made the enumeration a completeness claim it could not meet: the identical-replacement refusal carried no proof at all. The review measured that at 8adf21eff - `if mutated == original:` disarmed, whole file 49 passed 1 skipped. That gate is the one a red proof cannot be told apart from: the mutant is byte-identical, so the proof runs against the original tree and an already-red one reddens exactly like a detection. Disarmed, the harness certifies it as "the named test went red under the declared mutation, with the declared diagnostic" - witnessed here on the real library, restored after. So the measurement above no longer holds, by construction: the gate now has a disarm proof, and the sentence says explicitly that naming the gates is not a claim the list is closed. Proposal 3's rejection quoted "16 lines" with no predicate - the defect the record's own body names three paragraphs later, where the population scan is pinned verbatim for exactly that reason. The figure is not reproducible from the text. Replaced by a pinned `git grep` over the same corpus at the same sha (`32 files, 58 lines`), with what reading all 58 shows: they are rationale, the class the rule carves out, and the few real state anchors among them are not separable by pattern, because the difference is whether the sentence explains or asserts. refs #881 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
9e0e11030b |
docs(881): the third quotation on this branch that resolves to nothing
`docs/defect-shapes-773.md` §4's sentence was quoted verbatim-looking but with a lowered initial capital, and the source wraps it across a line at `evidence`/`behind`, so neither the written form nor the corrected one is findable by grep. Same treatment as the `post-review-verdict.sh` one: paraphrase without quote marks, keep the section reference, say why. The section reference itself was checked - the sentence is at line 351, under `## 4. Detectors`. refs #881 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
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
|