868ab657d8ecd2e0874c91b502f4e2b7aa1411cb
211
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4c2ceccaf7 |
docs(901): carry both rejected alternatives in the rule, not only in the body
The catalog renders `rule:` alone, so a reader resolving this by topic saw the two decisions without the alternatives they rejected — which is what stops a rejected option being re-proposed on plausibility. 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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |