929-vuln-pkcs-imagesharp-v4
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
71d660f213 |
test(929): the ICC carrier list includes BMP and is checked against a round trip of every configured format
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 11s
PR Gates / decisions lifecycle (pull_request) Successful in 14s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 10s
Review verdict / Set review-verdict status (pull_request_target) Successful in 16s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 12m42s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m56s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
review-verdict/h10 Review-verdict: MERGEABLE @ 71d660f (base: main)
Round 4 review found two gaps in the gwg2 runtime pin. The carrier list (png, jpeg, webp) was hand-written and left out BMP, whose 3.1.12 decoder also constructs an IccProfile (BmpDecoderCore::ReadInfoHeader) and which remote-URL and on-disk graphics can reach. And the record said every hostile-ICC case asserts the profile is still attached, while the WatermarkElement case asserts only allocation (its images are private). - IccCarriers is now bmp, jpeg, png, webp, with a BmpEncoder arm. - Icc_Carriers_Are_Every_Configured_Format_That_Round_Trips_A_Profile round-trips the hostile profile through each format of ImageSharpDecoding.Configuration via its default encoder and requires the formats that come back carrying a profile to equal IccCarriers. Witnessed red by dropping "bmp" from the list. - A Cecil scan of the 3.1.12 package (2026-10-28) lists the decoders that construct an IccProfile as BMP, JPEG, PNG, WebP and TIFF, so the round trip's blind spot (a decoder reading a profile its encoder never writes) is empty today; the record says so, dated. - The record and the element test's comment now say only the validator cases assert attachment; the element case shares their fixture. Measured: product path 38-94 KiB, element 368-834 KiB, Entries control about 129,545 KiB for all four carriers. Decisions-Edit: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1 |
||
|
|
bed50e2b77 |
test(929): pin by allocation that decode and clone do not parse a hostile ICC profile
Round-3 review: the record said every bound is enforced over compiled code, but half of the gwg2 (ICC CLUT) bound is ImageSharp 3.1.12's own runtime behaviour, which the IL scan cannot see. A 208-byte ICC profile declaring an mft2 CLUT (3 in, 15 out, 255 grid points), carried in PNG, JPEG and WebP, now goes through RemoteImageValidator.DecodeAndValidate + CloneFrame/Resize/CloneAs and through a real remote WatermarkElement. Each case asserts the profile is still attached and fails above 16 MiB allocated. The positive control reads Entries on the same profile and must allocate over 64 MiB. Measured 2026-10-28 on 3.1.12: the product paths allocate 38-94 KiB and the element 322-390 KiB; Entries allocates about 129,550 KiB. Mutation witnesses: a try/catch read of IccProfile.Entries injected after the validator's post-decode check reddens all 6 product-path cases on the allocation assertion. The same read after ImageElementBase's per-frame Resize reddens the 3 element cases. Both were restored from git. The record's gwg2 row and 'What holds the bounds' now name the two halves and which test holds each. docs/testing.md names the ICC cases. Decisions-Edit: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1 |
||
|
|
59749babd9 |
test(929): only ImageSharpDecoding may choose the decode configuration, and the test map names the IL scan
Review found that the surface guard checked that every decode takes
DecoderOptions and that only ImageSharpDecoding constructs them, but not
that the TIFF-blind configuration is the one that picks the decoder. A
new rule rejects, outside ImageSharpDecoding, a DecoderOptions
Configuration assignment, a read of Configuration.Default, and any
ImageFormatManager reference (a decoder fetched from a registry decodes
its own format whatever the options carry).
Mutation sweep against ImageElementBase.LoadImage, restored from git
after each:
- Configuration.Default.ImageFormatsManager.GetDecoder(...).DecodeAsync(
ImageSharpDecoding.Options(), ...): only the new rule reds (get_Default,
get_ImageFormatsManager, GetDecoder sites).
- ImageSharpDecoding.Configuration.ImageFormatsManager.GetDecoder(...):
only the new rule reds (ImageFormatManager clause alone).
- new DecoderOptions { Configuration = ImageSharpDecoding.Configuration }:
the new rule reds on set_Configuration and the construction rule on .ctor.
- options.Configuration = ... on a returned instance does not compile
(CS8852: init-only), so the reviewer's reassignment shape is not
expressible in C#; the IL rule still covers the setter.
docs/testing.md: the Architecture.Tests row now names all three rule
families (layering, provider-statics wiring, the Mono.Cecil ImageSharp
scan) and the host reference, and drops the stale exact count; the
missing ErsatzTV.Infrastructure.Tests row is added, since this branch
adds ImageSharpDecodingTests there. The record and guard inventory name
the fourth shape and what an IL scan cannot see.
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1
|
||
|
|
f30a54172b |
docs(929): the record names what holds each bound, and Renovate is held
security.imagesharp-3x-tiff-decoder-disabled now says the bounds are
enforced by ImageSharpSurfaceTests rather than by its own prose. The
"Load pass is masked" claim is dropped from the record and from the two
code comments that repeated it: with the architecture pin, reverting the
Load pass's options alone now reddens
Only_ImageSharpDecoding_constructs_DecoderOptions (witnessed
2026-10-27), so the claim had become false. The residual it cannot see,
an extension-dispatched Save(path), is named.
Renovate: a packageRule holds SixLabors.ImageSharp on 3.1.x. The
existing "range pins are respected" sentence in ci-cd.md and the EF
group's description were wrong: Dependency Dashboard #22 offers EF Core
v10 and MediatR 14.2.0 over their range pins, so a range does not hold
Renovate. Whether the rule holds the OSV security PR (#923) cannot be
measured before merge, and the record says so.
guard-inventory.md's hand-written C# guard list gains the new fixture
and its two Architecture.Tests siblings, and stops quoting a count.
This branch also edits the rationale prose of
ffmpeg.remote-image-fetcher-bounded (commit
|
||
|
|
52c383e832 |
test(929): pin the ImageSharp call surface the 3.1.12 hold depends on
The bounds in security.imagesharp-3x-tiff-decoder-disabled were prose: they held only while every decode passed ImageSharpDecoding's options and nothing touched ICC entries, the TIFF codec or histogram equalization. A new decode site or accessor would have reopened an advisory with no test going red. ImageSharpSurfaceTests reads the IL of every production assembly that can reach ImageSharp (derived from ErsatzTV.sln and the csproj graph, so a project gaining the reference joins the population) and fails on a decode entry point without DecoderOptions, a DecoderOptions built outside ImageSharpDecoding, or any reference into the TIFF, ICC or Normalization namespaces or a Tiff/HistogramEqualization member. The host joins Architecture.Tests' references so it is scanned too. The ImageSharp pin now carries a comment naming the hold and its record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1 |
||
|
|
e96e47a9d8 |
docs(929): record why ImageSharp stays on 3.1.12 and what bounds each advisory
New record security.imagesharp-3x-tiff-decoder-disabled: the license-key build gate in every patched release (measured on 3.2.0 and 4.1.3 Release builds), the per-advisory reachability table, the masked Load-pass clause (measured: reverting it alone leaves the suite green), that dotnet list --vulnerable ignores NuGetAuditSuppress (measured), and the revisit trigger. ci-cd.md Dependency management records the Pkcs direct pin, the rejected central transitive pin (NU1109), and that the weekly scan is red by design on ImageSharp until then. The #511 record's MaxFrames measurement is dated to 3.1.12 and no longer lists TIFF as in play. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1 |
||
|
|
9eed552876 |
fix(929): pin System.Security.Cryptography.Pkcs 9.0.21 and stop ImageSharp 3.1.12 decoding TIFF
The Pkcs 9.0.4 that EFCore.BulkExtensions' Oracle/SqlServer providers pull transitively carries GHSA-846f-f764-76mx (High) and GHSA-2fmv-rv2c-59x7. It is overridden by a direct reference in ErsatzTV.Infrastructure, the existing pattern (Microsoft.OpenApi, SQLitePCLRaw). The central transitive pin the issue suggested was measured and rejected: enabling CentralPackageTransitivePinningEnabled fails restore with NU1109 in every project, because Microsoft.Extensions.DependencyModel is exact-pinned [8.0.2] below the >= 9.0.x EF Core requires. ImageSharp stays on 3.1.12. Every patched release (3.2.0, 4.1.2, 4.1.3) ships a build-time Six Labors license check that fails a Release build without a key (measured: dotnet build -c Release errors 'No Six Labors license found'), and CI and the image build Release. Of the six 3.1.12 advisories now listed, the two DECODER ones are TIFF; every ImageSharp decode now goes through a configuration that cannot detect TIFF. The rest are an encoder, a processor and an accessor ErsatzTV never calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1 |
||
|
|
bcf6f7e04d |
Merge pull request 'fix(935): script-tests jq tripwire accepts {1.6, 1.7} until server-management#1080' (#938) from 935-jq-repin-17 into main
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 11s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 30s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 11s
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 5m27s
|
||
|
|
403bf011c1 |
fix(935): repoint the superseded jq key, re-wrap the preflight header, reword the floor note
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 32s
PR Gates / Docs update reminder (pull_request) Successful in 37s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 46s
PR Gates / decisions lifecycle (pull_request) Successful in 45s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 11s
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 8s
Review verdict / Set review-verdict status (pull_request_target) Successful in 13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m22s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 12m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m26s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
review-verdict/h10 Review-verdict: MERGEABLE @ 403bf01 (base: main)
ci.jq-version-contract is archived; verdict-write-retarget-fence.md and defect-shapes-773.md now name its successor ci.jq-accepted-version-set. jq-preflight.sh: the two ragged header paragraphs are re-wrapped, and the MIN_VERSION note names the runner fleet and server-management#1080 instead of a single CI runner. Decisions-Edit: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1 |
||
|
|
00424bf090 |
fix(935): pin the run: key in the wiring test, drop a dangling record key, reword a stale runner claim
Decisions-Edit: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1 |
||
|
|
8acba80a1a |
fix(935): state the measured fact (both hosts 1.7 on 2026-10-28), not an unmeasured mixed fleet
The fleet is uniform on jq 1.7 per the job logs; 1.6 stays in the set only because the image tag is unpinned. Real-jq 1.6 coverage is currently absent, and the shims cover only the empty-input -e quirk, not the NUL or parse-error ones. Measurement dated by the job date. Remaining "runner's jq 1.6" comments reworded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1 |
||
|
|
41d604f0ae |
fix(935): script-tests jq tripwire accepts exactly {1.6, 1.7} until server-management#1080 pins the fleet
jq-preflight.sh --expect takes a comma-separated set (validated whole, exact major.minor membership); pr-checks.yml passes 1.6,1.7. Both small runners float on runner-images:ubuntu-latest, so the fleet's jq is a function of pull history (1.7 measured on PR #933 job 12347 and PR #934 job 12360, 2026-10-29). New record ci.jq-accepted-version-set supersedes ci.jq-version-contract (archived); docs/ci-cd.md, the script header and runner-ships-1.6 comments now state the dated invariant. review-verdict.yml stays floor-only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1 |
||
|
|
e919b925f8 |
Merge pull request 'docs(release): record the v26.16.0 release notes' (#930) from release/v26.16.0-notes into main
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 14s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 11s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 10s
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 8s
|
||
|
|
e61bbb63b5 |
docs(ci-cd): the security scan names its real host, and a skipped semgrep pass is not a passed one
PR Gates / Docs update reminder (pull_request) Successful in 17s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
review-verdict/h10 Exempt: docs-only change (no code, no protected path)
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 16s
Review verdict / Set review-verdict status (pull_request_target) Successful in 21s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 16m14s
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 12s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 15s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 25s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: 99d1dedb-f027-4e05-baab-2111f3a7d951 |
||
|
|
e458630f48 |
docs(release): v26.16.0 notes record the .NET 10.0.14 runtime and the release scan
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 15s
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Canceled after 30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Canceled after 0s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 0s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Canceled after 32s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Canceled after 0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
PR Gates / Docs update reminder (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Canceled after 17s
PR Gates / CI image pin matches docker/ci (pull_request) Canceled after 1s
PR Gates / Fix proofs (Proves trailers) (pull_request) Canceled after 16s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Canceled after 14s
review-verdict/h10 Awaiting review verdict for e458630
Review verdict / Set review-verdict status (pull_request_target) Successful in 40s
The tag build picked up MCR's 10.0.14 aspnet/sdk images (published 2026-10-26), measured inside :26.16.0. ZAP and semgrep results are recorded as triaged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: 99d1dedb-f027-4e05-baab-2111f3a7d951 |
||
|
|
bdd4ed7bad |
docs(release): the v26.16.0 cut verifies the positive half of the v* tag protection
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 19s
PR Gates / Docs update reminder (pull_request) Successful in 10s
PR Gates / decisions lifecycle (pull_request) Successful in 14s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 11s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Failing after 10s
review-verdict/h10 Exempt: docs-only change (no code, no protected path)
Review verdict / Set review-verdict status (pull_request_target) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 10s
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
release.tag-protection-v-star deferred its positive verification to the next real cut. The v26.16.0 tag push as `timothy` succeeded on 2026-10-27, and GET tag_protections the same day showed rule id 1 unchanged since 2026-09-04, so the push ran with the rule in force. The record and both ci-cd.md caveats now say so. That a renovate push is refused remains unverified. Decisions-Edit: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: 99d1dedb-f027-4e05-baab-2111f3a7d951 |
||
|
|
02aa05d75b |
docs(release): record the v26.16.0 release notes
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 15s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 16s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 23s
PR Gates / Docs update reminder (pull_request) Successful in 18s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
review-verdict/h10 Exempt: docs-only change (no code, no protected path)
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 23s
Review verdict / Set review-verdict status (pull_request_target) Successful in 29s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 17m4s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 8s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Range 736649b3b..1f7b763a7 (v26.15.0 was cut at
|
||
|
|
1f7b763a7b |
fix(924): media-server music videos read their statistics from the server's playback info (#928)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 8m19s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Skipped
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 45s
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 & test (.NET) (push) Successful in 9m22s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m30s
Jellyfin's library-items projection builds every music video's MediaVersion with Streams = [] and no width/height; the server reports those only through per-item PlaybackInfo. MediaServerMusicVideoLibraryScanner.UpdateStatistics wrote that empty projection straight through (also removing any streams a stored row already had), so every Jellyfin music video was stored without streams and FFmpegStreamSelector.SelectVideoStream threw "Sequence contains no matching element" on play. Prod 2026-10-21: the Jellyfin "Music Videos" library (id 16), 1520/1520 versions with zero MediaStream rows. - UpdateStatistics mirrors the movie scanner: on deep scan, new item, changed etag or no stored streams it asks the abstract GetMediaServerStatistics, and writes nothing when the server cannot answer. - JellyfinMusicVideoLibraryScanner implements it via GetPlaybackInfo; chapters come from the incoming projection. - SelectVideoStream fails with the version id, file and remediation (deep scan, or remove a file that has no video stream) instead of a bare First() throw. - Other-video and TV scanners checked: no such gap. - Record scan.mediaserver-statistics-from-server. Existing rows heal only on a DEEP scan (a regular scan skips unchanged etags): POST /api/v1/libraries/16/scan?deep=true after deploy. fixes #924 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: 99d1dedb-f027-4e05-baab-2111f3a7d951 Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
b8ea62bfa0 |
fix(568): reject unknown channel graphicsElementIds with 422 and discriminate builtIn by the seeded path, not the filename (#922)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 11s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 17s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 13s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 12s
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 4m12s
fixes #568 refs #74, #917, #921 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
fcdc381246 |
docs(568): the rule the branch establishes gets a record, and every row the fix moved is re-measured
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 7s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / decisions lifecycle (pull_request) Successful in 18s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 12s
Review verdict / Set review-verdict status (pull_request_target) Successful in 26s
review-verdict/h10 Review-verdict: MERGEABLE @ fcdc381 (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m23s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 17m33s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
api-conventions.md now says which half of an id-list validator a sibling field may gate (existence, never the raw-count cap) and that a lost-race recovery re-asks the whole validator set rather than the fields whoever wrote the catch remembered. Those, with the bound and the field-named 422, are one convention with residuals, so they get a record -- api.top-level-id-list-validation -- and a task-signal row. The record states what #568 does NOT settle: three validators on two DTOs is a per-field constant, not the repo-wide rule #917 owns, and it says to expect #917 to replace the mechanism. graphics-elements.md: rows 35-44 re-measured against the whole ErsatzTV.Tests project on this tree, because the fix moved five of their red sets -- Validate is now also what the recovery path re-runs, so removing a validator from it reddens that handler's race test too. Rows 45-47 are new and measured the same way. The "redden more than one test" figure is recounted from the table (21 -> 24); the cross-fixture set is unchanged at five. The negative discriminator rows now carry a stated seeding rule: vary one half of the identity and hold the other at the seeded value. Varying both leaves the row rejected by the pre-#568 predicate as well, so a composite revert to it would pass every test at that site. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
9fc54fed87 |
fix(568): the raw-count cap is the request's bound, and a recovery path re-asks the whole validator set
Two holes the review round found in the previous fix, both of the same shape: a guard that names its own fields instead of deriving them. The deco validators short-circuited the entire Validators.IdsMustExist call when the DecoMode does not consume the ids, which took the 512-item raw-count cap with it -- an arbitrarily large array under Inherit/Disable parsed and materialized with nothing bounding it. Only the EXISTENCE half is the apply path's business, so the mode predicate is now a required argument of the shared validator and gates that half alone; the cap runs under every mode. The channel recovery path rechecked GraphicsElementIdsMustExist alone, so a watermark deleted between validation and SaveChangesAsync still surfaced as the unhandled 500 the fix exists to remove -- WatermarkId, FFmpegProfileId, FallbackFillerId and MirrorSourceChannelId are all written by the same save and lose the same race. Both handlers now re-ask the whole of Validate on DbUpdateException, so a validator added later is covered without editing the recovery path. The API-site outside-folder discriminator test seeded an Image row, so the Kind conjunct rejected it whatever the path comparison did: a composite revert to Path.GetFileName(path) == filename && kind == Text passed every API test. It now carries the seeded Kind, mirroring the seeder-site twin, so only the path half can reject it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
3541683c99 |
docs(568): re-measure every mutation row the merged identity and the shared validator moved
Nine rows of the mutation table in docs/graphics-elements.md name a clause that this branch's last commit moved, merged or gave new callers, and a red set is a measurement of the tree it ships in. All were re-taken against the whole ErsatzTV.Tests project (2139 tests, 6 skipped) on the code as it now stands, and four new rows added for the clauses the fix introduced. What moved and why the numbers changed: - Row 10 was the seeded-path filter alone. `Kind` now lives inside `IsOnNowNext`, so dropping the lookup's `Where` drops both halves at once and reddens four tests, not three. - Row 18 was the seeder's SQL `Kind == Text` filter and is now the `kind` conjunct of the shared predicate, so it reddens the API site too — a second cross-fixture row. - Rows 35-37 pick up the count-cap tests, since the cap rides in the validator they disarm. Rows 33, 34, 38, 39 re-measured unchanged. - Row 40's mutation text follows the API call's new two-argument shape; it reddens the new wrong-kind test as well. - Rows 41-44 are the new clauses: the raw-count cap (one clause, three call sites, which is what its red set shows), the diagnostic-id truncation, and the two lost-race catches. The two self-counted figures above the table were recounted from the table itself rather than adjusted: twenty-one multi-test rows and five cross-fixture ones (13, 18, 22, 33, 41). The deco lost-race test is renamed so no two rows cite the same test name. api-conventions.md gains the three rules the fix establishes for any write path with a top-level FK id list — bound the raw list, name the field, translate a lost check-then-write race — in the handler-hardening checklist where they belong rather than as a #568 anecdote. Refs #568 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
e7f794057d |
fix(568): bound the client-supplied id lists, name their field, and make Kind part of the built-in identity
Three of the four findings standing on the 2026-09-05 16:24 review verdict, which the branch had not answered. The count cap is the blocking one. The three id-list validators took whatever the request carried, so the only bound on `graphicsElementIds`/`watermarkIds` was the Kestrel body cap -- a transport limit, not a collection limit. The earlier disposition deferred it to #917 on the grounds that `ApplyUpdateRequest` reconciles the same list uncapped anyway; that is true and does not answer the ask, because the reconcile is downstream of a validator that can refuse the request outright. One shared `Validators.IdsMustExist` now carries the cap for all three, counted on the RAW list before `Distinct` (a million copies of one id costs the same to parse and materialize whatever the distinct count is) and before any database work. The same helper is where the field name and the diagnostic cap now live. The 422 said "Graphics element(s) do not exist: 999" without naming which request field carried the 999, and echoed every rejected id -- an oversized request answered with an oversized response. Both fixed once, in the shared place, so the three sites cannot drift. `Kind` moves into `GraphicsElementDefaults.IsOnNowNext`. The seeder required `Kind == Text` and the API's `builtIn` did not, so an Image row at the exact seeded path was `builtIn:true` on the wire while `GetBuiltInElementId` refused to treat it as the built-in element -- two sites disagreeing about one row, which is the shape #568 exists to close. Identity is now one predicate applied whole at both sites; the seeder's SQL `Kind` filter is gone rather than kept as a duplicate, since a duplicate guard would mask the predicate's own clause. Also the fourth finding, the check-then-write race: `RefreshGraphicsElements` can delete a validated element between `Validate` and `SaveChangesAsync`, handing the join insert the FK violation the validator exists to prevent. A transaction does not close it -- neither provider locks rows the validator merely read -- so both handlers catch `DbUpdateException`, re-ask the existence question on a fresh context, and return the validator's own 422 when an id has since gone; anything else keeps its own exception. Foreign keys are off in `InMemoryTvContext`, so the trigger is simulated by an armed save-failure interceptor while the recovery itself runs against real post-delete state. Refs #568 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
722f0057e8 |
docs(568): bound the "cannot fail" claim to the two rows that measure it
The sentence explaining why the wrong-kind-and-wrong-folder case is not shipped said "no single-clause mutation can let it through" — an unbounded quantifier over a population nothing here measures. What is actually established is narrower and is established: rows 10 and 18 are the two clauses of `GetBuiltInElementId`, each measured, and dropping either leaves the other rejecting such a row. The claim now says that, and names those rows as its evidence. Refs #568 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
a8240aba72 |
docs(568): point the deco-validator aside at the carve-out that is above it, not below
The §8 Channel-graphics aside cited the deep-FK-in-a-nested-list exception as "below"; that exception is §3b line 314 and the aside is line ~944, so the pointer sent the reader the wrong way. It now names the section (§3b above) rather than a direction alone, so a later reflow cannot invert it again. Re-wrapped the same passage so `deep-FK-in-a-nested-list` no longer straddles a soft line break — Markdown joins those with a space and the term rendered with a stray gap mid-word. Refs #568 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
7fa223a567 |
test(568): drop the different-kind case no single-clause mutation can redden, and measure the API-site clause
`Ignores_A_Same_Named_Element_Of_A_Different_Kind` seeded an Image row at `/templates/image/on-now-next.yml` and asserted the backfill ignores it. Under the bare-filename lookup that row was rejected by the `Kind == Text` filter alone, which is what its comment described. Under the full-path predicate the path rejects it first, so neither clause is load-bearing for it: dropping the `Kind` filter reddens only `A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row` (row 18) and dropping `IsOnNowNext` reddens only the three tests of row 10. The test survived both and its comment claimed a mechanism it no longer exercised. Its scenario is the conjunction of two already-pinned negatives and is strictly weaker than `Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder`, so it is retired rather than reshaped, and graphics-elements.md now says why the combination is deliberately not shipped — otherwise the next reader re-adds it. Row 40 records the API-side half of the discriminator, which had a measured red and no row. Measured whole-project on this tree, `dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj`: baseline `Failed: 0, Passed: 2123, Skipped: 6, Total: 2129`; with `BuiltIn = GraphicsElementDefaults.IsOnNowNext(e.Path)` reverted to `Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName`, `Failed: 1, Passed: 2122`, the sole red being `GetAllGraphicsElementsForApi_Should_Not_Mark_Same_Filename_Outside_Seeded_Folder_As_BuiltIn`. The table's two self-counts were recounted from the table after adding the row and both still hold: sixteen rows redden more than one test, three of those span two fixture classes (13, 22, 33). Refs #568 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
9f0f21ec52 |
docs(568): measure the five validator mutation claims the tests assert in prose
Five test comments asserted "reddens if <validator> alone is removed" with nothing binding the sentence to a measurement -- the shape testing.mutation-claims-are-executed refuses, and the shape whose CLAIMS half of the manifest cannot reach a .NET proof. The repo's record for those is the mutation table, so each claim got a row: all five mutated in turn against this tree with the whole ErsatzTV.Tests project re-run (the tuple-arity fix included, since a mutation that does not compile is not a result). 35 GraphicsElementIdsMustExist out of UpdateChannelHandler.Validate -> 2 red; 36/37 the deco graphics/watermark validators out of UpdateDecoHandler.Validate -> 1 red each; 38/39 the two Consumes* mode gates -> 1 red each. Sixteen rows now redden more than one test; three still span two fixture classes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
c249ca4110 |
test(568): bind the test's mutation claim to the measured rows, not to itself
The comment asserted an outcome ("also reddens if the Kind filter is dropped")
with nothing tying it to a measurement -- the shape testing.mutation-claims-are-
executed exists to refuse. Both clauses it covers are rows of the mutation table
in docs/graphics-elements.md, measured against this tree; cite them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
41fd1f64de |
docs(568): re-measure the rows the seeder's lookup call moved, and recount the table's own claims
The seeder now resolves the built-in row through GetBuiltInElementId, which puts that lookup on a second call path, so every row whose clause the new call can reach was re-run against this tree: 10 and 33 unchanged, 18 reinstated (the Kind == Text filter has a red now that a wrong-kind row at the seeded path can suppress the row the lookup needs), 21 unchanged, 22 gains a third red, and 34 is new (the existence check re-derived as SQL instead of asking the lookup). Two stale measurements went with it. The "known clauses with no red" bullet for the Kind filter quoted 2121 passed against a tree that produces 2123, having been taken before the branch's last two tests existed -- the whole bullet is gone now that the clause has a red. And the per-fixture-filter trap counted thirteen multi-test rows with two spanning two fixture classes, true on origin/main and false here since the branch added rows: fifteen and three, both recounted from the table, with a note that they are. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
74a6e01712 |
fix(568): the seeder asks the built-in lookup, instead of re-deriving it in SQL
EnsureBuiltInElementRow decided whether the built-in row already existed with its own `AnyAsync(e => e.Path == target)` -- the one discriminator site left comparing in SQL after 28827a7d3 moved the rest in memory. Two ways it could answer differently from GetBuiltInElementId, each leaving the built-in element undiscoverable for the life of the install: string equality in SQL is the provider's collation to decide, so on MySQL's normally case-insensitive default a case-variant row satisfied the check and the canonical row was never created; and it ignored Kind, so a row of another kind at the seeded path suppressed the Text row the lookup resolves. Ask GetBuiltInElementId instead, so the existence question and the resolution question are the same code. The wrong-kind half is observable under SQLite and is now pinned; the collation half is not (BINARY and an ordinal comparison agree on every input) and stays held by keeping the comparison out of SQL. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
1149d96eff |
test(568): the watermark mode-gate test starts from an actual attachment
Its graphics twin seeds an element, attaches it, then saves with Inherit and an unknown id, so "the join is empty afterwards" distinguishes a cleared attachment from one that was never there. The watermark half asserted the same emptiness on a deco that had no watermarks to begin with -- true of the fixture regardless of what the handler did, which is a fixture that omits the field it means to test. Seed a ChannelWatermark, attach it under Override, then save with Disable and watermarkId 777. Re-measured 2026-09-05 with the ConsumesWatermarkIds guard removed alone from the committed tree: 1 failed / 4 passed, the failure being Should_Ignore_An_Unknown_WatermarkId_When_The_Mode_Does_Not_Consume_It. Whole project green with the guard in place: 2123 passed, 6 skipped, 0 failed. Refs #568 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
cb11116d4f |
docs(568): stable row ids in the mutation table, and the case-sensitivity sign-off the issue's wording invites
Two review findings, both about docs the branch already rewrote.
The mutation-coverage table in docs/graphics-elements.md ended up with two rows
numbered 24 -- the new IsOnNowNext row was inserted after 23 without checking
what followed -- while 18 was vacated when the Kind-filter row moved to the
"no red" list. The section's own prose cites rows by number ("the IsOnNowNext
clause (row 10)"), so a duplicate id makes a citation ambiguous. The new row
becomes 33, the next unused number, and the rule that made it 24 in the first
place is now written down: a row number is an identity, not a position, so a new
row takes the next unused number, nothing is renumbered, and a retired clause
leaves its number vacant rather than having it reused under a new meaning. Both
row claims were re-measured and are unchanged; only the id moves.
#568's second half is titled "builtIn discriminator is filename-only,
case-sensitive, folder-agnostic", and the branch removes the first and third
while deliberately keeping case sensitivity -- which reads like two thirds of a
done-when box. It is not: the remedy the same box prescribes, "full seeded
relative path", is exactly as case-sensitive as the filename match it replaces,
so the three adjectives describe one predicate rather than name three separable
demands. Read the other way the box would be unsatisfiable by its own remedy.
The reason case sensitivity is kept -- a case-INsensitive test hands the built-in
identity to a user element differing from the seeded path only in case -- lived
only in GraphicsElementDefaults.cs, where a reader arriving from the issue title
would not find it. It is now in the active record that owns the discriminator.
Refs #568
Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
0e92c147a4 |
fix(568): a deco validator may only reject ids the apply path would consume
Review found the new UpdateDecoHandler FK validators ran unconditionally while ApplyUpdateRequest reads either id list ONLY under DecoMode.Override or Merge -- under Inherit/Disable it Clear()s the join and ignores the field. So the branch turned a previously-succeeding save into a 422 over ids that were about to be discarded, and the SPA reaches that shape: DecosScreen's toReplaceRequest sends watermarkIds/graphicsElementIds from the draft whatever the mode selector says, while the picker itself is disabled off-Override. RefreshGraphicsElementsHandler deletes rows whose template file is gone (cascading the join away), so a stale editor draft could be locked out of saving a deco back to Inherit, with a 422 naming an element the disabled UI does not even show. Measured before the fix on the review's E2E instance: PUT /api/v1/decos/1 with graphicsElementsMode=Inherit and graphicsElementIds=[999] returned 422 "Graphics element(s) do not exist: 999". The mode predicate is now named once per collection -- ConsumesWatermarkIds / ConsumesGraphicsElementIds -- and read by both the apply path and its validator, rather than the apply path holding one copy and the validator implying another. A second copy is what let the two disagree in the first place. Two tests pin the gate, one per collection, each reddening when its guard alone is removed: Should_Ignore_An_Unknown_GraphicsElementId_When_The_Mode_Does_Not_Consume_It Should_Ignore_An_Unknown_WatermarkId_When_The_Mode_Does_Not_Consume_It Measured 2026-09-05, each guard removed alone from the committed tree: 1 failed / 4 passed, and the failure is exactly the test named for that guard. Both assert the apply-path outcome as well as the accept, so a validator that stopped rejecting for some other reason would not satisfy them. Refs #568 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
ae314aa5ea |
docs(568): re-measure the built-in lookup's mutation rows against the code as it now stands
The mutation-coverage table's claims were measured against an earlier shape of GetBuiltInElementId and are re-taken here, because the lookup changed twice on this branch (filename -> seeded path, then SQL -> in-memory IsOnNowNext) and a claim about which tests a mutation reddens does not survive either move on its own. Measured 2026-09-05, each mutation applied alone to the committed tree: - Row 10, the seeded-path filter removed: 3 red, not the 2 the row listed. Ignores_A_Case_Variant_Of_The_Seeded_Path joins the two already named, because without the filter every Text row resolves as the built-in one. - Row 24 is new: IsOnNowNext loosened from Ordinal to OrdinalIgnoreCase reddens exactly the two case-variant tests, 2 failed / 77 passed. One row covers both discriminator sites because they now share the predicate. - The Kind==Text filter's "no red" bullet is re-measured across the WHOLE ErsatzTV.Tests project -- 2121 passed, 6 skipped, 0 failed -- rather than the 11 tests of the one file that names GetBuiltInElementId. ChannelGraphicsDefaults reaches the lookup from the channel-create handlers as well, so the narrower population could not have seen a red there. The conclusion is unchanged; what changes is that it is now measured over the population that could falsify it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
131c63f7f4 |
fix(568): one ordinal identity predicate for the built-in element, applied in memory at both sites
The branch moved the `builtIn` discriminator from a bare filename to the full seeded path, but split how the two sites evaluate it: the API handler compares in memory (ordinal) while GetBuiltInElementId's new `.Where(e => e.Path == OnNowNextSeededPath)` compares in SQL. GraphicsElement.Path takes no explicit collation -- TvContext.OnModelCreating pins one only on the listed name/title columns -- so SQLite answers that case-sensitively and MySQL uses the server default, which is normally case-INsensitive. On MySQL the two discriminators could therefore disagree about the same row: AttachOnNowNextByDefault would resolve a case-variant user element as the built-in one while the API reported builtIn:false for it. Collapse both onto GraphicsElementDefaults.IsOnNowNext, ordinal, applied in memory. GetBuiltInElementId goes back to loading the Text candidates and filtering in memory (the shape it had before this branch), keeping only the `Kind` enum filter in SQL. The prose claimed more than the code did. "A filename-only comparison is case-sensitive-by-accident" appeared in four places as a defect the full-path fix removed; a full-path comparison is exactly as case-sensitive, so the clause said nothing and implied a fix that had not happened. Case sensitivity is now deliberate and stated as such -- the built-in element is the exact file the seeder wrote, at the exact path it wrote it to -- and the reason the comparison is kept out of SQL is recorded where the predicate lives. docs/decisions/records/graphics/channel-level-attachment.md said BuiltIn was "computed by comparing the row's `Path` to GraphicsElementDefaults. OnNowNextFileName", which was true of neither the pre-#568 rule (filename to filename) nor the current one; an active record resolved by key now states the current predicate in its own sentence rather than in a parenthetical. Two tests pin the ordinal rule against a loosening to OrdinalIgnoreCase, one per site. Measured: OrdinalIgnoreCase reddens exactly GetAllGraphicsElementsForApi_Should_Not_Mark_A_Case_Variant_Of_The_Seeded_Path_As_BuiltIn and Ignores_A_Case_Variant_Of_The_Seeded_Path, 2 failed / 77 passed of the 79 graphics tests. They do NOT pin provider independence -- under SQLite's BINARY collation an equivalent SQL comparison answers identically, so no test in this suite can distinguish the two. That is stated at each site rather than left for a reader to assume the tests cover it. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
352305ade8 |
fix(568): sync docs/comments to the seeded-path builtIn discriminator, harden UpdateDecoHandler's twin FK ids
Review round on #568 found the branch changed builtIn identity from a bare filename comparison to the full seeded path (GraphicsElementDefaults. OnNowNextSeededPath) but left several places still asserting the old rule: - docs/decisions/records/graphics/channel-level-attachment.md and on-now-next-on-by-default.md (both status: active) still described a filename-only match; corrected in place and cross-referenced. - docs/api-conventions.md §8 quoted the retired `Path.GetFileName(element.Path) == OnNowNextFileName` expression verbatim; replaced with the current OnNowNextSeededPath comparison and a note on the UpdateChannelHandler 422 hardening. - Three in-code comments (GraphicsElementDefaults.cs, GraphicsElementSeeder.cs, ChannelGraphicsDefaults.cs) still said "identity is the filename". - docs/graphics-elements.md's mutation-coverage table (row 10, row 18) named clauses that no longer exist or no longer redden any test post-#568; re-measured directly (removing the seeded-path check reddens Ignores_A_Non_Built_In_Element_With_A_Different_Filename and Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder; removing the Kind==Text filter alone reddens nothing, so it moves to the "known clauses with no red" list with that measurement dated). Also closed the should-fix twin: UpdateDecoHandler's graphicsElementIds and watermarkIds are top-level ReplaceDecoRequest fields in the same position as UpdateChannelRequest.graphicsElementIds (not the deep-FK-in-a-nested-list carve-out), and the reconcile in ApplyUpdateRequest blindly Added a join row for any incoming id -- the identical FK-constraint-to-500 defect #568 fixed on the channel path. Added GraphicsElementIdsMustExist/WatermarkIdsMustExist validators mirroring UpdateChannelHandler's, pinned by UpdateDecoGraphicsElementsTests (reddens when either validator alone is removed -- verified). Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
c84bdd2427 |
docs(568): record the full-seeded-path builtIn discriminator in graphics-elements.md
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
900dac9832 |
fix(568): reject unknown channel graphicsElementIds with 422; discriminate builtIn by seeded path not filename
UpdateChannelHandler.Validate never checked incoming graphicsElementIds against
GraphicsElements, so PUT /api/v1/channels/{id} with a non-existent id hit
FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId at SaveChangesAsync
and surfaced as an unhandled 500. Add GraphicsElementIdsMustExist, following the
existing FFmpegProfileMustExist/WatermarkMustExist/FillerPresetMustExist shape,
so an unknown id now returns 422 for parity with every other FK field on this
full-replace DTO.
GetAllGraphicsElementsForApiHandler and GraphicsElementSeeder.GetBuiltInElementId
keyed builtIn off Path.GetFileName(e.Path) == OnNowNextFileName -- folder-agnostic,
so a user element named exactly on-now-next.yml in any other template folder would
also report builtIn:true. Both now compare against
GraphicsElementDefaults.OnNowNextSeededPath, the full path the seeder actually
writes to.
Follow-up from the #74 whole-branch review (2026-07-22), deferred as
data-safe/not SPA-reachable.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
8cda92748e |
docs(901): a predicate over an artifact whose grammar it does not implement pins the artifact whole; a shape-matcher is the exception that argues for itself (#920)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 8s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 11s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 13s
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 4m19s
fixes #901 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
022a05acb2 |
docs(901): the third count had no in-repo source, so it becomes what both cited artifacts say
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 9s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 23s
PR Gates / Docs update reminder (pull_request) Successful in 22s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 13s
Review verdict / Set review-verdict status (pull_request_target) Successful in 30s
review-verdict/h10 Review-verdict: MERGEABLE @ 022a05a (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m21s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m34s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
The thesis measurement quoted three numbers side by side. Two are verbatim from the corpus — `docs/guard-inventory.md:179` "wrong NINE times", and `scripts/tests/test_image_build_delegates_the_spa_suite.py:772` "defeated seven measured ways". The third, "a lexical rule over a hook preamble **five**", was reachable only from #901's own issue body ("Five spellings, one mechanism"); nothing in the repo re-derives it, and both artifacts the record cites for #891 — `docs/decisions/records/process/hook-resolves-inputs-from-repo-root.md:64-68` and `scripts/tests/test_hook_fire_log.py:144` — count the SAME sequence as three ("Three successive lexical rules over this line each fell"). Counted as spellings it is eight or nine; five is neither basis. A number in prose that no artifact re-derives is this record's own subject matter, and "Measured 2026-08-30" invites trust rather than re-derivation. Answered by subtraction, not by new prose: - body: the third clause states what both cited artifacts state — three successive lexical rules, each defeated by the next shape. - `rule:` drops the copied numeric triple ("nine, seven and five times") for a pointer to the sequences below, so the counts live in one place (`dont-keep-a-copy-of-a-set`). - `signals:` swaps the unsourced token for the sourced one. Same class, found while checking the neighbours: "would have licensed the parser above through most of nine rounds" (`rule:` and body) counted #887's NINE DEFECTS as rounds — guard-inventory records them as nine defects across THREE cold-review rounds. Now "most of those nine defects". Body stays 58 lines; the paragraph is reflowed at the file's existing width. Refs #901 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
81358dec5a |
docs(901): the acceptance hole belongs to the REJECTED splitter, so the clause claiming a second one is deleted
`dc3f158bc` corrected a false universal ("this arm's errors are refusals, never
acceptances") and, in the same sentence, appended a second unmeasured
fail-direction claim: "and the `split(\"\\n\")` residual below is the second
acceptance of the same kind". That clause inverts the paragraph it points at.
MEASURED, by loading the module and calling its own primitives with
`CANONICAL_SINK_ASSIGNMENT + \x0c + "rm -rf /tmp/nothing"` on one physical line
followed by `CANONICAL_SINK_SOURCE`:
instrumentation_faults(...) -> "the sink preamble is not
the canonical two lines"
splitlines() selection == [CANON, SOURCE] -> True (ACCEPTS)
split("\n") selection == [CANON, SOURCE] -> False (REFUSES)
The shipped `split("\n")` refuses exactly where the rejected `splitlines()`
accepts, which is what the pre-existing paragraph six lines below already says.
There is no second acceptance below; the acceptance belongs to the alternative
that was NOT shipped. `\x0c` is not a line terminator for bash either, so
`split("\n")` matches the shell the checker models and has no hole of this kind.
The FIRST half of the sentence stands and was re-measured: a hook carrying the
two canonical lines plus `v="ETV_HOOK_FIRE""_LIB=/tmp/evil.sh"; eval "$v"`
produces no preamble fault, so the selector really does accept a writer that
never spells the literal. Answered by SUBTRACTION per the brief: the sentence
ends at "not all refusals." and no replacement prose is written.
`dc3f158bc`'s message argues from the same inversion ("the comment already
conceded an acceptance hole of the same kind six lines later"); that half of its
reasoning is withdrawn here. Its correction of the universal is unaffected — the
selector escape it names is structural and independently measured, above.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
20875b0ca7 |
docs(901): answer round seven by SUBTRACTION — the thesis loses both absolutes, the list loses two copies
Round seven's blocker was the record's own thesis sentence, and the fix is to
delete rather than to re-argue. Six rounds each replaced a refuted absolute with a
fresh one; this one states only what was measured.
THE THESIS (body). Struck: "a pin cannot be defeated by a respelling of what it
COMPARES" and "nothing [stays exposed] for a whole-file pin". Both are false for
the record's own worked whole-file pin — measured against the guard's own
primitives:
_normalise_lines("const flag = '--run --reporter=x';")
== _normalise_lines("const flag = '--run --reporter=x';") -> True
_normalise('RUN npm ci && echo "a b"')
== _normalise('RUN npm ci && echo "a b"') -> True
Both pairs differ in bytes and both are respellings of what the pin compares.
`PINNED_VITE_CONFIG` — the pin the record calls "pinned WHOLE" — is compared
through `_normalise_lines`, so its normalisation is a second exposure axis beside
the selector's. The record already refuted itself twice: `rule:` ends "A pin also
declares its NORMALISATION and what the normalisation cannot see", and `mechanics:`
says the whitespace collapse "including inside a QUOTED STRING" belongs to both
TEXT pins. What replaces the sentence is the fail DIRECTION alone — a pin's is a
false RED, a shape-matcher's a false GREEN — plus the declaration obligation the
rule already carries. No new universal is written in its place.
MECHANICS. "named once so the checker, the mutation proofs and the hooks cannot
come to mean different strings" is deleted, not repaired: the assignment string is
written out at THREE sites in `test_hook_fire_log.py` (measured by walking the AST
and comparing each assembled string to `CANONICAL_SINK_ASSIGNMENT` — the constant,
and the `current` local of `test_an_ENV_VAR_resolved_sink_path_is_DETECTED` and of
`test_the_NEXT_env_var_to_be_invented_is_DETECTED`). The source comment making the
same claim is corrected in place, and its correction is STRUCTURAL: it names the
three sites and the `current in text` assertion each proof carries, and asserts no
mutation outcome, because "an edit here faults loudly there" would be a `CLAIMS`
entry under `testing.mutation-claims-are-executed` — wherever it is written — or it
is not written. Same reason `4d5bd0dbb` removed the outcome claim from `mechanics:`
rather than binding it.
THE QUALIFYING-GRAMMAR LIST now exists once, in the record's `rule:`.
`docs/README.md` and `docs/guard-inventory.md` state the operative test — an
artifact with a grammar the predicate does not implement — and point at the record.
The copies had already disagreed inside the commit that wrote them:
`docs/README.md` carried five of the six members, omitting JSON5, which is the
member the issue's own correction comment names as the one the narrow "shell or
config TEXT" framing would have let through (`dont-keep-a-copy-of-a-set`, #869).
Rider 1 drops its copy of the vite `DEFAULT_CONFIG_FILES` ordering the same way,
deferring to the dated reading in the `test_image_build_delegates_the_spa_suite.py`
row.
Gate: `pytest scripts/tests -q` 1599 passed, 3 skipped (all pre-existing by-design
skips); `decisions_validate.py` OK with the record off the >60-line list; catalog
regenerated with no diff; ruff clean; `check-doc-narrative --diff origin/main` 0
warnings.
Refs #901
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
dc3f158bcb |
docs(901): #885 merged, so the sentence the record's structural fact refutes is corrected at its source
`scripts/tests/test_hook_fire_log.py:154` ended "this arm's errors are refusals,
never acceptances". That is false for the SELECTION, which is the half this branch's
record is about: `mentions` is chosen by the literal `ETV_HOOK_FIRE_LIB`, so a writer of
that variable which never spells the literal is outside the compared set altogether, and
the checker never sees it. The comment already conceded an acceptance hole of the same
kind six lines later, about `split("\n")` versus `splitlines()`, so the universal was
contradicted inside its own block.
The correction stays STRUCTURAL — it reads the selector one line below and says what the
selector reaches — and makes no claim about what a mutated hook would return. An outcome
claim would be a `CLAIMS` entry in `scripts/tests/mutation_manifest.py` or nothing, per
`testing.mutation-claims-are-executed` as amended by #881, which is the same reason
`mechanics:` states the selector rather than a measured result (`4d5bd0dbb`).
WHY THIS LANDS NOW: #885 closed while this branch was in review (merged as #919,
`c30847204`), which frees `scripts/tests/`. Of the two obligations `47619320e` recorded
as owed, that message is superseded here:
- DISCHARGED: this one, inline, above.
- NOT OWED, and the reason is not the blocker: the `CLAIMS` entry for the hook-preamble
selector. `4d5bd0dbb` removed the outcome claim from `mechanics:` rather than binding
it, so the record asserts no mutation outcome and the rule it invokes has nothing to
bind. Re-adding a claim in order to bind it would reverse a review-mandated change; an
executed GREEN entry (target `.claude/hooks/decisions-guard.sh`, clause = the canonical
sink assignment, replacement = that line plus the `eval` spelling, plus the mandatory
`reach_replacement`/`reach_expect`) remains available as an ENRICHMENT of the structural
fact, and belongs to whoever wants the fact executed rather than argued.
Rebased onto `366a0f904..c30847204` on the way: the `docs/guard-inventory.md` conflict is
two rows, resolved by taking #885's newer `test_workflow_persist_credentials.py` row
(it gained a second invariant) and this branch's `test_image_build_delegates_the_spa_suite.py`
row (it dates the vite `DEFAULT_CONFIG_FILES` reading to 8.1.3 and to the lockfile).
`docs/decisions/README.md` regenerated, not merged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
868ab657d8 |
docs(901): the record was wrapped narrower than its own corpus, so 65 lines was a WRAP width, not a word count
`798e5ee40` stopped at 65 prose lines and gave a reason: the remaining candidates for
removal were the *why* behind non-obvious choices, which `docs.no-session-narrative`
says to keep. That reason is sound about the WORDS and wrong about the LINE COUNT,
because the two are not the same quantity. `decisions_validate.record_prose_lines` counts
PHYSICAL lines, and this record was the narrowest thing in the set being measured:
max body width, 222 active records: median 106; 28 at <=100, 154 at 101-120, 40 >120
this record: 100. The sibling it cites by key, process.hook-resolves-inputs-from-repo-root: 116.
The 60-line ceiling was derived at #620 from that distribution, so measuring a
100-column record against it charges the record for a wrap width the corpus does not use.
Re-wrapping the seven body paragraphs at 116 — the exact width of the neighbour record —
takes the body from 66 physical lines to 58, and the validator now reports 58, off the
over-ceiling list (50 records over -> 49, and the key no longer appears).
The reflow removes NOTHING: the script asserted word count equal before and after (893)
and whitespace-normalised body text byte-identical, and refused to write otherwise. What
it buys is that the issue's `## Done-when` box "The record is under the 60-line advisory
prose ceiling" is satisfiable as written, so `pretooluse-merge-consent.sh` is not asked
to derive consent from a box ticked falsely or left standing. The advisory itself was
never breached — the ceiling is a `::warning::`, the validator exits 0, and 49 of 222
records are over it inside the 2-25% CEILING_MINORITY band.
Also: the file was the only one of 222 records with no final newline. Fixed in the same
commit; `record_prose_lines` is `splitlines()`, so it does not move the count.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
11f92761af |
docs(901): the whitespace collapse belongs to the PRIMITIVE, so it belongs to both text pins
`mechanics:` opened with a universal — "a residual read off one of them does not
transfer to the others" — and then attributed the collapse-whitespace-inside-a-string
residual to `_normalise_lines` alone. It does transfer, between exactly the two pins
that sentence separates: `_normalise_lines` is `_normalise` applied per line
(`scripts/tests/test_image_build_delegates_the_spa_suite.py:327`), and the stage-command
pin calls `_normalise` directly (lines 386/389, compared at line 586), so it cannot see a
whitespace change inside a quoted shell string either. For SHELL text that is the more
consequential of the two residuals, which is the opposite of what the old ordering
implied.
The sentence now says NEED NOT transfer, names the one that does, and puts the
string-literal blindness on `_normalise` where it originates. The vite-only fact that
survives is the blank-line drop, and the claim that the test STATES its residual is
narrowed to the vite test, which is the only one of the two that does.
The hole is dormant rather than live — no entry in `PINNED_STAGE_COMMANDS` carries a
quote character — but the defect was the prose universal, which the record's own rule
("a pin also declares its NORMALISATION and what the normalisation cannot see") is what
this paragraph exists to demonstrate. This is the third finding read off this one
sentence: `b8dc321af` corrected its fault-message half and `4d5bd0dbb` its
outcome-claim half.
TWO OBLIGATIONS ARE OWED to the closing record, both blocked on #885 (open, so this
branch does not touch `scripts/tests/`), and both freed together when it closes:
1. The `CLAIMS` entry in `scripts/tests/mutation_manifest.py` for the hook-preamble
selector — target `.claude/hooks/decisions-guard.sh`, clause = the canonical sink
assignment, replacement = that line plus the `eval` spelling, proof = a
`test_hook_fire_log.py` node, outcome=GREEN with the mandatory
`reach_replacement`/`reach_expect`. Until then `mechanics:` states the SELECTOR as a
structural fact and makes no outcome claim (`4d5bd0dbb`).
2. The comment at `scripts/tests/test_hook_fire_log.py:153-154`, which ends "this arm's
errors are refusals, never acceptances". The structural fact this record ships — the
compared set is the non-comment-led lines containing the literal `ETV_HOOK_FIRE_LIB`,
so a reassignment that never spells the literal is outside the selection — is an
ACCEPTANCE by that arm, and the same comment block concedes the class five lines later
("so it is an acceptance hole, not only a stricter refusal"). The sentence is owed a
correction; the record documents the residual beside it in the meantime.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
761a71278d |
docs(901): pay for the two corrections by tightening the prose around them
The two corrections added four prose lines to a record already one line over
the 60-line advisory ceiling. Recover what can be recovered without losing
substance: reflow the paragraphs, drop the padding ("and it fails silently"
→ ", silently"; "the count rises" → "and rises"), and cut one restatement.
It lands at 65 lines, not 60. That is a deliberate stop: the remaining
candidates are the *why* behind non-obvious choices — which the repo's own
docs rule says to keep — and `decisions_validate` reports the constant itself
as drifted from the distribution it is supposed to mark the tail of (p90=104,
p95=142, 50 of 222 records over it). The validator passes.
Refs #901
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
0977f04174 |
docs(901): the respelling-immunity thesis is bounded by the pin's REACH, and the neighbour doc declines the rule it was cited for
Two prose overclaims in the new record, both of the shape the record itself exists to police. The thesis sentence generalised over the two worked pins and held for one. `PINNED_VITE_CONFIG` compares a whole file, so no respelling escapes it; the hook-preamble pin compares a SELECTION (`test_hook_fire_log.py:161` keeps only the lines containing the literal `ETV_HOOK_FIRE_LIB` that are not comment-led), so it must recognise a line before it can reject it — which is exactly the residual `mechanics:` documents four lines later. Bound the immunity to what a pin COMPARES and name the leftover exposure as the selector's reach. The neighbour citation claimed `docs/defect-shapes-773.md` §4 "argues the general form". §4 is a ranked table of detectors A-G — none of them this rule, and A is already assigned to `guard-derives-population-from-source` by the preceding clause. The doc's nearest class is `string-predicate churn` in the §3.6 partition, marked `no detector proposed`, and §3.7 argues the class away as a cross-cutting property (2 of 33 round-churn records). Only §4's closing meta-finding — class-level rules beat one record per instance — supports anything here, and it supports the FORM, not the content. Say that. Refs #901 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
c67b33cdcd |
docs(901): a mutation OUTCOME is a manifest entry or it is not written — state the selector instead
Round three, blocking. The record's `mechanics:` field asserted a measured mutation outcome — that appending an `eval` which composes `ETV_HOOK_FIRE_LIB` at runtime leaves `instrumentation_faults` returning `[]`, identical to the unmutated baseline — with no `CLAIMS` entry in scripts/tests/mutation_manifest.py. `testing.mutation-claims-are-executed` as amended by #881 puts a prose claim about a mutation's outcome under the executed-claim rule wherever it is written, a decision record included, and this branch's own docs/README.md row restates that. A record whose rule text requires "each defeat the matcher claims to catch is a DECLARED, executed mutation" cannot itself carry an undeclared one. scripts/tests/ is held by #885, which is open, so the entry cannot be added here. What replaces the outcome claim is the structural fact that carries the same point and needs no execution: the compared set is the lines containing the literal `ETV_HOOK_FIRE_LIB` that are not comment-led, so the pin reaches exactly the two preamble lines and a later reassignment which never spells the literal is outside the selection — whatever the checker then returns. The `CLAIMS` entry is owed once scripts/tests/ is free. Three more from the same round: - `_normalise_lines` was a universal over three pins that holds for one. The stage commands compare through `_normalise` (continuations joined, whitespace within one command collapsed); the script map is dict equality over parsed JSON and compares no text; only PINNED_VITE_CONFIG uses `_normalise_lines`. The three are now stated separately, with the note that a residual read off one does not transfer. - docs/guard-inventory.md's pointer restated the "shell or config TEXT" framing the record exists to reject. It now says what the record says: an artifact with a grammar the predicate does not implement. - Rider 1 stated vite's DEFAULT_CONFIG_FILES ordering unbound to a version. The ordering belongs to a release and expires with one, so the record cites the guard-inventory row rather than keeping a second copy, and that row now dates the reading and names the release web/package-lock.json pins. Refs #901, #891, #887, #881, #885 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
17c65005f5 |
docs(901): the hook pin is a pin WITHIN a selection, and the selection has a measured escape
The record's own rule ends: a pin also declares its NORMALISATION and what the
normalisation cannot see, because "pinned whole" invites a reader to assume
byte equality. The `mechanics:` field did that for the image-build pin (it
names `_normalise_lines` and what it drops) and not for the hook pin, which it
described only as pinning two lines byte for byte.
The comparison at scripts/tests/test_hook_fire_log.py:161-167 is over a
SELECTED set — lines containing the literal `ETV_HOOK_FIRE_LIB` that are not
comment-led — so byte-identity holds within the selection and says nothing
about a writer of that variable spelled without the literal.
MEASURED 2026-09-05 on this branch against `.claude/hooks/decisions-guard.sh`:
appending
eval "$(printf %s%s=/dev/null ETV_HOOK_FIRE _LIB)"
after the canonical assignment leaves `instrumentation_faults(text,
'decisions-guard')` == `[]`, byte-identical to the unmutated baseline `[]`,
while running those two lines under bash prints `final=/dev/null` — the sink is
repointed and the checker is silent. A false GREEN, which is the failure
direction this record exists to argue about.
The residual is #891 code and is not introduced here; no code changes. What
changes is that a record citing this file as one of its two worked pins now
states the residual instead of implying byte equality over the file.
Refs #901, #891
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
32e8578438 |
docs(901): the one line a guard author reads names two of the three rules
The record is a rule about the SHAPE of a guard's predicate — the third member of the set docs/guard-inventory.md:15-16 tells a guard author to read before editing a guard or adding a row — and that pointer named only `guard-derives-population-from-source` and `guard-ships-with-mutation-proof`. Before this commit, `grep -rln guard-pins-the-artifact-not-a-shape docs/` outside the record and the generated catalog returned docs/README.md alone, so the record was reachable from the task-signal map and by topic but not from the inventory a guard author already has open. That is the reachability failure the record itself names: its body says `docs/guard-inventory.md` carries its precedent per incident, findable only from inside one. A record about topic-resolvability that is missing from the entry point of its own topic reproduces it. Refs #901 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
78593ace9f |
docs(901): a quotation that resolves to nothing, and a universal over three pins that holds for two
Two review findings, both the class this record is about: prose that claims more than the artifact says. The neighbour-record citation presented a paraphrase in quotation marks. `testing.guard-derives-population-from-source` line 96-97 reads "when the authoritative source is missing, the answer is to create one, never to approximate it with a predicate over text"; the record quoted it without "the answer is to" and without the second "to", so a fixed-string lookup of the quoted span found nothing anywhere under docs/ — #812's second defect, which `testing.mutation-claims-are-executed` names explicitly. The quote marks are gone rather than repaired: the source sentence spans a line break, so any single-line verbatim quotation of it would still not resolve by grep, and an open paraphrase claims only what it is. The `mechanics:` field said the image-build pins fail with a message naming the constant to update. Measured against scripts/tests/test_image_build_delegates_the_spa_suite.py: PINNED_STAGE_COMMANDS (line 589) and PINNED_VITE_CONFIG (line 798) name themselves, PINNED_PACKAGE_SCRIPTS (lines 757-763) does not — it names the FILE and prints both maps. 423bf94e7 corrected this same sentence for the hook half after verifying it and left the image-build half an unverified universal. The first replacement drafted here read "all four faults ask for the reason in the same commit", which is false a second way: the hook-preamble fault (test_hook_fire_log.py lines 162-167) asks for no reason at all, it reports got={mentions}. The shipped sentence is scoped to the three pins whose fault messages were read. Refs #901 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
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 |
||
|
|
87d90f330c |
docs(901): the hook-preamble fault does not name a constant — correct the mechanics claim
The record asserted both worked pins fail with a message naming the constant to update and asking for a reason. True of `test_image_build_delegates_the_spa_suite.py`'s three pins; false of `test_hook_fire_log.py`, whose byte-identity fault reports the divergent `mentions` list and names no constant. Verified against both files rather than inferred from the neighbouring one. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
0f4552d093 |
docs(901): pin the artifact whole is the DEFAULT for a predicate over a grammar it does not implement
Two predicates over artifacts with a real grammar were each defeated by successive spellings and withdrawn in favour of pinning the artifact whole: #887's shell parse (nine defects from one mechanism, then seven more against a partial match of web/vite.config.ts) and #891's lexical rule over the hook preamble (five spellings). Both incidents carry a record; neither is resolvable by topic before round three, which is what this class-level record adds. Decides the two questions #901 left open: - DEFAULT, not remedy. A shape-matcher's failure is a false GREEN, so the defeat that would trigger a remedy policy is found by a reviewer or an incident and never by the guard: "not defeated yet" measures who has looked. Rejected: write the matcher and pin after the first defeat — it also understates its bill, since a withdrawal costs the rounds spent AND the proofs calibrated against the narrow clause. - The exception argument carries FOUR things: the grammar and its parser; the input space as a closed enumeration with the reason it is closed; the fail direction measured as a declared, executed mutation; and what it buys priced in a cost the pin charges. Rejected: a numeric "survives N spellings" bar (measures the reviewer's imagination) and a reviewer sign-off bar (depends on the signal that arrives late). Records both riders (a pin assumes it pins the artifact that still DECIDES; widening a clause turns a survived-clause canary into a tautology) and states the threshold as the moment the NEXT spelling is found by the reviewer rather than the author. docs/README.md's guard-convention task-signal row points at the record; catalog regenerated; 59 prose lines, under the 60-line advisory ceiling. fixes #901 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
c30847204c |
fix(885): a head-authored run may hold no stored secret, so the PR route reads the registry and the status API anonymously (#919)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 19s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 13s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 11s
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 4m24s
fixes #885 refs #909 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
61ed6a7955 |
test(885): exercise REGISTRY_PASSWORD at both if: levels, re-confirm the layer measurement
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 16s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
PR Gates / Docs update reminder (pull_request) Successful in 23s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
Review verdict / Set review-verdict status (pull_request_target) Successful in 15s
review-verdict/h10 Review-verdict: MERGEABLE @ 61ed6a7 (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m35s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
Round-nine's bare-if fix table paired each secret name with only one level (job for REGISTRY_PASSWORD, step for RENOVATE_TOKEN), so the exact REGISTRY_PASSWORD-at-step-level and RENOVATE_TOKEN-at-job-level cases the finding named were never driven. All four combinations now run. The decision record's anonymous-layer-download closure read as reporting a past run without saying who ran it. Re-measured directly this session (2026-09-05, no stored credential): anonymous token -> pinned manifest's first layer -> 200/32991280 bytes, same GET with no token -> 401. Record updated to say the leg was re-confirmed, not merely "measured...since". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
45cada07e4 |
test(885): the predecessor read is asserted on BOTH condition rows, not one
The block claimed the ordinary-string read — `secret_refs`, how `secret_name_counts` routed an `if:` value before `condition_refs` — "is asserted empty on every row", while asserting it on the job-level condition only. The step-level row's own string went unchecked, so a predecessor that happened to see it would have left the row proving nothing. Both rows now run the same three assertions from one loop. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
d793342f50 |
docs(885): a condition NAMES a secret without exporting it, and is faulted for naming it
The `if:` clause reads the one site whose reference the evaluator resolves without materialising anything into the job environment, so a reader can reasonably ask why it faults. Both the function and the record now say: the predicate is "names a stored secret", never "exports one" — on the head-authored route the contributor picks the comparison, which makes a condition an oracle over the value, and a predicate about exposure would have to model what each site does with its reference and give up the structure-blindness that saw `toolchain-preflight`'s step `env:` when a `container:`-shaped predicate did not. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
786d1c3c4b |
docs(885): an unmeasured failure TIME, replaced by the structural fact it stood for
`scripts/ci-toolchain-image-resolves.sh` and `docs/ci-cd.md` both stated that the #772 container jobs "died after 1-2s", and the header used the same number to argue the preflight needs no `needs:` gate. Nobody measured it, and it cannot be measured from a working session without reproducing a deleted-tag incident. What the number stood for is structural and IS known: a container job that cannot pull its image fails AT the pull, before it runs a step, so it wastes no work waiting to be told and the argument against serialising the five jobs survives intact. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
47a5582443 |
docs(885): the record names the spelling the scoping costs, and the layer leg is measured
Two corrections to `ci.pr-route-carries-no-stored-credential`, both about claims that read
as checked and were not.
The `rule:` said the detector reads every spelling "only inside a `${{ }}` span", and the
body enumerated `secrets: inherit` as the ONE shape left uncovered. An unwrapped `if:` is a
second, and it is a shape this repo writes: both now name it, and say the value of an `if:`
is read whole.
`mechanics:` listed an anonymous LAYER download among two things the daemon probe did not
exercise. Measured 2026-09-05 from a workstation holding no registry credential: the
anonymous pull token reads the pinned manifest's first layer
`sha256:179c68a720750ab4d354f6b55c0a9f551d4fd7bde93606dd0be79ba16493a39e` -> HTTP 200,
32991280 bytes, and the same GET with no token -> 401. act_runner's own pull call path is
the one leg still unexercised.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
f075d1417c |
fix(885): an if: is an expression unwrapped, so the scoped detector reads that key whole
The `${{ }}` span scoping added in a6225e4ee was correct about prose and wrong about one
real spelling: `if:` is the only key the expression grammar lets omit the delimiters in, so
`if: secrets.REGISTRY_PASSWORD != ''` named a stored secret in a document holding no `${{`
at all, and the collector reported it clean. Measured on the previous head e35e1b772:
`secret_refs("secrets.REGISTRY_PASSWORD != ''")` -> `[]`, and the same string as a
job-level or step-level `if:` on a synthetic `pull_request` job -> `stored_secret_faults(...)
== []`. That an unwrapped condition is evaluated is not inferred — `docker-build.yml`'s own
`build` job carries `if: github.event_name != 'pull_request'` bare, and `PR_EXCLUDING_IFS`
pins that exact string.
`condition_refs` reads an `if:` value as one span with the delimiters neutralised to a
SPACE (deleting them collapses `${{ secrets.A }}${{ secrets.B }}` into the single identifier
`secrets.Asecrets`, losing a reference), and `secret_name_counts` routes the value there
instead of onto the stack, so a wrapped condition still counts once. Everywhere else the
scoping stands and the English `# We pass no secrets. Then …` still costs nothing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
d4c00f7567 |
docs(885): the handover clause states its own unprobed reachability, where the rule is read
The code banner and the test docstring say that whether act_runner on this instance resolves `workflow_call` + `secrets: inherit` was not probed, and why that is acceptable — it governs reachability today, not the guard's silence. The record stated the clause without that bound, so a reader who meets the rule through the catalog rather than through the file met a confidence claim the source deliberately does not make. Refs #885 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
c325cd6ee4 |
fix(885): a handover written in the YAML grammar names no secret, so the guard reads the KEY too
The detector was exhaustive over the `${{ }}` expression grammar and blind to
`jobs.<id>.secrets: inherit` on a `uses:` job, which passes the caller's whole
store to the called workflow while naming nothing. `secret_refs` reads only
inside expression spans — correctly, since outside one `secrets.` is a full stop
— and `inherit` is a plain scalar, so such a job was put in the derived
population by `pull_request_jobs`, walked, and reported CLEAN. Measured against
the predecessor:
stored_secret_faults('synthetic.yml', {True: {'pull_request': None},
'jobs': {'reused': {'uses': './.gitea/workflows/reusable.yml',
'secrets': 'inherit'}}}) -> []
... the same job with secrets: {TOK: '${{ secrets.RENOVATE_TOKEN }}'} -> 1 fault
so the miss was specific to the VALUE SHAPE, not the key. That is the failure the
done-condition names — a new job joining the population unprotected without
reddening anything — in a guard whose stated selling point is exhaustiveness over
the grammar and no exemption list.
`opaque_secret_handovers` now faults a `secrets:` key whose value is not a mapping
of names, under the existing `secrets.*` whole-context sentinel, and both fault
sites read through one `held_secret_names` so the workflow scope and the job
subtree cannot drift on which references are forgiven. The test is on the value
shape and not on the word `inherit`, for the reason the residue counter is not a
match on `toJSON`: any non-mapping value hands over a set the guard cannot
enumerate, a spelling act_runner grows later included.
Both halves of the predecessor measurement are re-derived every run rather than
left as prose: the new test asserts `secret_names(job) - INJECTED_SECRETS` — the
collector verbatim as it read before this clause — empty on the same fixtures it
asserts the fault on, and asserts the job is in the population. Reverting
`held_secret_names` to that expression reddens that test and only that test
(measured: 1 failed, 18 passed).
The clause reads the DOCUMENT only and the text-versus-walk cross-check cannot
cover it — there is no expression for its half to match, which is a stronger
reason than the shared-blind-spot one the cross-check already discloses. Said at
the definition, in the cross-check's "STRUCTURALLY CANNOT REPORT" paragraph, and
in the record, rather than left to be discovered; it does not redden the
cross-check either, since the clause feeds the fault collector and not
`secret_name_counts`.
Whether Gitea 1.27.1 / act_runner resolves `workflow_call` + `secrets: inherit`
on this instance was NOT probed — that affects reachability today, not the
guard's silence, and the direction is the one the spelling rows already take.
No tracked workflow uses a job-level `uses:`, so nothing reddens.
Refs #885
Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
fe05526ef4 |
fix(885): the tag rule this branch added is dated where it is asserted
`release.main-direct-push-disabled` was edited on this branch to note the new `v*` tag protection, and picked up two of the defects the round was hunting elsewhere. Its `rule:` said the `renovate` bot "can no longer push a tag that publishes `:prod`" as settled fact, while `release.tag-protection-v-star` records that exact claim as NOT VERIFIED and `docs/ci-cd.md` was already corrected to EXPECTED, UNVERIFIED. Only the `timothy` credential exists in a working session, so neither a real release cut nor a refused bot push has been exercised; all three now agree on confidence. Its `mechanics:` still read "`GET .../tag_protections` returns `[]`" in the present tense — the one fact this branch changed, and the one site an otherwise complete sweep left behind. Read back live today the endpoint returns one rule, `v*` whitelisted to `timothy`. The clause is now past tense and bound to its probe date, with the current state named. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
fa06da21f3 |
fix(885): the guard states both invariants, and the second one is executed
The two `docs/guard-inventory.md` rows and the module docstring described `test_workflow_persist_credentials.py` as the `actions/checkout` guard only. The deferral to #909 rested on `docs/guard-inventory.md` being held by the session working #881; that issue is closed and its PR is the commit this branch is rebased onto, so the file is free and the edit belongs here under docs-update-is-part-of-done. `MUTATIONS` keys at most one declared clause mutation per guard FILE (`test_the_manifest_covers_exactly_the_MUTATION_rows` asserts `len(MUTATIONS) == len(declared)`), and the grading row's proof-ref column is compared against it, so the route invariant cannot take a second `MUTATIONS` row. It takes a `CLAIMS` entry instead — the population #881 widened this file to carry — bound to the inventory sentence that states it: deleting `build`'s `if: github.event_name != 'pull_request'` from the shipped `docker-build.yml` is applied to a sandbox copy every run and the named proof is required to redden with the collector's own wording. That grows the `CLAIMS` population from three entries to four, which invalidates the cost span `testing.mutation-claims-are-executed` measured over three. Re-taking it here produced 54.3s/149.5s, 81.6s/78.7s and 114.3s/84.2s across three A/B pairs with other builds on the host — two inverted, so the load dominates the signal. The record now says the span is a lower bound and that a re-measurement is owed on a quiet machine, rather than carrying a scaled or invented number. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
e656583247 |
style(885): the tag-protection paragraph reads as unverified in its opening clause too
"unaffected for the release operator" asserted the outcome the rest of the paragraph then marks unverified. Both places now say what is intended and what is measured, and the blockquote is rewrapped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
6713b82042 |
style(885): reflow the paragraph rather than leaving a stub line mid-sentence
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
d137d70bf2 |
style(885): rewrap the header comment the previous commit ran past 100 columns
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
a84fa7665b |
fix(885): brace the accumulator so [ after it is not read as an array subscript
`pattern="$pattern[$char…]"` is SC1087 — shellcheck reads `$pattern[` as an array expansion and errors out. It concatenates correctly here because `pattern` is a plain string, so this is a lint stop rather than a runtime defect; braced, the character class is unambiguous to reader and linter alike. 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 |
||
|
|
a4eea6460b |
fix(885): a legal challenge in another case read as a missing realm
Header field names (RFC 9110 §5.1) and auth-param names (RFC 7235 §2.1) are both case-insensitive, so `WWW-AUTHENTICATE: Bearer REALM="…"` is the same challenge this registry sends in mixed case today. The preflight matched the header name in a fixed case for all but four letters and the directive name in lowercase only, so that spelling fell into the "named no realm" arm: the job fails — the safe direction — but names a cause that is not the real one and points an operator at a token endpoint that is healthy. The header line is now selected by an `awk` comparison on the lowercased field name, which leaves the value's case alone (a realm URL is case-sensitive), and the directive name is matched through a character class generated from the key. The new test drives the whole anonymous read end to end against an all-caps challenge rather than testing the parser, so the token leg and the authenticated re-read both have to survive the spelling. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
d62335bc8d |
fix(885): an English full stop is not a secret name — the detector reads secrets only inside an expression span
`_SECRET_REF` ran over the whole string while the comment above it claimed every
pattern was confined to `${{ }}` spans. Executed against the shipped module,
`secret_refs("# We deliberately pass no secrets. Then the pull is anonymous.")`
returned `['Then']`, and fed through the real collector that is one fault reading
"job `j` names stored secret(s) on the pull_request route: Then" — a fabricated
name, on a PR-route job, for its own comment. The same comment separately reddened
the text-versus-walk cross-check, because the line-level strip removes a `#` line
from the text half only.
The existing negative control passed for a reason that does not generalise: no `.`
follows the word in `"no secrets are used here"`. A sentence ENDING in "secrets."
is the likeliest thing to be written into a PR-route `run:` block on this branch's
own subject, so the trap was self-inflicted.
`secret_refs` now resolves names per `${{ }}` span, so every spelling is scoped the
way the residue counter already was. The added rows drive the real predecessor —
`_SECRET_REF` applied to the whole string — and assert it read a name where the
scoped reader reads none, so reverting the scoping reddens them.
The `INJECTED_SECRETS` comment stops calling the injected `GITEA_TOKEN` "bounded by
the workflow's own `permissions:`": on this route the head supplies that file and
can delete the block. Allow-listing it is a claim about the store it is not in, not
about a bound.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
dd836c1f46 |
fix(885): the swept symbol's last live-voice site in the dispatch record
The same-subject sweep that past-tensed `ETV_STATUS_AUTH` and this record's own `rule:` field left the record BODY saying `toolchain-preflight` takes the registry credential "via `ETV_REGISTRY_AUTH`" in the present tense — a symbol this branch removes from every workflow, so the body contradicted the `rule:` field of the same record. Container-free and `runs-on: small` are still true today and stay in the present tense; only the credential clause moves to the past, matching the `rule:` field's "took the credential through `ETV_REGISTRY_AUTH`". Body-only, so the generated catalog is unchanged (regenerated to confirm). Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
ae5a32290f |
style(885): ruff format the assertion the previous commit wrapped by hand
`ruff format --check` is part of the local gate for touched Python and the predecessor of this file passed it; one new assertion was hand-wrapped across lines that fit on one. Refs #885 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
23cef5c9d1 |
fix(885): two dependencies with opposite failure directions, and a token leg that got zero retries
The header comment and the record's `rule:` said the registry reads and the commit-status reads "both depend on `timothy/ersatztv` and its `ersatztv-ci` package staying PUBLIC; making either private fails those jobs loudly at image pull, never silently". Wrong in both clauses for the repo half, and this branch has already been sent back twice for exactly this shape of mechanism claim. Measured 2026-09-05: the `ersatztv-ci` package is linked to no repository (every version reports `"repository": null`), so the repo's visibility does not gate the anonymous pull token at all; and the only thing it does gate — the combined-status GET — fails in the opposite direction, because `ci-detect-already-validated.sh` answers a failed `curl -sf` with `emit false; exit 0`. That job stays GREEN and the #420 cross-run skip silently stops firing. So the two dependencies are now stated apart, each with its own failure direction, in `docker-build.yml`, in the preflight's header, in the record and in the `ci-cd.md` outcome table; the preflight's own 401/403 messages stop sending an operator to the repo's visibility when it is the package's. `token_leg_done` was set once per RUN, before the attempt, so a token endpoint that could not be reached failed the preflight with no retry while an identical blip on the manifest read got three. The stated reason — "a registry genuinely refusing anonymous reads is asked once rather than once per pin" — is a per-pin argument that never covered the per-attempt axis. It is now sorted by what the endpoint SAID rather than by which leg it happened on: an answer (no token in the body, a challenge naming no realm, no challenge at all) settles the question and is asked once per run; an endpoint that could not be reached, or that answered 5xx, settled nothing and is retried on the same `ETV_CI_ATTEMPTS` budget as the manifest read, because a red here denies a merge (the consent hook reads the COMBINED status, #598) and the two legs of one read must not have opposite flake tolerances. The token-leg message now reports the attempts it actually made. Driven against the SHIPPED predecessor rather than a hand-written mutant: the three new behavioural assertions are red on it (1 token call where 3 are required, and a blip shorter than the budget failing the run), while the two that pin the property the retry must not cost pass on both. Refs #885 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
8885fdd9d2 |
fix(885): the swept symbol's last live-voice site, and a caveat that is now a measurement
`ETV_STATUS_AUTH` is gone from `test`, `migrations` and `functional-e2e`, and three same-subject sites were reworded to match. `review-verdict.yml`'s comment was the fourth and still named the symbol as a live thing; it now names the credential by what it is, and says the PR route materialises none to refuse. `docs/remote-state-inventory.md`'s row for `ci-toolchain-image-resolves.sh` listed "an unusable credential" among the shapes that fail the job — that script holds no credential any more. The row names the three refused-anonymous-read shapes the shipped script actually has instead, and re-confirms the `UNSAFE-KNOWN` grade against the anonymous script: the tag it reads is mutable either way. That is #909's first half; its other half, `docs/guard-inventory.md`, stays with the session holding that file. `ci.pr-route-carries-no-stored-credential`'s `mechanics:` carried one self-declared unmeasured claim — whether act_runner's daemon performs the credential-free `container:` pull. Measured 2026-09-05 on the runner host 192.168.1.99, which runs both act_runner containers and creates every job container on its own docker socket: a `docker pull` of the pinned tag with a scratch docker config holding only `{}` exits 0. The two things that run did not exercise — an anonymous layer download, and act_runner's own pull call path — replace the open unknown rather than being dropped, and `docs/ci-cd.md` cites both measurements. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
383171dfa4 |
fix(885): a detector that knows ONE spelling is one an added job writes around
`secrets['REGISTRY_PASSWORD']` is the same reference to the expression evaluator as
`secrets.REGISTRY_PASSWORD`, and the guard shipped here could see only the dot form. Measured
2026-09-05 against the predecessor of this commit, a `pull_request`-route job whose `env:` read
`"${{ secrets['REGISTRY_PASSWORD'] }}"` produced `stored_secret_faults(...) == []` AND
`walk_versus_text_faults(...) == []` — the text cross-check cannot report the gap, because both of
its halves resolve references through the one pattern, so a spelling it does not know is a shared
blind spot they agree at zero on rather than a disagreement they name
(`proof-sharing-with-subject-proves-nothing`).
The file enumerated four other blind spots it has — composite actions, reusable workflows, nested
directories, both directions of the comment strip — and not this one, which is what made the
omission read as coverage.
`secret_refs` is now the single entry point for both halves, and it matches the dot form, both index
forms and a case-varied context, then counts the RESIDUE: any `secrets` token inside a `${{ }}` span
that yielded no literal name is reported under the sentinel `secrets.*`. Counting the residue rather
than pattern-matching `toJSON(secrets)` and a computed index is what makes it exhaustive over the
grammar — a spelling nobody has written yet still faults, in the fail-closed direction. The bare word
is read as the context only inside an expression, because in prose it is ordinary English; this file
and four workflows discuss "secrets" in comments.
`test_the_collector_sees_every_SPELLING_of_a_secret_reference` drives the six spellings through the
collector and the cross-check and asserts each is invisible to the real predecessor, so reverting the
widening reddens it. Whether act_runner resolves each spelling against this instance was not probed
from here (that needs a live run); the direction makes that acceptable — a spelling the runner does
not support costs a spurious demand on a job nobody has written, the omission cost a live
write-capable credential on the head-authored route.
Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
433e8f81c8 |
fix(885): cite the predecessor by description, not by a sha the rebase orphaned
Three docstrings dated their measurement to `59003d5a3`, this branch's head before
it was rebased onto `main` after ersatztv#907 landed. That commit is unreachable
from the branch and will never be in `main`, so `git show` on it fails for every
later reader — a citation that cannot be followed is worse than none, because it
reads as checkable. Each now names what it measured against ("the predecessor of
this commit", and for the guard, "as it walked `jobs.<id>` only and compared
per-file NAME SETS"), which is what the reader actually needs and what survives
any rebase.
Refs #885
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
25ec811c73 |
fix(885): the cross-check's own COUNTING could be reverted with the suite green — extract it and drive it
`test_the_cross_check_COUNTS_locations_rather_than_collecting_NAMES` asserted the
property on the two COLLECTORS, not on the check that uses them. The comparison
itself was inlined in a loop over the real workflows, which agree under either
mechanism — so changing `walked != scanned` back to a name-set comparison, restoring
the exact blind spot this branch exists to close, left all 17 tests passing. A guard
whose distinguishing mechanism has no mutation proof is the shape
`testing.guard-ships-with-mutation-proof` names.
The per-file half is now `walk_versus_text_faults(name, text)`, driven on a
text/walk pair whose NAME SETS AGREE: a second `${{ secrets.REGISTRY_PASSWORD }}` in
a trailing comment, which the line-level strip leaves in the text half and the YAML
walk cannot reach. Counting reports it; the set comparison the branch replaced
reports nothing, and the test asserts BOTH halves of that so the contrast is the
assertion rather than a comment.
Measured: with `walked == scanned` mutated to `set(walked) == set(scanned)`,
1 failed / 16 passed; restored, 17 passed.
Refs #885
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
ccc604aef7 |
fix(885): the never-asked message reports what the RUN did, not what the registry sent
"WITHOUT issuing a Bearer challenge" is a claim about the registry's response that this script never checks. `probe` enters the token leg on a `401` only, so a `403` carrying a perfectly good `Www-Authenticate` would be refused with that sentence having never looked at the header — the same defect one branch over, in the message written to fix it. It now says NO TOKEN WAS EVER REQUESTED, which is a fact about the run: the token leg was not entered, and this answer was never followed as a challenge. The assertion and the outcome-table row move with it, and the comment says why the weaker claim is the honest one. Refs #885 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
985a6e1505 |
fix(885): a challenge-less 401 lands in the TOKEN-LEG arm, not the never-asked one — the table and the comment said otherwise
The row added for the third refusal shape was written as "401 / 403 carrying NO `Www-Authenticate` challenge at all", and the script comment beside it made the same binding. Both are wrong for the 401 half: `probe` enters the token leg on a 401, so a challenge-less 401 DOES call `acquire_token`, which sets `token_leg_done=1` and abandons for want of a realm — it reports `could NOT OBTAIN an anonymous pull token`, the row above. Only a FIRST-READ 403 reaches the never-asked arm. The parametrised test already drives both codes and asserts exactly that split; the prose beside them did not match it. The three rows now bind one shape each: a refusal surviving a bearer the run really obtained, a 401 whose token leg yielded none (no challenge header, no realm, or no token in the answer), and a first-read 403 that asked for nothing. Prose between arms regenerates mis-bindings — which is why the arms are stated as one self-binding row apiece rather than as a category sentence covering two. Refs #885 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
fba895852c |
fix(885): the two named records state the invariant this branch actually ships, not the job-only half
`ci.workflow-dispatch-ref-unrestricted` and `ci.actions-credential-scoping` are the two records the issue requires be updated to match, and both restated the invariant as "every job of a `pull_request`-triggered workflow that names a `secrets.*`". That was the shipped predicate when they were written and is now narrower than what the guard holds: the workflow scope outside `jobs:` is judged too, because a root `env:` or `defaults:` is materialised into every job and no job-level `if:` reaches it. A record that understates its own guard is the failure this repo grades worst — it reads as a checked description and stops the next reader looking. `ci.pr-route-carries-no-stored-credential` also names the two inventory rows that this issue made incomplete and did not edit, because both files are held by concurrent changes: `docs/remote-state-inventory.md` still lists "an unusable credential" among the shapes that fail the preflight, and `docs/guard-inventory.md` still describes `test_workflow_persist_credentials.py` as the `actions/checkout` guard alone. Neither goes red — both suites assert set equality over FILES and both files were already listed — so the carry is tracked as #909 rather than left to be discovered. Refs #885, #909 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
2f661be684 |
fix(885): the outcome table gains the third refusal arm, and the collector stops keeping a second copy of its own walk
Two follow-ons from the fixes in this branch, both of the class the branch is about. `docs/ci-cd.md`'s preflight outcome table listed two token-leg rows and now needs three: a `401`/`403` carrying no `Www-Authenticate` at all never reaches the token leg, and the table is what an operator reads to decide where a red preflight sends them. The paragraph after it named "the two token-leg rows" and now says why the three are worded apart at all — a message naming a step the run skipped is evidence for a diagnosis nobody performed. `secret_name_counts` was added beside `secret_names` as a second traversal with a different accumulator. That is a copy of a mechanism, free to drift from the one the assertion runs on — the guard reproducing, inside itself, the defect it was just widened to catch. There is now ONE walk: the counting one, with `secret_names` derived from it, which is the lossless direction. Re-witnessed after the refactor — the workflow-scope hoist into the shipped `docker-build.yml` still reports 3 failed, the clean tree 17 passed. Refs #885 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
bbd919bfe3 |
fix(885): a refusal carrying NO challenge never ran the token leg, so it stops claiming one
`probe` enters `acquire_token` on a `401` only. A registry answering `403` on the first read — or a `401` with no `Www-Authenticate` — therefore leaves `token_leg_done=0` and `token=""`, the guard at the `401|403` arm is false, and the run fell through to the message that says the read was refused "even after a Bearer token was obtained". Probed 2026-09-05 with a curl shim answering `403` and dumping only `HTTP/1.1 403 Forbidden`: that message is printed, EXIT=1, and no token was ever requested. The fail direction was safe; the diagnosis was not. It sends an operator to package visibility on evidence that does not exist (`dont-narrate-mechanisms-you-didnt-measure`) — in a script whose whole design is that its refusal messages are worded apart on purpose. The arm now branches on what actually ran, `token` first so the never-asked case cannot borrow either other mechanism: * `token` non-empty -> refused after a GOOD bearer (an answer about the PACKAGE) * token leg attempted -> challenged but produced no token (about the TOKEN ENDPOINT) * neither -> refused with no challenge at all (about ACCESS) The pre-existing `403` test could not reach this: `CURL_SHIM` answered `401` + a challenge to every unauthenticated read regardless of the configured code, so the `403` parameter was only ever observable AFTER the token leg. The shim grew a challenge-less behaviour (`CHALLENGE=none`, `REFUSAL=403|401`) rather than the assertion being written against the old one, and both codes are driven because they take different paths — the challenge-less `401` still enters and abandons the token leg. Witnessed red on the predecessor script (2 failed) and green on the fix. `docs/ci-cd.md`'s "Cutting a release" runbook — the section an operator reads at cut time — gains the `v*` tag protection, the account it whitelists, the fact that its positive half is unverified, and the `DELETE .../tag_protections/1` unblock. The tag-protection note already in this file sits inside the `main`-direct-push discussion, which is not where a release cut is driven from, and `release.tag-protection-v-star` names its own failure mode as a cut that will not push. `ci.pr-route-carries-no-stored-credential` records that `docs/remote-state-inventory.md`'s row for the preflight still lists "an unusable credential" among the shapes that fail the job, which this issue deleted. That file is held by a concurrent change, so the one-clause edit is tracked as #909 rather than made here. Refs #885, #909 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
b71bf4d7b2 |
fix(885): a name SET cannot see a second copy, so the PR-route guard judges the workflow scope and the cross-check COUNTS
The shipped guard walked `jobs.<id>` only and leaned on a text-versus-walk
cross-check to catch anything the walk could not reach. That cross-check compared
per-file NAME SETS, and the two halves cancelled on the one file the invariant is
about: measured 2026-09-05 at 59003d5a3, hoisting
env:
ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
into `.gitea/workflows/docker-build.yml`'s root `env:` — which materialises into
EVERY job on the head-authored PR route — left `pytest
scripts/tests/test_workflow_persist_credentials.py -q` at `14 passed`, rc=0. The
same hoist in `pr-checks.yml` reddened, because no job there already names those
secrets. The guard could only ever see a name NO job used; a second copy of a
reference `build` legitimately keeps naming changed no set. That is
`dont-keep-a-copy-of-a-set` / `proof-sharing-with-subject-proves-nothing`: the
proof shared its accumulator with its subject and cancelled.
Two changes, because the cross-check was being asked to do the assertion's job:
* the workflow scope (everything outside `jobs:`) is now judged in its own right
by the same structure-blind collector — it is a second entry site on equal
footing with the job subtree, not an edge case, since no job-level `if:` can
take a root `env:`/`defaults:` off the route;
* the cross-check walks the whole document and compares occurrence COUNTS. A
duplicate at an unreachable location now reddens: probed 2026-09-05, a trailing
`# ${{ secrets.REGISTRY_PASSWORD }}` on a root `env:` line reports `walk
[('REGISTRY_PASSWORD', 1)] vs text [('REGISTRY_PASSWORD', 2)]` where the set
version agreed. Under counting the comment strip becomes load-bearing rather
than the no-op the old docstring admitted it was.
Driven by a mutation on the SHIPPED `docker-build.yml`, the way the `build`-loses-
its-`if:` mutation already is, plus a direct assertion on the two collectors that
a duplicated reference changes the count and not the names. Witnessed red with the
hoist in the tree (3 failed) and green without it (17 passed).
The decision record's own claims were false in the same way and are corrected:
`rule:` said "NO job ... may name a stored secret" (a root `env:` is not a job) and
the prose said "a text-versus-walk cross-check reports any reference the walk
cannot reach".
Refs #885
Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
7355873c39 |
fix(885): a head-authored run may hold no stored secret, so the PR route reads the registry and the status API anonymously
`docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that
run executes contributor-authored YAML and every `secrets.*` it names is materialised into it.
Six jobs held `REGISTRY_PASSWORD` that way — `toolchain-preflight`, `test`, `migrations`,
`functional-e2e`, `api-docs`, `format` — two of them branch-protection required contexts.
The read-only pull PAT the issue asked to cost first was REJECTED, and the measurement is the
reason: this registry already issues an anonymous pull token for `timothy/ersatztv-ci`
(`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200), that token reads the pinned
manifest and its config blob (200/200), and the combined-status GET answers 200 unauthenticated.
A read-only PAT would grant exactly what anonymity grants while adding one more credential to the
store head-supplied YAML reaches. So the stronger form was implemented instead: no PR-route job
names a stored secret at all.
- `.gitea/workflows/docker-build.yml`: the five `container: credentials:` blocks, the
`ETV_REGISTRY_AUTH` step env and the three `ETV_STATUS_AUTH` step envs are gone. `build` keeps
the PAT; it is gated `if: github.event_name != 'pull_request'`.
- `scripts/ci-toolchain-image-resolves.sh`: reads `realm` out of the `Www-Authenticate` challenge,
exchanges it once per run for an anonymous pull token, retries with the bearer. Every refusal
direction is preserved — a 401/403 after the token leg, a token endpoint yielding no token, and
one that cannot be reached all `fail` rather than degrading to could-not-tell — and the message
now names the cause an operator can act on (the repo or package has stopped being public).
- `scripts/ci-detect-already-validated.sh`: the status GET is anonymous. No credential override is
kept: the URL names one instance, that instance is public, and an unusable `":"` would draw a 401
and turn a working read into a permanent skip=false.
- `scripts/tests/test_workflow_persist_credentials.py`: the invariant, derived from the git index by
"every job of a `pull_request`-triggered workflow that names a `secrets.*`" — never the six-name
list, and never "every `container:` job", which names five of six because `toolchain-preflight` is
container-free. Witnessed red against the unfixed workflow naming all six jobs; green after.
Live tag protection applied and read back: `POST /repos/timothy/ersatztv/tag_protections`
`{"name_pattern": "v*", "whitelist_usernames": ["timothy"]}` -> id 1. A non-`v*` probe tag pushed
and deleted proves tag pushes still work at all. The POSITIVE release-cut verification is DEFERRED
to the operator's next real cut: pushing a `v*` tag publishes the `:prod` image, which is a release,
not a verification step.
What this does not close, stated so the records are not cited as a boundary: `REGISTRY_PASSWORD`
stays in the Actions store for `build`, and head YAML can still name it, `RENOVATE_TOKEN` or
`SERVERMGMT_DEPLOY_KEY`. Blast radius, not the route.
New records `ci.pr-route-carries-no-stored-credential` and `release.tag-protection-v-star`;
`ci.workflow-dispatch-ref-unrestricted`, `ci.actions-credential-scoping` and
`release.main-direct-push-disabled` updated to match; catalog regenerated. Closes #885.
Decisions-Edit: yes
Proves: scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
366a0f9040 |
fix(916): the worktree-isolated fallback reviewer follows the lenses, fix commits are a sha range, and the cap is one .NET slot (#918)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 15s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 13s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 11s
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 4m42s
fixes #916 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
a083c851b3 |
fix(916): the harness counts lens completions per round, so the runner assertion can fail
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 8s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / Docs update reminder (pull_request) Successful in 18s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
review-verdict/h10 Review-verdict: MERGEABLE @ a083c85 (base: main)
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 15s
Review verdict / Set review-verdict status (pull_request_target) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m23s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m38s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m52s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
The runner observer reset the shared counter to zero one line before reading it, so its assertion held under the very mutant it existed to reject and the fallback assertion caught that mutant for the wrong reason. Completions are now keyed by the round in each agent's own label; no stub resets shared state. Measured: re-serialising the runner reddens the runner assertion (expected [2] to equal [0]) in both scripts; moving the fallback beside the lenses reddens the fallback assertion and the round-one-failure case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
b1d5fbefcb |
fix(916): cross-family state is per round, and the orchestrator reads cross_family before a verdict
A substitute that failed in round one said nothing about the tree that lands after round two, yet the flag was sticky and doomed the run; the xfamily string was never reset either, so clearing the stickiness alone would have let a stale "substitute ALSO failed" sentence into the PR body. Both reset at the top of review(). The harness runner is round-aware (ran per round, its own counter reset) and a two-round case pins the fix; restoring the sticky flag reddens it in both scripts. Step 4 of the mechanics page tells the referee to read cross_family, not only error, before posting on a rubric-class PR. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
595c819de1 |
fix(916): a failed substitute is an error, not a landed claim; head_sha described in both schemas; the issue's box says what the code does
The runner-beside-the-lenses design is now what Done-when box 1 asks for (body amended). A rubric round whose runner and worktree fallback both fail returns an error before the push instead of landing a PR whose body claims a substitute reviewed it. The harness records lens count at the runner's start too (expects 0, so a re-serialised runner reddens), resets its counter per round, and has a case for the double failure. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
929dff835a |
fix(916): the Codex runner runs beside the lenses, only the fallback waits; head_sha described everywhere; the docs keep their thresholds
The runner builds nothing, so serialising it only added its wait to the critical path; the worktree-isolated fallback is what must follow the lenses, and the harness case now records lens count at the FALLBACK's start alone. setTimeout in the harness is globalThis.setTimeout (the .mjs lint config has ES builtins only). head_sha carries the same description in both scripts and every fixer/implementer prompt asks for the worktree HEAD, not a PR head. The mechanics page says why the cap stays at one after the serialisation and restores the 20%/10% RAM thresholds by key; the record says "several", not "three". Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
f7a0da4051 |
fix(916): the cross-family review step follows the lenses instead of running beside them, and fix commits are a sha range
One .NET slot's review round ran two worktree-isolated reviewers at once — the correctness lens and, on a rubric change, the Codex fallback — and took swap from 6.8 GB to 10.8 GB in three minutes on the 16 GB host; three slots reached load 82. review() now awaits the lenses, then the Codex runner, then its fallback. The finisher's fix attribution is the sha range the fixer's report head advances (head_sha is required on every report), replacing a line-set difference over free text that listed all eleven #563 commits as fixes. The harness gains a case that records how many lenses were still in flight when the cross-family agents started (must be zero); moving the fallback back into the parallel batch reddens it in both scripts. The mechanics page and the standing prompt state the measured cap: one .NET-building slot at a time. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
ea1519871d |
test(563): characterize the scripted engine build API in-process and replay a committed script fixture through the real controller (#915)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 11s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 18s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 13s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 12s
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 4m15s
fixes #563 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
4454f1adfc |
docs(563): anchor the measurements to the issue, which a squash merge cannot erase
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 12s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 22s
PR Gates / Docs update reminder (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 16s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
Review verdict / Set review-verdict status (pull_request_target) Successful in 20s
review-verdict/h10 Review-verdict: MERGEABLE @ 4454f1a (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m34s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 17m42s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
The paragraph added a commit ago pointed at "the commit messages that ran them" as the home of the binder and `trim` mutant outcomes. A squash merge writes its own message and drops the bodies it squashes, so that pointer can go stale the moment this branch lands. The issue and its pull request survive it, and #563 is where the round-by-round measurements already are. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
3df98d247f |
docs(563): state each binder test's mechanism instead of its mutant colour, per the amended CLAIMS rule
`testing.mutation-claims-are-executed` was amended on main while this branch was in review (#881, merged as #914): a sentence asserting that a specific mutation reddens — or does not redden — a named test is now either a `CLAIMS` entry in `scripts/tests/mutation_manifest.py` that executes every run, or it is not written. This branch carried six such sentences and none of them can be declared: `Claim.node_id` resolves a proof to `scripts/tests/<node id>` and `run_pytest` invokes pytest, so an NUnit proof has no representation in that harness at all. Durable prose now states the mechanism each test is built on — which serializer difference, which engine branch — which a reader re-checks by reading the code rather than by trusting a remembered outcome. The record says that in one paragraph, so the limit is stated rather than papered over. The outcomes themselves are here. Re-measured 2026-09-05 on this branch's tree (the commit before this one), each mutant applied to the working tree and restored from the index between runs, tree verified clean afterwards: positive control ScriptedScheduleControllerTests Passed: 9, Failed: 0 OpenApiSerializerContractTests Passed: 4, Failed: 0 Bind<T> -> System.Text.Json with JsonSerializerDefaults.Web Failed: 2, Passed: 7 — Production_Body_Binder_Ignores_Required_Members, Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null BodyBinderSettings = ApiJsonSettings.Create() -> new JsonSerializerSettings() Failed: 1, Passed: 8 — Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null OpenApiSerializerContractTests RuntimeSettings -> new JsonSerializerSettings() Failed: 4, Passed: 0 — all four cases, on PascalCase keys ScriptedScheduleController AddDuration(..., request.Trim, ...) -> false Failed: 1, Passed: 8 — Committed_Script_Fixture_Produces_The_Pinned_Snapshot ScriptedScheduleController PadUntilExact(..., request.Trim, ...) -> false Failed: 1, Passed: 8 — Committed_Script_Fixture_Produces_The_Pinned_Snapshot The last one is the round-two finding closed and re-witnessed: before the fixture's pad target moved off the content boundary, that mutant left all nine green. A squash merge writes its own message, so these figures also belong in the PR description. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
d6e4426aa9 |
docs(563): retire the forward-references the supersession falsified, and trim the record to what only it says
Two pointers still named #563 as the open reason scripted playout has no coverage. The ContentEnumeratorBuilderTests header now points at the successor record and docs/testing.md instead of the issue this branch closes. docs/decisions.md carried an orphaned fragment, "external-process pipeline remains #563's", in the residual block under ## Index -- with the pipeline now permanently outside the automated suite rather than deferred, the fragment states something false and has no recoverable subject to rewrite it around, so it goes. The record's rule gains the two things measurement settled: that ApiJsonSettings shares production's configuration and never MVC's settings object (MaxDepth 32, the two ProblemDetails converters, pinned by ApiJsonSettingsTests), and that a fixture must aim every trimming instruction between two content boundaries or that action's trim argument is witnessed by nothing. Its body loses the mutant table and the extraction paragraph, which the mechanics doc its own frontmatter points at carries verbatim; what remains is the conclusion plus the reasoning that exists nowhere else. 70 prose lines to 56, under the advisory ceiling without dropping a distinct finding. Refs #563 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
053982180d |
test(563): witness the trim flag on both trimming actions, and stop the binder claiming parity with MVC's
The fixture's pad_until_exact targeted 10:00, which the 15- and 30-minute items reached exactly, so the engine's trim branch never ran and mutating `engine.PadUntilExact(..., request.Trim, ...)` to `false` left every controller test green -- measured on the pre-change fixture, `Passed! - Failed: 0, Passed: 9`. The target moves to 09:55, off every content boundary: Movie 01 is now trimmed from 30 minutes to 25, the snapshot is re-pinned around it, and the same mutant fails Committed_Script_Fixture_Produces_The_Pinned_Snapshot while the sibling add_duration mutant still does. The trimmed span and OutPoint are asserted directly rather than resting on the snapshot alone, and the fixture and the snapshot comment both record that landing a trimming instruction on a content boundary is what silences its trim flag. ApiJsonSettings.Create() was documented as a standalone serializer configured the way MVC's is, which measurement refutes: Apply runs against a bare JsonSerializerSettings rather than the one MvcNewtonsoftJsonOptions pre-configures, so MaxDepth stays at Newtonsoft's 64 instead of MVC's 32 and ProblemDetailsConverter and ValidationProblemDetailsConverter are absent (MissingMemberHandling, TypeNameHandling and DateParseHandling do match). Neither gap can reach a scripted request body -- two levels of nesting, never a ProblemDetails -- so this was overstated prose, not a broken test. Restating the delta everywhere parity was claimed would leave four copies to rot, so ApiJsonSettingsTests pins it in both directions and the prose points at the pin. Also clears the three nullable warnings the replayer helpers introduced (CS8600/CS8604 on the action string, CS8603 on Bind<T>) and corrects the ExpectedSnapshot comment, whose last column is built from MediaItemId rather than looked up from the seeded title. Refs #563 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
c0a70d724a |
test(563): witness the Newtonsoft half of the binder claim, and narrow the prose to what reddens
The branch asserted that binding fixture bodies through ApiJsonSettings makes "a swap to a
lookalike serializer" redden. Measured, only half of that was true: replacing
ScriptedScheduleControllerTests' BodyBinderSettings with a plain `new JsonSerializerSettings()`
-- a Newtonsoft lookalike that has lost the production configuration -- left all 8 tests green.
Only the System.Text.Json swap reddened. So the production edits the branch makes for that
coupling (ErsatzTV/Serialization/ApiJsonSettings.cs and the Startup rewrite) were justified in
four places by a hazard no test could see.
Both halves are now real. Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null
binds `{"order": null}` and posts it to AddCollection: NullValueHandling.Ignore keeps
ContentCollection.Order at its declared "shuffle" and the call is a 200, where Newtonsoft's own
Include default writes the null through and AddCollection's Enum.TryParse returns a 400. That is
a behaviour difference a script would see, not a settings-shape assertion, so it is not a second
copy of the settings list.
Mutants, run 2026-09-05 over the 9-test fixture:
Bind -> System.Text.Json web defaults 2 red
BodyBinderSettings -> new() 1 red (was 0 before this commit)
OpenApi RuntimeSettings -> new() 4 red (write side, naming strategy)
What still nothing observes is Startup.ConfigureServices itself: re-inlining the
AddNewtonsoftJson lambda as a hand-copy of Apply reddens no test, because a byte-equal mirror is
behaviourally indistinguishable. ApiJsonSettings removes the duplicate rather than detecting its
drift, and docs/testing.md, the decision record and all four docstrings now say that instead of
claiming a detector. Drift confined to ReferenceLoopHandling or the StringEnumConverter is
witnessed by neither suite; that is stated rather than left implied.
Also files the 401 blind spot the record had described as "tracked separately" while nothing
tracked it. ersatztv#913 records the chain, verified from source: the filter is registered
globally, EndpointRequiresKey fail-closes every mutating verb, ScriptedScheduleController carries
no [SkipApiAuthorization], and neither ScriptedPlayoutBuilder nor entrypoint.py supplies a
credential.
Refs ersatztv#563 and ersatztv#913.
Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
|
||
|
|
b6ac7febbe |
style(563): rewrap the residue paragraph in the ScriptedScheduleControllerTests docstring
No content change. 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 |