Compare commits

...
174 Commits
Author SHA1 Message Date
timothyandClaude Opus 5.5 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>
2026-10-22 06:24:18 +00:00
timothyandClaude Fable 5.1 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
2026-09-05 20:52:18 +00:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:15 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:15 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:15 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:15 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:15 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:15 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:15 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:15 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:15 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:15 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:15 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:15 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:15 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:15 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:14 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:14 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:14 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:14 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 21:35:14 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 19:29:44 +00:00
timothyandClaude Fable 5.1 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
2026-09-05 19:47:05 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 19:05:31 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 18:38:20 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 16:01:18 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:59:16 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:59:16 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:59:16 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:59:16 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:59:16 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:58:48 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:58:48 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:58:48 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:58:48 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:58:48 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:58:48 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 13:56:59 +00:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:44 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:43 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:42 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:42 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:42 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:42 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:42 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:42 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:42 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:42 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:42 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 15:15:42 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 12:58:12 +00:00
timothyandClaude Fable 5.1 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
2026-09-05 14:13:56 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 13:48:15 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 13:14:09 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 12:43:58 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 12:07:17 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 08:43:19 +00:00
timothyandClaude Fable 5.1 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
2026-09-05 09:36:56 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 09:31:42 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 09:25:11 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 09:25:11 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 09:25:11 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 09:25:11 +02:00
timothyandClaude Fable 5.1 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
2026-09-05 09:25:11 +02:00
timothyandClaude Fable 5.1 16a6e2790f test(563): bind the fixture with the production body binder, and name the wrapper that is left
The docstring, the decision record and docs/testing.md all claimed the replay covered everything
except two hops. MVC model binding was a third: production binds /api/* bodies with Newtonsoft
(Startup -> AddNewtonsoftJson -> CustomContractResolver + StringEnumConverter) while the replay
deserialized with System.Text.Json. Measured on this tree: for the fixture's own bodies the two
agree, but for a body omitting the `required` member "collection" they diverge -- System.Text.Json
throws, Newtonsoft binds Collection = null and the action runs. So the fixture's stated purpose
("field names and casing match what the HTTP body binder accepts") was asserted by nothing, and a
fixture production would bind differently could still go green.

Rather than only widening the residue list, bind the way production binds. The registration moves
into ErsatzTV/Serialization/ApiJsonSettings.cs, Startup applies it from there, and both
OpenApiSerializerContractTests (which had its own mirror of the settings) and the scripted replay
now call that same function -- one definition, no copies to drift.
Production_Body_Binder_Ignores_Required_Members asserts both halves of the divergence THROUGH the
replay's own Bind helper, so pointing the replayer at another serializer reddens; the fixture's own
bodies cannot witness that swap.

The residue is now named honestly in all four places: the binding WRAPPER (input formatter, the
[ApiController] automatic 400 before an action runs) is uncovered, the serializer inside it is not.

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 09:25:11 +02:00
timothyandClaude Fable 5.1 0d2cd89782 docs(563): supersede the scripted-golden deferral with the in-process coverage rule
The deferral record described ScriptedScheduleController as a "1:1 pass-through"
to SchedulingEngine. It is not: an unparseable playback order is a 400, an
unparseable filler kind SILENTLY degrades to FillerKind.None, an unknown build id
is a 404, and the engine's no-progress InvalidOperationException is translated to
a 400. Carrying that wording forward would have shipped a false statement, so the
successor states a thin adapter with named mappings, each pinned by a test.

- new record testing.scripted-engine-in-process-net (active, since 2026-09-05)
- predecessor testing.scripted-playout-golden-deferred git mv'd to
  docs/decisions/archive/testing/ with frontmatter retargeted only; body prose
  byte-identical, so no Decisions-Edit trailer
- docs/decisions.md Index line retargeted to the archive path plus a new dated
  line for the successor
- catalog regenerated with scripts/build_decisions_catalog.py
- docs/testing.md: the Golden-file nets paragraph now points at the new coverage
  instead of "tracked in ersatztv#563"; a new "Scripted playout coverage" section
  states what is covered where and what is deliberately not covered (Cli.Wrap
  launch, Kestrel + Startup middleware, ApiAuthorizationFilter), dated
  2026-09-05; Timezone independence records the per-call TZ audit that decided
  which engine instructions the fixtures may use.

Refs #563

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 09:25:11 +02:00
timothyandClaude Fable 5.1 b3bc3ed115 test(563): pin per-item guide-group advancement, which mutant b showed was unmeasured
Removing SchedulingEngine.AddCountInternal's _state.AdvanceGuideGroup() left both
guide-group assertions green: the locked-group test only compared inside/outside
the group, and the snapshot only compared item 2 to item 0. Assert the actual
sequence instead.

Refs #563

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 09:25:11 +02:00
timothyandClaude Fable 5.1 02ecd484ed test(563): characterize the scripted engine build API in-process and replay a committed script fixture through the real controller
#381 deferred Scripted from the playout golden net because ScriptedPlayoutBuilder
shells out via Cli.Wrap to a user-authored program that drives SchedulingEngine
over HTTP loopback. #563 offered two arms: a full process+Kestrel integration
harness, or expanded engine coverage with the shell-out scoped out.

This takes the second arm, but delivers the first arm's "documented in-process
stand-in" so the scope-out is a measured claim rather than a prose one:

- SchedulingEngineTests grows from 1 test to 21, covering AddCollection/AddCount/
  AddAll/AddDuration/PadUntilExact, EPG guide-group locking, per-item history,
  the 20-call no-progress halt and its reset, and the anchor round-trip a
  Continue build restores from. Unknown-content-key cases assert false AND that
  nothing was scheduled.
- ScriptedScheduleControllerTests replays Fixtures/scripted-build.json through
  the real ScriptedScheduleController + ScriptedPlayoutBuilderService.MockSession
  + SchedulingEngine and pins a 13-item snapshot in the golden line format, so
  there is exactly one action->engine mapping under test — the production one. It
  also pins the three mappings the predecessor record's "1:1 pass-through"
  wording hides: 404 on an unknown build id, 400 on an unparseable playback
  order, a SILENT fall back to FillerKind.None on an unparseable filler kind, and
  the InvalidOperationException -> 400 translation.

MockSession was declared on IScriptedPlayoutBuilderService with zero callers;
it is the seam this needs and now has one.

Both fixtures are TZ-independent by construction (Chronological order plus only
instant-preserving instructions) and verified passing, not skipping, under
TZ=UTC, America/New_York, Australia/Lord_Howe and Asia/Kathmandu.

Refs #563

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 09:25:11 +02:00
timothyandClaude Fable 5.1 8575c02ad2 fix(881): a mutation-outcome claim is executed wherever it is written, bound to the sentence that makes it (#914)
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 14s
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 4m0s
fixes #881

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 06:40:34 +00:00
timothyandClaude Fable 5.1 0f8e73ab05 fix(881): the replacement clause asserted an absolute I had not measured
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 17s
PR Gates / decisions lifecycle (pull_request) Successful in 23s
PR Gates / Docs update reminder (pull_request) Successful in 14s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 11s
Review verdict / Set review-verdict status (pull_request_target) Successful in 37s
review-verdict/h10 Review-verdict: MERGEABLE @ 0f8e73a (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m41s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m47s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
The previous commit replaced "the order is load-bearing" with "and that is the
whole of what the order buys". What was measured is narrower: with the relevance
gate moved first, the three gates are each still witnessed refusing alone. That
does not establish the order buys nothing else - the reset placement is a second
candidate, unmeasured either way - so the absolute is gone from both sites and
what stays is the cost reason, which is readable from the control flow.

refs #881

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 07:09:14 +02:00
timothyandClaude Fable 5.1 f34828806d fix(881): the gate order I called load-bearing is a cost choice, measured
`verify_claim`'s docstring and the record's `mechanics` both said the relevance
gate must run LAST or the status and vacuity gates could never be witnessed
failing alone. Executed at ff65e5e7c: moving the `reset_sandbox` + reach
`verify_mutation` + `if not reach.ok:` block ahead of both earlier gates and
changing nothing else, then running `pytest scripts/tests/test_mutation_harness.py
-p no:randomly -k "GREEN_EXIT_STATUS or GREEN_VACUITY or GREEN_RELEVANCE or
UNKNOWN_outcome"` gives 4 passed. Two of those four assert the reasons the LATER
gates produce ("exited 1", "NOTHING PASSED"), so with the relevance gate first
both earlier gates were still read and still witnessed refusing alone. It cannot
hold, and the branch already said why one line away: the reach mutation injects a
failing test, so it reddens in every fixture except the relevance one, which is
what `_inert_claim_sandbox`'s own docstring states. What the order actually buys
is cost - the relevance gate is the only one of the three that costs a second run
of the proof - and that is what both sites now say. #881's own defect shape,
inside the record that establishes the rule against it.

Second, the record twice gave line-wrapping as the reason a sentence was
paraphrased rather than quoted. The branch's own first `CLAIMS` entry quotes a
sentence that spans a comment line break, embedding the `# ` continuation, and
the harness resolves it exactly once - so a wrapped sentence is quotable by this
very mechanism. The real reason at the calibration site is the replacement
itself: that sentence is not in the tree any more, measured 2026-09-05 by a
fixed-string search over `git ls-files`, which returns no file. At the second
site the referent (`docs/defect-shapes-773.md` section 4) is present and
quotable, so the causal clause is dropped and only the paraphrase marker stays.

refs #881

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 07:06:52 +02:00
timothyandClaude Fable 5.1 ff65e5e7c9 fix(881): the docstring quoted an outcome the branch had already narrowed
The `Claim` docstring motivated the GREEN direction by enumerating three corpus
sites and restating what each asserts. The middle restatement said
`scripts/check-doc-narrative.py` "says removing its `/dev/null` arm reddens no
test" — the universal a9341d841 removed from that file when it narrowed the
comment to the scope the harness actually executes
("`test_check_doc_narrative.py` stays green with this arm removed"). The
docstring was written before that narrowing and kept re-asserting the wider
claim, attributed to a file that no longer makes it: `git grep -F "reddens no
test"` returned exactly one hit, the line asserting it. That is #881's own
defect #2 reproduced inside the fix.

Restating an outcome is what makes it drift, so the enumeration now names the
three sites and the mutation each describes, states the shape they share, and
says why the outcome wording is not repeated. The only two copies of that
outcome left in the tree are the site comment and the `CLAIMS` quote bound to
it, which is the binding by construction. Both other members were re-checked
today and hold: `.gitea/workflows/review-verdict.yml:2411` and
`scripts/tests/hook_fire_isolation.py:82`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 06:25:54 +02:00
timothyandClaude Fable 5.1 b1c4cdb6d7 docs(881): rewrap the comment the narrowed claim left ragged
Line-wrap only; the declared quote and the mutated clause are untouched.

Refs #881

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 05:32:13 +02:00
timothyandClaude Fable 5.1 91a9fbf81a docs(881): name the reach mutation the way the rule field names it
The residual paragraph called it "a stronger mutation", which is a judgement
about size; what the mechanism requires is a second mutation of the same clause,
declared and required to redden the proof. One vocabulary across the rule field,
the manifest and the body, so a reader does not have to decide whether two
descriptions are the same thing.

Refs #881

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 05:18:43 +02:00
timothyandClaude Fable 5.1 a956cc6b8d fix(881): the same outcome claim, restated in the proof's own docstring
The claim the shipped GREEN entry binds — what removing the `+++ /dev/null` arm
does — was written twice: in `check-doc-narrative.py`, where the entry binds it,
and again in `test_a_DELETED_doc_is_not_reported_as_added_content`'s docstring,
where nothing does. That is the copy-of-an-outcome shape this rule forbids, in a
site class the rule names, found while reading the proof for the residual below.
The docstring now points at the manifest entry and keeps its rationale (a
deletion yields no `+` lines either way), which is the half the carve-out
protects.

The residual paragraph is also made exact rather than general. The reach
mutation proves the proof depends on the clause through the `b/` stripping every
scanned header goes through, not through the `/dev/null` arm itself, so in
general such a green cannot separate "no test feeds that input" from "the arm
changes nothing". For this entry it can, by reading the proof: the deleted-doc
test deletes a tracked file, and a deletion diff under the flags `run_diff` pins
carries a `+++ /dev/null` header — probed rather than reasoned.

Refs #881

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 05:05:59 +02:00
timothyandClaude Fable 5.1 a9341d8415 fix(881): a GREEN claim is only readable if its proof REACHES the clause
Round three found the one half of the new mechanism with no relevance gate.
`verify_claim`'s GREEN path read exactly two things — the run exited 0, and
something PASSED — and both are satisfied by a proof that never touches the
mutated file at all. Reproduced before fixing: retargeting the shipped GREEN
entry's proof from `test_check_doc_narrative.py` to `test_bom_guard_detection.py`
changed nothing, and the entry still reported verified. The RED direction never
had this hole, because a proof that ignores the mutation stays green and is
refused as "the clause is not load-bearing".

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

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

Also from that round:

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

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

Refs #881

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

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

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

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

refs #881

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

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

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

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

refs #881

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

refs #881

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 04:44:29 +02:00
timothyandClaude Fable 5.1 9e0e11030b docs(881): the third quotation on this branch that resolves to nothing
`docs/defect-shapes-773.md` §4's sentence was quoted verbatim-looking but with a
lowered initial capital, and the source wraps it across a line at
`evidence`/`behind`, so neither the written form nor the corrected one is
findable by grep. Same treatment as the `post-review-verdict.sh` one: paraphrase
without quote marks, keep the section reference, say why.

The section reference itself was checked - the sentence is at line 351, under
`## 4. Detectors`.

refs #881

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 04:44:29 +02:00
timothyandClaude Fable 5.1 80cd0a9ee9 docs(881): a quotation that resolves nowhere, and a paragraph contradicting the one above it
Two more in the same class, both in prose this branch wrote.

The calibration paragraph quoted `post-review-verdict.sh` as asserting "the
absent-entry check catches every case on its own". That string occurs in no file:
the comment wraps it across a line break at `catches`/`every`, so `git grep` for
it finds exactly one hit - the record asserting it. That is ersatztv#812's second
defect reproduced inside the record written to end it. Paraphrased without quote
marks and pinned to lines 316-317 at `efadbec29`, which is what this record's own
proposal-2 clause prescribes for a quotation that cannot be checked.

The closing paragraph called the scan's hits "the remaining population" and "a
backlog", three paragraphs after establishing that both figures are CANDIDATE
counts and that reading them as a backlog of real claims overstates them. The
closing text now says what is actually known: a place to look, with nobody having
established how many are claims.

refs #881

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 04:44:29 +02:00
timothyandClaude Fable 5.1 443df1391b docs(881): drop a figure anchored to the branch HEAD - the anchor moves
The sentence explaining why the scan pins a sha quoted a second figure for
`HEAD`, which is exactly the anchor-to-a-moving-state shape this record settles:
correct on this branch, wrong the moment anything else lands. The reason it was
supporting is checkable without a number - the paragraph's own prose, the pinned
command included, matches the pattern.

The first draft of that replacement said "twice over". Three lines of the
paragraph match, so the count is dropped rather than corrected; a count of
matching lines in a paragraph nobody will re-measure is the same defect one size
smaller.

refs #881

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 04:44:29 +02:00
timothyandClaude Fable 5.1 1b5dd80681 fix(881): regenerate the catalog the previous commit's rule edit staled
The generated catalog embeds each record's `rule`, so rewording the quote-scope
clause in 38bdf7bd0 left `docs/decisions/README.md` behind the record. Nine tests
red on it - the four `build_catalog_check_path` reformat cases, its stale-catalog
CLI proof, two `decisions_validate` main() cases, and the two mutation-harness
entries whose positive control runs that validator.

refs #881

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

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

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

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

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

refs #881

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

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

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

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

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

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

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

refs #881

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 04:44:29 +02:00
timothyandClaude Fable 5.1 4008f4b574 fix(881): exempt the claim binding from the index-population registry, and fix three prose claims in the change itself
`test_mutation_harness.py` now imports the shared index derivation, which
`test_every_index_derived_module_is_registered` requires be registered or exempt.
It is exempt: its population is `CLAIMS`, and it consults the index only per
member, to answer whether a declared `site` is a path git tracks. The exemption
list's own docstring counted its entries, so that count and its review date move
with it.

Three claims written by this change were falsified by this change, which is the
shape it exists to catch:
- the binding test's docstring said membership comes from the index "not from
  `Path.is_file`", while the same test now asserts existence with `is_file`;
- the record quoted the manifest docstring this change rewrites — an anchor to a
  state the commit moves, which the record itself rejects. It now anchors to
  `efadbec29`;
- the `Claim` docstring quoted three files without naming them. They are named.

refs #881

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

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

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

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

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

fixes #881

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 04:44:28 +02:00
timothyandClaude Fable 5.1 4a56d54283 fix(911): a should-fix finding sends the round back, a failed-lens round is an error, and a harness executes the loop (#912)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 6s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 28s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m46s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m11s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m59s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m37s
fixes #911

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 02:43:44 +00:00
timothyandClaude Fable 5.1 fd58b1df33 fix(554): the channel preview says why it is not playing, and only the preview opts into muted autoplay (#910)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 28s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 12s
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 4m46s
fixes #554

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 02:23:08 +00:00
timothyandClaude Fable 5.1 14e075174b fix(911): in a resume, the existing PR's stale head is the expected state until the push, not a finding
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 9s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ 14e0751 (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 14s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 18s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 15m39s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m38s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m41s
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
Three consecutive #554 resume rounds graded "PR #910's head is still the
first commit" as blocking; the push deliberately follows the loop. The
reviewer brief now says so when args.pr is set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 03:06:02 +02:00
timothyandClaude Fable 5.1 692affca97 fix(911): a round with no reviews is an error, the history binds each fix to the round it answered, and a harness executes the loop
A round in which every lens returned null read as a clean pass and would now
have been quoted verbatim into the PR body; it is an error before the push,
in the review loop and in the post-rebase round, which also gains the same
blocking-or-should-fix filter; a fixer that dies or stops (no done) is an
error too, the same test the implementer already gets. The history entry now
carries the fix that answered that round and only the commits that fix added
(a line-set difference against the previous branch log — a fixer that
reformats or rebases mid-loop defeats it, which is why the finisher is told
to read git show, not the list). An empty fix-commit set is described as
"answered without a new commit" when a fix round ran, and as "round one was
clean" only when none did.

web/scripts/orchestration-workflow-loop.test.mjs compiles the committed script
bodies with stubbed agent/parallel and pins eleven paths per script (22
tests). Measured: reverting the loop condition to blocking-only reddens six
cases per script (every case that needs a should-fix round to reach the
fixer); deleting any of the three zero-lens guards, the fixer guard or its
done half, or the empty-fix sentence branch reddens its own case, in both
scripts. web/vite.config.ts is untouched: it is pinned whole by
test_image_build_delegates_the_spa_suite.py, comments included.

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 02:50:23 +02:00
timothyandClaude Fable 5.1 350509f8bb docs(554): the opt-in's unreachability is about started, not about mount order
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 22s
PR Gates / decisions lifecycle (pull_request) Successful in 26s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 14s
Review verdict / Set review-verdict status (pull_request_target) Successful in 35s
review-verdict/h10 Review-verdict: MERGEABLE @ 350509f (base: main)
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 16m49s
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 33s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m20s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m1s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 45s
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
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m21s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
"its button renders only before the player has ever mounted" is loose: a channel
switch to a forced channel re-renders the button after a player has mounted for the
previous channel. The load-bearing fact is the one the code comment states — the
button renders only while `started` is false, and the only thing that un-starts the
panel is the channel reset that clears the flag in the same batch.

Refs #554

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 02:45:42 +02:00
timothyandClaude Fable 5.1 f119bfcb29 docs(554): the opt-in is a third path back to starting, and the invariant must say so
The hint's single-guard invariant was stated as "every path back to `starting` clears
the flag itself", enumerating Retry and a channel change. `onOptIn` is a third such
path and clears nothing, so the sentence was false as written — in the code comment,
in the Retry test's comment, and in docs/spa-conventions.md §5b.

Adding a clear to `onOptIn` would be dead code no test could distinguish, which is the
exact shape this branch removed from `onPlaying`. The omission is correct for a reason
none of the three places stated: the opt-in button renders only while `started` is
false, `started` only goes false in the render-phase reset that clears the flag two
lines later, and no player exists to set the flag while `started` is false. State that
exception, and pin the reachability premise it rests on with a test that fails if the
opt-in button outlives the mounted player.

Refs #554

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 02:42:39 +02:00
timothyandClaude Fable 5.1 b6b4fb6619 test(554): pin both clears the hint's single guard depends on
Dropping the onPlaying clear made the `state === 'starting'` render guard the
hint's only guard, which moves the burden onto the two paths back to
`starting`: each has to clear `autoplayBlocked` itself. Both were assertable
but unasserted — either `setAutoplayBlocked(false)` could be deleted with the
whole panel suite green, so the invariant the code comment and
docs/spa-conventions.md §5b both state was unpinned in both of its named paths.

Add one test per path (Retry; a channel switch), each asserting the hint is
gone while the panel is back at `starting` — so the render guard cannot be
what hid it. Each also asserts the player really re-mounted (loadSource count
/ last URL, plus a non-null <video>), so the hint cannot be absent merely
because the `resolvedSrc` block is unrendered.

Measured on this tree, each mutation caught by exactly one test:
deleting the onRetry clear reds only 'clicking Retry clears the
autoplay-blocked hint' (Tests 1 failed | 25 passed); deleting the
render-phase reset clear reds only 'clears the autoplay-blocked hint when
switching to a different channel' (1 failed | 25 passed); replacing
`autoplayBlocked && state === 'starting' &&` with `autoplayBlocked &&` reds
one test too. Unmutated: 26 passed.

Refs #554

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 02:08:36 +02:00
timothyandClaude Fable 5.1 4e042fb7de fix(554): the hint's cause must be measured, and muting is the consumer's call
Round one measured three defects in the first commit.

onAutoplayBlocked fired on ANY rejected video.play(), so the panel could
say "autoplay was blocked" when it was not. A play() interrupted by
teardown rejects with AbortError — which is exactly what the panel's own
Retry produces while the MANIFEST_PARSED play() is still pending — and
because the element was muted, a genuine NotAllowedError is the rare
case, so the realistic firings were the mislabelled ones. Report only a
DOMException named NotAllowedError, on both the MSE and native paths.

`muted` was applied to the shared player unconditionally, which silently
muted the playback-troubleshooting screen — the tool whose job includes
verifying the audio side of an FFmpeg profile, and which the legacy
Blazor player never muted. Make it an opt-in `muted` prop defaulting to
false; the channel preview passes it, troubleshooting does not, and a
test on each side pins its own value.

The two clauses hiding the hint once playback starts masked each other:
removing either alone left the panel suite green. Every path back to
'starting' (Retry, a channel change) already clears the flag itself, so
the clear in onPlaying could never be the load-bearing guard — drop it
and let the `state === 'starting'` render guard be the single pinned one.

Also cover the native-HLS (Safari) branch, which no test had ever
executed: its play() kick, its playing/error wiring, and both autoplay
rejection names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 01:46:37 +02:00
timothyandClaude Fable 5.1 ade14a7521 fix(911): a should-fix finding sends the round back, and the finisher reports the recorded review history
The fixer brief already said "fix every blocking and should-fix one"; the loop
condition alone disagreed, so a merge-worded round with real defects skipped
the fixer and the finisher attested to fixes it never saw (#554 / PR #910).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 01:41:00 +02:00
timothyandClaude Fable 5.1 02e1c583e9 fix(554): mute the preview player so autoplay is never blocked
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 21s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 24s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 17m7s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m47s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
review-verdict/h10 Awaiting review verdict for 02e1c58
Review verdict / Set review-verdict status (pull_request_target) Successful in 33s
HlsPlayer's manifest GET can block until segments exist (unbounded
maxTimeToFirstByteMs), so MANIFEST_PARSED can arrive past the
browser's transient user-activation window and video.play() gets
rejected as blocked autoplay — the channel preview panel then sat at
"starting" over a black frame with no hint the operator just needed
to press play.

Render the <video> element muted (browsers permit autoplay of muted
media without user activation) so the common case starts on its own,
and add an optional onAutoplayBlocked callback for the residual case
(stricter policy/extension) that the channel preview panel wires to a
"press play" hint shown only while still starting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 01:12:39 +02:00
timothyandClaude Fable 5.1 e9de5dcb1b fix(830): a write failure reports to a surface that outlives the dialog that started it (#878)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 17s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 22s
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 4m54s
fixes #830

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-04 23:02:44 +00:00
timothyandClaude Fable 5.1 e1d211cd1b test(830): pin which ARM the call site reaches, not only that the hook has one
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
PR Gates / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 10s
Review verdict / Set review-verdict status (pull_request_target) Successful in 22s
review-verdict/h10 Review-verdict: MERGEABLE @ e1d211c (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m34s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m5s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Review measured M6: swapping `reportFailure(...)` for `onAddFailed(...)` in
`AddItemsDialog`'s catch left all 490 tests across the 42 `src/screens` files
green. Under that mutant a failure that lands while the dialog is STILL OPEN
renders into `ManualItemsView`'s screen banner, which sits behind the dialog's
`createPortal` panel with `aria-modal="true"` — covered for sighted users,
hidden from AT, and the surface the user is actually looking at stays blank.
That is the exact shape the decision record calls "its own defect", and the
whole gap was the call-site wiring: the hook's inline branch is pinned at unit
level in `hooks.test.tsx`, but a unit test of the hook cannot see which arm a
consumer reaches.

Adds the integration assertion: fail the POST with the dialog still up, assert
the message is inside `[role="dialog"]` and appears exactly once in the tree.
Re-executed the mutation with it in place — 1 failed / 490 passed, and the red
is this test alone.

Records the new pin as mechanics (5) on
`spa.dismissible-write-failure-reporting` and the general form in
`spa-conventions.md` §3c, so the next site wired to the hook pins both arms at
its call site rather than inheriting the hook's unit coverage.

Refs #830

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 00:23:09 +02:00
timothyandClaude Opus 5 e4c346e649 test(830): pin the ORDINARY close too — the negative test did not cover it
Round 6 verdict was MERGEABLE with two follow-ups; both are one-liners on lines this branch just
touched, so they are in rather than deferred.

L11. I believed the suite covered "a normal add still closes the dialog". Review MEASURED that it
did not: deleting the `onClose()` call entirely -- so a successful add leaves the picker open
forever -- kept the whole suite green, 1277/1277. The new #830 test only pins the NEGATIVE direction
(do not close when unmounted), so a future edit dropping the call, believing the guard had made it
dead, would have shipped silently. The Song add test now asserts the dialog closes; with that line,
the same deletion reddens. Both directions of the report/dismiss split are pinned.

Worth naming the shape: I asserted coverage from plausibility rather than from a mutation, in the
same PR whose whole subject is claims that were written down before they were measured.

N12. The guards test's "exactly ONE post-await write to state THIS component owns" is still true,
but it now reads as a census of `mountedRef` reads, and `submit` has two -- the success path's
guarded `onClose()` is the other, which that failure-path test never reaches. Added the clause so
nobody derives the guard population from that number.

1277 tests green, tsc/eslint/build clean, pytest 1228 passed, validator OK, catalog no drift.

The two red CI contexts on the previous head are runner flakes, not this branch: both failed inside
`Post Checkout` with `Cannot find module '/var/run/act/actions/<hash>/dist/index.js'`, their logs
are timestamped 19:18 (before that head existed), this branch touches no CI or docker/ci file, and
both contexts were green on its earlier heads.

refs #830, #877

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY
2026-09-05 00:23:09 +02:00
timothyandClaude Opus 5 86e9ad41a6 fix(830): gate onClose at the exemplar — the convention contradicted its own reference
Round 5 found that §3c and the `rule:` field now instruct readers to gate the DISMISS request, while
`AddItemsDialog` -- the one site this record names as "the shape fixed here" -- called `onClose()`
unguarded, with a comment arguing that was correct. So a reader following the convention wrote the
gate and a reader copying the reference implementation did not.

The defect is pre-existing; the CONTRADICTION is mine, introduced when round 3 withdrew H1's code
but kept the convention it produced. I checked the docs against the withdrawn addTo code and did not
re-check them against the exemplar that stayed.

Measured at this site: submit, Escape mid-request, reopen the picker to retry, first POST returns
204 -> the stale instance's `onClose()` (`() => setPickerOpen(false)`) closes the dialog the user
just reopened, discarding the selection they rebuilt. Identical mechanism to the addTo clobber.

Unlike the addTo layer, the one-line gate IS sufficient here, and that difference is the point:
`AddItemsDialog`'s parent has no competing closer (`onAdded` is `load`, which never touches
`pickerOpen`), whereas `AddToMenu.handleAdded` closes its dialog itself. That is now stated in the
record as the concrete reason one half shipped and the other went to #877.

- `onAdded()` stays unguarded -- it REPORTS, and the parent's list reload must survive dismissal
- `onClose()` is guarded -- it REQUESTS A DISMISSAL, and after dismissal it aims at whatever the
  user opened next
- comment rewritten to say which is which and why, instead of defending both as "belong to the
  still-mounted PARENT"

Pinned, and nothing pinned it before: "a late SUCCESS does not close the dialog the user reopened
after dismissing (#830)". It carries an anti-vacuity check that the late response was actually
processed -- `onAdded` is `load`, so a second GET of the items endpoint must have happened -- because
otherwise "the dialog is still open" holds trivially. Executed: deleting the `if (mountedRef.current)`
around `onClose()` reddens it alone.

Also rewrapped five record body lines left ragged by earlier splices.

1277 tests green, tsc/eslint/build clean, pytest 1228 passed, validator OK, catalog no drift.

refs #830, #877

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY
2026-09-05 00:23:09 +02:00
timothyandClaude Opus 5 4b12125f4d docs(830): drop a coverage claim this record's own mechanics field contradicts
Round 4 found one clause left, and it is a good example of the thing this record is about. The
one-consumer paragraph said `hooks.test.tsx` "is the ONLY thing pinning the diverted branch".
Measured at the previous head, disarming `reportRef.current(message)` reddens THREE tests -- both
hooks.test.tsx divert tests AND the CollectionsScreen integration test -- which is exactly what the
`mechanics:` field of the same record says 58 lines earlier. So the record asserted a coverage fact
and then contradicted itself.

The concrete harm is not the inconsistency: a future session pruning tests reads "hooks.test.tsx is
the only pin", concludes the CollectionsScreen #830 test is redundant, and deletes the only
end-to-end pin of the whole path -- the one that actually drives Escape-dismissal through the real
dialog. Clause dropped; the argument the paragraph needed (the hook's shape earns its own unit
tests) survives without it.

The clause originated in the reviewer's round-3 wording and I transcribed it without checking it
against a field I had written myself two rounds earlier. Worth recording: a review finding is not
exempt from verification just because it came from the reviewer.

Also:
- the `AddToMenu` clobber sentence now splits what was MEASURED (a late success closes a reopened
  dialog) from what was READ (both parents call `clearSelection()` unconditionally, so the wipe
  follows). On a record whose subject is over-attributing measurements, that distinction has to hold
  in its own prose.
- §3c now carries the same "nothing diverts to those screens today" disclaimer the record's limit
  (2) has, so the two artifacts say the same thing
- rewrapped one 141-char comment line left ragged by the previous round's splice

Docs only, plus one comment rewrap. 1276 tests green, tsc/eslint/build/validator/catalog clean.

refs #830, #877

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY
2026-09-05 00:23:09 +02:00
timothyandClaude Opus 5 626cbfbf9d docs(830): three sentences survived the revert of the code they described
Round 3 verified the withdrawal itself is clean -- the eight reverted files are byte-identical to
origin/main, no orphans, and the mutation gives the stated three reds -- but found docs still
asserting the withdrawn change shipped. That is the stale-comment failure in its usual form: after a
retraction, the retracted WORDING has to be swept, not just the code.

- `hooks.ts` said "#830 removed that gate", flatly false at this head, in the hook's own doc comment
  right above the export -- the first thing a maintainer reads. It also carried round 2's framing
  ("both halves of the outcome") as the hook's purpose, when what ships carries only the failure
  half. Rewritten to the present tense of the shipped tree.
- The `rule:` field still said the surviving surface "differs per screen", naming MediaBrowseScreen
  and SearchScreen as wired. They wire nothing. This one matters beyond an ordinary sentence:
  `rule:` is the canonical summary, it is what the catalog row shows, and it is what gets mirrored
  per-key into MemPalace -- so it is the version a future session retrieves WITHOUT opening the
  file. Now: exactly one wired screen, the Toast pair named as a CANDIDATE.
- The "two limits" bullet described a failure being diverted to those same screens and announced
  politely. Nothing can divert there -- they receive no reporting callback. Restated as the limit
  the second surface will have when it is wired.
- `onFailed` in a hooks.ts comment was a dangling identifier; the real prop is `onAddFailed`.

Also added the caveat the reviewer asked for rather than leaving it to be discovered: this is a
shared hook with exactly ONE consumer. It earns that shape (directly unit-tested, and those tests
are the only thing pinning the diverted branch; prescribed by §3c; #877 queued as a second
consumer) -- but #877 may land a shared reporting SURFACE instead of a per-site prop, in which case
the second consumer never arrives. Accepted risk, now written down.

Docs only. No code change, 1276 tests still green, tsc/eslint/validator/catalog clean.

refs #830, #877

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY
2026-09-05 00:23:09 +02:00
timothyandClaude Opus 5 de9432ff49 fix(830): withdraw the media/addTo extension — two rounds, two defects, one coupled gate
Round 2 of adversarial review found that my round-1 fix introduced an adjacent defect, and it is the
same mechanism both times: `onAdded?.()` and `onClose()` sat behind ONE unmount gate in the four
`media/addTo/` dialogs, and those two callbacks do not mean the same thing.

- Gate both (origin/main): a write that SUCCEEDS after dismissal reports nothing. Measured on
  `AddToCollectionDialog` -- `onAdded` called 0 times after dismissal. That was round 1's finding.
- Un-gate both (my round-1 fix): a late success closes a dialog the user REOPENED to retry, and on
  SearchScreen/MediaBrowseScreen `clearSelection()` wipes a multi-select they rebuilt. Measured
  against the real `AddToMenu`. That was round 2's finding, and I introduced it.
- Gate only `onClose`: still wrong on its own, because `AddToMenu.handleAdded` nulls the dialog
  itself. Needs three coupled edits across five files -- plus a genuine product question nobody has
  answered: should `clearSelection()` fire for a write the user walked away from?

That is a design change, not a bug fix, and #830 never asked for it -- the issue is about
`AddItemsDialog`. Two defects from one mechanism in two rounds is the signal to stop widening, so
the `media/addTo/` extension is REVERTED here and moves to #877 with every measurement attached
(#877 comment). What ships is the thing the issue asked for, proved:

- `useDismissSafeError` + `AddItemsDialog` + `CollectionsScreen` wiring
- the witnessed red is unchanged: disarming `reportRef.current(message)` reddens the integration
  test on `Unable to find an element with the text: Request failed with status 500`

Docs now describe what is actually true rather than what I hoped:
- the record says ONE A1 site is fixed and explains why the other four were withdrawn, keeping the
  wrong first claim visible because "one site read, four assumed" is the lesson
- §3c splits the rule the round-2 defect came from: report the OUTCOME unguarded, gate the DISMISS
  request separately -- the earlier text lumped `onClose` in with `onAdded` and would have
  propagated the clobber to the next screen that adopted it
- §5c no longer tells authors to wire an `onFailed` that the addTo layer does not have; it says the
  layer has no failure channel at all and points at #877
- the reporting prop is REQUIRED where the host has a surface (`AddItemsDialog.onAddFailed`), which
  is what the docs now say instead of calling it optional
- `mechanics:` no longer implies the shared clause reddens one test; it reddens three, so re-running
  the mutation should expect three

refs #830, #877

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY
2026-09-05 00:23:09 +02:00
timothyandClaude Opus 5 f61764d051 fix(830): report SUCCESS past dismissal too — the review measured my claim false
Adversarial review (cold, worktree-isolated) blocked the first commit on its central prose claim,
correctly. I wrote "at every one of these sites SUCCESS already outlives dismissal" into three
durable artifacts -- the decision record, spa-conventions §3c and the hooks.ts header -- after
reading ONE site. `AddItemsDialog` does report success past dismissal and says so in a comment; I
generalised from it. The review probed the other four instead and MEASURED `onAdded` called 0 times
after dismissal: all four `media/addTo/` dialogs gated `onAdded?.()`/`onSaved?.()` behind their own
`activeRef`, exactly like the failure path.

So after the first commit those four were still asymmetric, just inverted: dismiss-then-fail loud,
dismiss-then-succeed silent -- and additionally leaving the caller's selection state stale, because
SearchScreen's `onAddedToSelectionTarget` never ran to clear it. The record's own advice ("add the
failure counterpart") followed literally would have reproduced it.

Fixes, each proved by execution:
- the `activeRef` gate above `onAdded?.()`/`onSaved?.()` is removed in all four dialogs; those two
  statements belong to the still-mounted PARENT, which is the reasoning AddItemsDialog already had
- `AddToCollectionDialog.test.tsx` covers the media/addTo half in BOTH directions. It had NO
  coverage before: reverting `reportFailure` to `setInlineError` in all four left the whole suite
  green. Restoring the success gate reddens the SUCCESS test alone; disarming
  `reportRef.current(message)` reddens the FAILURE test alone
- the three prose sites now say what was measured, and the record keeps the wrong first version
  visible, because "one site read, four assumed, written down before measuring" is the finding

Also from the review:
- hooks.ts said "React 18"; package.json pins 19.2.7. Now "React 18+"
- the "nothing better to do" comment overclaimed: diversion reaches ONE level, so Back out of a
  collection mid-add still drops the message. Stated, with where it would be fixed
- recorded two limits rather than leaving them to be rediscovered: useIsMountedRef clears in a
  PASSIVE effect cleanup, leaving a narrow window where the message renders inline into a detached
  tree (useLayoutEffect would close it, but that hook is shared by every async caller -- #877, not a
  bug fix); and Toast is role="status" with one last-writer-wins slot, so it is not equivalent to
  CollectionsScreen's role="alert"
- §5c now cross-links §3c, since that is the section a screen author reads before wiring AddToMenu
- the sweep count is 67 caller-owned + ConfirmDialog's own internal <Dialog>

Three other media/addTo dialogs remain unpinned; they are identical in shape to the covered one,
which is a reason to expect the same behaviour, not evidence of it. Said so in the record.

refs #830, #877

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY
2026-09-05 00:23:08 +02:00
timothyandClaude Opus 5 9ba397e1dd fix(830): a write failure reports to a surface that outlives the dialog that started it
`AddItemsDialog.submit` POSTed to `/api/v1/collections/{id}/items` and reported failure into a
banner rendered from its OWN state. The dialog is dismissible mid-request through three paths that
never consult `adding` -- Escape and a backdrop click (both `useOverlayBehavior`) and the header
close button -- and the caller remounts it on `key={`add-${pickerOpen}`}`, so dismissal genuinely
unmounts it. Select 12 items, Add, press Escape, the request fails: nothing surfaces, the list
reloads unchanged, and the user believes 12 items were added.

drop deliberate rather than accidental. A deliberate drop is still a user who is told nothing.

The asymmetry is the finding: SUCCESS already outlived dismissal everywhere here, because it is
reported through a parent callback (`onAdded`/`onDone`, which the screens turn into a `Toast`).
Only failure died with the surface. So this is not a new notification system -- it routes failure
through the channel success already uses. `AddToMenu` had `onDone` and no counterpart at all.

`useDismissSafeError` (`web/src/hooks.ts`) renders the message INLINE while the surface is mounted
-- the better surface, since it keeps the user's selections and context -- and diverts to a
caller-supplied `onFailed` once it is gone. The surviving surface belongs to the parent and differs
per screen (a `role="alert"` banner on CollectionsScreen, `notice`+`Toast` on MediaBrowse/Search),
so it is a prop contract rather than a rendering decision. Gating dismissal on the busy flag was
considered and rejected: it traps the user behind an in-flight request with no cancel path, and
would not cancel the write anyway.

Applied to the A1 shape -- where the surface owns the error state and is really unmounted:
AddItemsDialog plus the four `web/src/media/addTo/` dialogs, whose failures previously could not
reach the screen Toast that already showed their successes.

Proofs, executed rather than described:
- deleting `reportRef.current(message)` alone reddens the new CollectionsScreen test on
  `Unable to find an element with the text: Request failed with status 500`
- `hooks.test.tsx` pins both branches directly, plus that the report goes through the LATEST
  callback rather than the one captured on first render
- `CollectionsScreen.guards.test.tsx`'s is-mounted read count moves 2 -> 1 because the catch's
  guard migrated into the hook (its `...actual` module mock cannot see the hook's internal
  `useIsMountedRef()`); removing the surviving `finally` guard takes it to 0 and reddens, so the
  anti-masking property that count was added for is intact

Scope is stated rather than implied. A sweep of all 68 Dialog/ConfirmDialog/SlideOver call sites
found three shapes; only A1 is fixed here. A2 -- error state that survives but whose render site is
gated by the same condition dismissal clears, mostly delete-confirm flows -- is left open in #877
because its right answer is probably a shared surface, not twenty prop threads.

fixes #830
refs #877, #740, #685

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY
2026-09-05 00:23:08 +02:00
timothyandClaude Fable 5.1 92db6dc81e feat(907): orchestrated sessions — mechanics page, standing prompt, pick/build/resume workflows (#908)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 13s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 22s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 12s
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 4m23s
fixes #907

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-04 22:09:14 +00:00
timothyandClaude Fable 5.1 208ae00953 fix(907): round four — a resume that updated its existing PR is a success, and a post-push error says so
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 41s
review-verdict/h10 Review-verdict: MERGEABLE @ 208ae00 (base: main)
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 50s
Review verdict / Set review-verdict status (pull_request_target) Successful in 12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m48s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m35s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m23s
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
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 33s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 19s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m33s
PR Gates / decisions lifecycle (pull_request) Successful in 13s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-04 23:39:20 +02:00
timothyandClaude Fable 5.1 181847f70a fix(907): round three — the gate is a function of port and worktree, the port guard demands an integer
Embedding the slot's gate text in the reviewer brief carried the slot worktree
into the one prompt that forbids it, and a substring port substitution could
rewrite a path containing the same digits; gateFor(port, where) renders each
brief for its own tree and port. The port guard accepted "", null and false
through Number(); it now requires a JS integer in (1024, 65000). The finisher
schema requires only patch_changed, and a done report without a PR URL or head
sha is an error rather than a placeholder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-04 23:34:33 +02:00
timothyandClaude Fable 5.1 a0203f2863 fix(907): round two — a guard denial means a foreign worktree, required keys under every branch, a gated post-rebase round
The marker-overwrite instruction asserted an antecedent no agent can verify
and, since same-session worktrees carry no marker, could only fire in another
session's worktree; the scripts now stop and report. `ran` and `patch_changed`
move into required keys of their own schemas so a missing field cannot read
as a successful cross-family review or an unchanged patch. A blocking finding
in the post-rebase round now returns an error like every other failure path.
Each reviewer lens gets its own E2E port; the gate text travels with the
reviewer brief. The standing prompt no longer contradicts the substitution
the scripts perform; the record names patch-id, the mechanism the scripts use.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-04 23:25:49 +02:00
timothyandClaude Fable 5.1 63256afd96 fix(907): answer the cold review — drop the E2E lock, scope force-with-lease in a record, referee ticks the boxes
The mkdir lock around scripts/e2e-local.sh serialised the launch, not the run
(the launcher returns with the server up), and its stale-holder path double-
acquired in 4 of 91 measured races; the launcher's documented conflict is its
per-worktree wwwroot, so slots now run on their own port and the lock is gone
with its inventory row. process.orchestrated-session records the two scopings
the harness needed: a rebase pushed with --force-with-lease as the one sanctioned
rewrite, and the referee as the only agent that ticks Done-when boxes. Scripts:
required-arg guard, per-issue claim probe, reviewer fetch recipe, codex fallback
to a cold review-only agent with the substitution stated in the PR body, rebase
before the review loop with a patch-id check at the push, non-interactive
squash recipe, Land phase. README bullets re-parented; kickoff bullet keyed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-04 23:07:59 +02:00
timothyandClaude Fable 5.1 a380129700 feat(907): orchestrated sessions — mechanics page, standing prompt, pick/build/resume workflows, E2E gate
The single-issue kickoff stays as it is; this adds the layer that runs several of
those under one referee. docs/handoffs/orchestration.md owns roles and sizing,
one worktree per issue under ~/orca/workspaces/ersatztv/, the landing order with
the review loop inside the worktree before the single push, and the merge through
the consent hook. Three Workflow scripts encode it: a picker over
scripts/select-queue.sh with two refuters, an issue-build pipeline (claim, recon,
implement, gate, cold review with a cross-family runner for the rubric's risk
classes, fix loop, finisher), and a resume pipeline for a paused branch.
scripts/e2e-gate.sh serialises live-E2E across worktrees because e2e-local.sh
refuses concurrent runs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-04 22:27:42 +02:00
timothy efadbec297 fix(876): sweep session narrative out of hooks, workflows, scripts, tests and code comments; grow the detector to the process corpus (#906)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 12s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 20s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 12s
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 5m10s
2026-09-03 19:26:01 +00:00
timothyandClaude Fable 5.1 a7d91bf15a fix(876): sweep session narrative out of hooks, workflows, scripts, tests and code comments; grow the detector to the process corpus
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 35s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 57s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 1m0s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ a7d91bf (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
`docs.no-session-narrative` reaches every durable artifact, but its detector scanned only
`docs/**/*.md` and root markdown, and nothing had ever swept the rest. The issue named four sites
from one grep and called them a floor. Deriving the population instead — a whitespace-joined sweep
over every tracked file outside the detector, for the detector's own phrasings plus the attribution
and review-round class #812 found — gave 453 sites in 108 files at `fb5592971`, and a second pass
for phrasings the first list missed (hyphenated `round-N`, "an earlier version", "the reviewer
proved") added residuals in the same files. Every site was classified with #812's three
dispositions (CUT / SEVER / KEEP with its sub-kind) under the who-benefits test; the per-site
manifests are on the PR. The rejected designs, tested-and-rejected fixtures, measurements and
traps stay; the attribution of who found them and the round in which they were found go.

The detector's population grows to `.claude/`, `.gitea/`, `.husky/` and `scripts/` regardless
of extension, minus the detector and its own test (whose fixtures ARE the phrasings) and minus
`scripts/tests/fixtures/` (test data, including decision-record copies — the same reasoning as
the records' own exemption, and what keeps the record's depth measurement true), and `--all`
lists tracked REGULAR files only — a symlink's content is its target and a gitlink has none. The #812
argument for leaving `docs/superpowers/**` in the population runs the other way here: `--diff`
sees only ADDED lines, and 287 of the 453 sites were under 30 days old — this corpus is where
narrative is being added, so the advisory nudge has reach. Density agrees: 56 line-mode hits over
the 113 regular files the predicate admits, against 9 over 66 docs files before #812. `web/` and C# stay out on the same
measurement (3 of 74 PATTERNS-matching sites, ~4,600 files). The predicate did not grow: PATTERNS
matched 74 of 453 sites, and widening the word list to the attribution class is the treadmill
the withdrawn parity test ran on. The population oracle is restated over segments with the new
arms, the synthetic cross product gains the process heads and non-markdown extensions, a fixture
witnesses that a tracked symlink is neither scanned nor counted, a `.py.bak` axis separates a
by-name exemption from a `startswith` over the same tuple, and eight mutants (drop the process
arm, drop the by-name exemption, exempt by `startswith`, drop or add a prefix, drop the fixtures
exemption, list only markdown, drop the symlink filter, test the mode per row instead of per
path) each
redden it. A pre-existing silent drop in `--diff` goes with it: git tab-terminates a `+++`
filename that contains a space, and the kept tab made `is_scanned_path` refuse the file with no
notice — fixed, with a positive control and its own mutant.

Code is unchanged by construction, measured per file type against `origin/main`: Python modules
are AST-equal with docstrings stripped, except `#` lines inside the embedded fixture programs
(string literals) of three test modules; workflows differ only in `#` lines inside `run:` block
scalars; shell, C#, TypeScript and jq are equal with comment lines stripped. The stated
exceptions: the detector and its test, 26 vitest titles that carried review-round or severity
labels or a reviewer attribution (call sites whose title changed — every changed title line
walked back to its `it(` / `it.each(...)(` anchor, so a `' + '` concatenation counts once), two
registry note strings and the mutation manifest's prose fields. scripts/tests: 1565 passed.
Web: lint, typecheck, 1319 tests green. Closes #876.

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEcBoFw7ctrf3Nb7R7x7wk
2026-09-03 20:51:39 +02:00
timothyandClaude Opus 5 fb55929711 fix(869,893): re-establish the 1.25.4-dated CI claims on 1.27.1, and settle the page_statuses asymmetry from source (#905)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m24s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m17s
Finishes the 1.25.4-dated CI claim sweep #747 deliberately left incomplete (#869), and answers #893 from the Gitea v1.27.1 source instead of inferring it from a header. Docs and comments only - zero non-comment changes in scripts/ and .gitea/.

Population derived with `git ls-files`, not from the issue's item list: 17 files, 43 occurrences of `1.25.4`, against the 4 items #869 named.

Re-established on 1.27.1: the `creator`-attribution claim the H10 allow-list rests on (4 merged heads, both endpoints); the scope enum (no `status` scope); the `reqRepoWriter(unit.TypeCode)` gate; the `write:package` 403 (live probe with a read control 200 and a write control 201, throwaway repo, artifacts deleted); the absence of any REST cancel route (from source, which a 404 alone cannot establish); and `pull_request`/`pull_request_target` definition resolution.

#893: `/statuses/{sha}` does NOT drop rows after pagination. `getCommitStatuses` appends unconditionally and its only filter is a SQL WHERE in the same query as the LIMIT/OFFSET, so an empty page really is the end, `page_statuses` terminating on its first empty page is safe, and the asymmetry with `count_pr_mutations` is correct - recorded with its reason and a date so it is not tidied away.

Corrected rather than re-dated: the `--depth=1` no-merge-base claim was filed against the wrong axis (a git property, re-probed on git 2.55.0), and `enable_bypass_allowlist` postdating 1.25.4 had an issue body as its only provenance.

Five cold review rounds plus a cross-family Codex pass. They caught a wrong MECHANISM for `creator: null` (it is `CreatorID == -2`, not `== 0`), an evidence count that straddled the upgrade, and a reason for not re-probing MCP `cancel_run` that was invented - all fixed, final verdict CLEAN.

fixes #869
fixes #893

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

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

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

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

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

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

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

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

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

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

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

fixes #855
2026-08-30 19:25:23 +00:00
timothy 4cd6929735 fix(887): the image build builds the SPA and does not test it (#899)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 20s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m14s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m5s
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 4m1s
`docker/Dockerfile`'s web-build stage ran the SPA vitest suite with two hand-written
`--exclude`d spec paths. The stage is gitless twice over, and the suite has members
needing a git checkout or the git binary, so that list was a population nothing derives.
#883 added a third member without updating it; because `Build & push image (amd64)` is
`if: github.event_name != 'pull_request'`, the red was unreachable on a PR and landed on
`main` and the `v*` tag path. Every image build has failed since.

The list is removed rather than extended: the stage builds the SPA and does not test it,
and the suite runs once, unfiltered, in the `test` job that `build` already `needs:`.

`scripts/tests/test_image_build_delegates_the_spa_suite.py` holds the invariant in three
parts, because the first two together still certify a publish on which the suite never
ran. It PINS command text rather than parsing it: three earlier versions asked what a
command MEANS and were wrong nine times, and a partial match of `web/vite.config.ts` was
then defeated seven more ways, so both mechanisms were withdrawn rather than respelled.

The transferable rule, recorded in the guard and the decision record: a pin assumes it is
pinning the artifact that still DECIDES. Every route found was authority moving where the
pin was not looking — another file, another occurrence, another workflow, or a hook the
pinned command invokes.

Nine independent cold-review rounds, eight BLOCKED. 73-mutant development battery, 0
missed; one declared clause mutation harness-executed per suite.

fixes #887
2026-08-30 16:06:21 +00:00
timothy 4b7ede80b8 Merge pull request 'docs(release): record the v26.15.0 release notes' (#898) from release/v26.15.0-notes into main
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 6s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 12s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 15s
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
2026-08-30 15:25:58 +00:00
timothyandClaude Opus 5 9da0020462 docs(887): sweep the withdrawal through the record — it still described the withdrawn pin
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 17s
PR Gates / Docs update reminder (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 16s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 12s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
review-verdict/h10 Review-verdict: MERGEABLE @ 9da0020 (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m53s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m2s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
Round 9 returned MERGEABLE with BLOCKER and HIGH empty. Every remaining item was a
sentence, and every one erred by UNDERSTATING the guard — which is the safe direction and
still worth fixing, because the decision record is what CLAUDE.md routes convention
lookups to.

The record's `rule:` still listed "`web/vite.config.ts`'s `test:` block" among the pinned
things — the very mechanism the previous commit withdrew — and named only `vitest.config.*`
as the outranking family, omitting `vite.config.js`/`.mjs`, which is the MEASURED attack
from round 7 (a `web/vite.config.js` ran the suite in the gitless stage with 1411 tests
green). That family went short in round 7 and again in round 8. This is
`enumerate-CLAUSES-to-close-a-sweep`: the survivors were phrased in a different category
(WHAT is pinned) from the retracted claim (HOW it is extracted), so sweeping for the
retracted words missed them.

Also: "any edit to this file reddens, including a comment" was an absolute and is
refutable — a reindent, added blank lines, tabs, and a form feed all stay green, because
`_normalise_lines` collapses whitespace. Restated as what is actually true (a line's TOKEN
sequence, a comment's words included) plus the reason the tolerance is currently inert:
this file has no template literal and no ASI-sensitive token outside a comment. And a YAML
single-quote escape had leaked from the frontmatter into the markdown BODY, where `''`
renders literally.

No code change; the guard is unchanged and still 73/0.

Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 17:14:22 +02:00
timothy 4c44a13e3d docs(release): record the v26.15.0 release notes
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 14s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 15s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 23s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 12s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 9s
review-verdict/h10 Exempt: docs-only change (no code, no protected path)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Review verdict / Set review-verdict status (pull_request_target) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m1s
Adds the `v26.15.0` row to the release table in `docs/ci-cd.md`.

The tag goes on `736649b3b`, NOT on this commit and not on `main`'s head. Every
`Build & push image (amd64)` since `e8f80c42c` fails: that commit added
`web/src/api/completeAnnotations.guard.test.ts`, a third importer of
`virtual:etv-tracked-source-files`, without adding it to the hand-maintained
`--exclude` list in the Dockerfile's `web-build` stage — and that stage has no
git index, by construction (#887, claimed and in progress elsewhere). The guard
is behaving correctly; it refuses to fall back to a filesystem walk. Measured:
run 2459 on `736649b3b` ran the image job for 6m45s and published; run 2515 on
`cf5f42edf` died in web-build after 86s. `736649b3b` is therefore the newest
commit on `main` that can produce a release image.

Consequence recorded in the row itself: #880 (scheduling recurrence) slips to
the next release, since it merged after the break.

Release-boundary sweep (docs/ci-cd.md -> "Before cutting a release"):
- `decisions_validate.py` -> OK; 0 legacy-unmigrated records remain
- `build_decisions_catalog.py` -> no drift
- record ceiling: 45/218 over 60 lines (fraction 0.21, inside the blocking
  0.02-0.25 band). The validator notes the 60 has drifted below the tail
  boundary (p90=104, p95=142) and asks for re-derivation when convenient —
  a maintenance signal about the constant, not a blocker for this cut.
2026-08-30 16:56:17 +02:00
timothyandClaude Opus 5 1506e5a545 docs(887): 'pinned whole' is not byte equality — say what it cannot see
`_normalise_lines` drops blank lines and collapses whitespace WITHIN a line, so a reflow,
an indentation change, and a change to the spacing inside a STRING LITERAL are invisible.
The first two carry no meaning; the third could, and does not here. Line order and any
token change are caught. All four measured.

Stated because the phrase 'pinned whole' invites a reader to assume byte equality, and a
reader who assumes that will not check the one case where it matters.

Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 16:26:51 +02:00
timothyandClaude Opus 5 58def5faa1 fix(887): withdraw partial matching too — web/vite.config.ts is pinned WHOLE
Round 8. Three consecutive rounds had each closed one SPELLING of the same marker match,
which is `every-blocker-was-one-mechanism-so-delete-it` at the count where it says
withdraw. Round 6 pinned a block; round 7 fixed `test:  {` (two spaces); round 8 defeated
the repaired matcher four more ways — `test : {`, `"test": {`, and the same two for
`plugins:` — plus two that never touched the marker at all:

    plugins: [react(), trackedSourceFilesPlugin()].concat([evil])
    test: { …pinned… },  ...moreTest

`defineConfig` is the identity function in BOTH vite and vitest (read from the installed
tree), so a spread AFTER the pinned span simply replaces what the pin matched. No
respelling of the marker could ever have caught those: the defect was partial matching,
not the pattern.

So the file is pinned whole. 48 lines, nothing generates it, no marker to respell and
nothing after the span. One assertion replaces a bracket walk, a block extractor and two
uniqueness assertions — and catches all seven measured routes. Stated cost, which is the
same one every other pin here carries: any edit to that file reddens, a comment included.

This also retires a claim I made in a commit message AND in the inventory row: that the
two pins "share one bracket walk and cannot drift apart again". It was false when
written — the block extractor had its own inline copy and never called the shared helper.
Verified by spying on the call: the `test:` pin made zero calls to it.

Also closed: the second exec-form branch in `copies_spa_source` was DEAD code (measured by
poisoning it with a `raise` — every test stayed green), since the round-7 reorder rewrites
`remainder` before it; the exec-form `--from` case that reorder was written for had no
STANDING test, only the development battery, and now has three; and
`OUTRANKING_CONFIG_PREFIXES` matched nested paths, so a `web/e2e/vite.config.ts` — and
`web/e2e/` is a real directory — would have reddened with a message asserting it outranks
the pinned config, which is false. Direct children of `web/` only.

Battery 68 -> 73, 0 missed.

Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 16:25:39 +02:00
timothyandClaude Opus 5 69b30d15ec docs(887): the shared bracket walk fails RED on a bracket in a string — measured
It counts brackets without understanding string literals, so a `]` inside one ends the
span early. The direction is what matters and it was measured: the truncated span does
not equal the pin, so the outcome is a false RED, never a false green. Stated rather than
fixed — parsing TypeScript to do better is exactly the predicate this file withdrew.

Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 15:35:37 +02:00
timothyandClaude Opus 5 8141ac7807 fix(887): the rule applied to one config family and not its sibling
Round 7. BLOCKER empty; three real holes, two of them introduced by round six's own
fixes, which is this repo's recorded `each-fix-round-introduces-an-adjacent-defect`
happening inside a single commit.

**`web/vite.config.js` outranks the pinned `.ts` for `vite build`.** Read from the pinned
tarball rather than remembered: vite 8.1.3's `DEFAULT_CONFIG_FILES` is
`["vite.config.js", "vite.config.mjs", "vite.config.ts", …]`. So a `vite.config.js` whose
plugin shells out to the suite runs it in the gitless stage — measured with all 1411
tests green. The governing rule was already written down one family over ("pinning one
config is worthless while a second can outrank it") and had been applied to
`vitest.config.*` and not to this one. The refusal now covers both families.

**The decoy fix counted one SPELLING of the marker.** `text.count("test: {")` is defeated
by writing the real block `test:  {` with two spaces beside a decoy that matches exactly:
count is 1, the comparison takes the decoy, and the live block filters out precisely the
specs #883 broke on. Now `re.finditer(r"\btest:\s*\{")`.

**The plugin pin added in that same commit shipped the identical decoy hole** it was
written next door to fix — a raw `text.count(PINNED_VITE_PLUGINS) == 1` with a decoy
above `defineConfig`. Both `vite.config.ts` pins now share ONE bracket walk and ONE
whitespace-tolerant uniqueness rule, so they cannot drift apart again.

PROSE, and this one is a false completion claim in my own previous commit message: I said
the `PUBLISH_ACTION`/anti-vacuity sentence and the singular "only an `ENV`" residual were
corrected. They were — in the record and the inventory row, and NOT in the guard
docstring, which is the artifact a code reader hits first. Both are now fixed there too,
the route COUNT is removed from the docstring and the record and kept in ONE place, and
the residual that stated its own false version before retracting it now states the
boundary once.

Also: the `--from=` branch never reached the JSON exec-form parser, so
`COPY --from=web-build ["/source/web", "/dest"]` left the receiving stage unpinned; the
revalidate arm of the gating `if:` is now described as a DEPENDENCY on
`ci-detect-already-validated.sh` (graded `MUTATION: NONE`) rather than as something
asserted here, since only the `docs_only` arm is; and the plugin-bodies residual now says
there are TWO plugins, `react()`'s being third-party and unmitigated.

Battery 64 -> 68, 0 missed. One of those four exists because the battery itself briefly
reported NOTHING and exited 0 after a bad splice deleted its `main()` — it now carries an
anti-vacuity assert on its own mutant count.

Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 15:34:31 +02:00
timothyandClaude Opus 5 06b8081664 docs(887): two probed non-routes, recorded so they are not re-derived
Hunting a fifth instance of "the pin assumes it is pinning the thing that still decides"
turned up two candidates that look like routes and are not, both measured rather than
argued:

  * `setupFiles` is pinned by NAME while its CONTENT is not, which reads like the
    package.json hole one level down. It is fail-NOISY: `process.exit(0)` at the top of
    `src/setupTests.ts` makes vitest report `121 failed (121)`, not a green.
  * `tsconfig*.json` shapes what `tsc -b` compiles, not what vitest collects.

Recorded because a reader who spots either will otherwise spend the same probe to reach
the same answer — and because the honest residual beside them is the one that IS open: a
dependency's own install script, reached through `npm ci` and `web/package-lock.json`.
That is a supply-chain concern wider than this guard, and it is named rather than claimed
covered.

Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 14:23:27 +02:00
timothyandClaude Opus 5 3ec73f3769 fix(887): a pin assumes it is pinning the thing that still DECIDES
Round 6 found three more false greens and named the class they share, which is worth
more than any of the three fixes:

  * `web/vitest.config.ts` OUTRANKS the pinned `vite.config.ts` — closed in the previous
    commit, found by probing vitest rather than reading about it.
  * A DECOY first `test: {` block. The comparison took `text.index("test: {")`, so a copy
    of the pin placed above `defineConfig` satisfied it while the real block was narrowed.
    Exactly one is now required — the same assertion this file already made about the
    gating step's NAME, for the same reason, not carried across.
  * A `needs:` edge matched by bare job id. `needs:` resolves within its own workflow, so
    a SECOND workflow publishing this Dockerfile while needing its own unrelated job
    called `test` satisfied it. Now bound to `GATING_WORKFLOW`. (The reviewer downgraded
    this to MEDIUM on measuring that `test_remote_state_inventory.py` forces a human to
    classify any new workflow — so the hole is "the guard is blind", not "silent". The
    forced review asks about remote state, not about whether the image is gated, so the
    one-line fix stands.)
  * A vite PLUGIN can shell out to the suite from `buildStart()`. The plugin ARRAY is
    pinned; the plugin BODIES are a stated residual, mitigated because
    `trackedSourceFilesPlugin` is deliberately lazy — a fact its own comment now marks as
    LOAD-BEARING for the image build rather than leaving as an optimisation note.

THE CLASS: **a pin assumes it is pinning the artifact that still decides.** Every route
found so far is authority moving where the pin is not looking — to another FILE, another
OCCURRENCE in the same file, another WORKFLOW, or a HOOK the pinned command invokes. That
question is now written down for the next person adding a pin, because a list of four
instances is not what generalises.

Prose, all refuted by execution: the residual naming the uncovered COPY shapes was wrong a
THIRD time at the same site (`/source/web /elsewhere` IS recognised — only the destination
is renamed — and the file's own test 700 lines below said so); "only an `ENV` is
unmodelled" was an absolute and is now a list; "Reach: N mutants, 0 missed" is restated as
a DEVELOPMENT BATTERY, since it is not in the repo, nothing re-derives it, and an
independent battery found misses against an earlier head; and `PUBLISH_ACTION` was claimed
covered by anti-vacuity, which proves the selector is non-empty and cannot prove it
complete.

Battery 61 -> 64, 0 missed.

Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 14:07:47 +02:00
timothyandClaude Opus 5 848216e277 fix(887): a second config file outranks the pinned one — found by probing vitest itself
Pinning `web/vite.config.ts`'s `test:` block is worthless while a file that takes
precedence over it can simply be added. Vitest resolves `vitest.config.*` (and
`vitest.workspace.*` / `vitest.projects.*`) BEFORE `vite.config.*`.

MEASURED, not read: dropping a `web/vitest.config.ts` carrying
`include: ['nope/**'], passWithNoTests: true` beside the pinned file made `npx vitest run`
report "No test files found, exiting with code 0". The gating step would be green having
run NOTHING — worse than the filtered run ersatztv#887 removed, because a filtered suite
at least reports on what it ran.

The construct is refused rather than modelled: no such file exists, so the guard asserts
none appears. Its population is the git INDEX, which is right and worth stating — an
untracked config does not exist in a CI checkout either, so the mutant proving this has
to STAGE the file. It failed to redden until it did, which is the correct behaviour
demonstrating itself.

Route count five -> six -> seven -> eight, wrong at every previous count, so it stays a
running total with its history attached. Battery 60 -> 61, 0 missed.

Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 13:37:59 +02:00
timothyandClaude Opus 5 9b99a23835 fix(887): a selector where a pin was available — the blocker round 5 measured
Round 5 found a BLOCKER, and it is the sharpest kind: I made the exact mistake I had
described one screen earlier. `test_only_the_PINNED_npm_SCRIPTS_run_vitest` SELECTED the
scripts to pin by asking whether their body contained the literal `vitest` — a selector,
the category this file calls the worst-behaved because going short is silent — and then
its docstring claimed "going short is caught by the equality below", which is false: a
script the substring misses is absent from the compared map, so the equality still holds.

Four one-line `web/package.json` edits, none of which spells `vitest`, each put the suite
back into the gitless stage with the whole guard green: `"build": "npm run test -- --run
&& …"`, the same via `npm t`, and the `prebuild` / `preinstall` LIFECYCLE HOOKS, which
npm runs for `npm run build` and `npm ci` without anything naming them. That is #883
verbatim, through the route round 4 identified and the previous commit reported closed.

The fix is the one the file's own vocabulary prescribes: pin the WHOLE script map. A
script that does not exist cannot be a lifecycle hook, and one that changes is not equal.
The category disappears rather than being widened by two entries.

ALSO CLOSED, all measured:
  * `web/vite.config.ts`'s `test:` block is now pinned. `npm test -- --run` collects what
    that file says, so `test.exclude` is where a filter would now naturally be written —
    it is the only place left after this change removed the Dockerfile's. Three mutants
    narrowed the gating suite through it with the step's own command unchanged.
  * A step-level `shell:` and a job-level `defaults:` each override the pinned workflow
    default. Both forbidden.
  * `test_no_run_BODY_builds_or_pushes_an_image` is RESTORED — I dropped it in the parser
    withdrawal, and a job publishing via `run: docker build … && docker push …` was then
    outside the action-derived population with anti-vacuity none the wiser.
  * A leading-slash context copy (`COPY /web/. ./web/`) was not recognised.
  * The sweep gains `yarn test`, `pnpm test`, `bun test`.

The residual naming the uncovered COPY shapes was wrong for the SECOND consecutive round —
it named `COPY --from=X /source/web /elsewhere`, which is covered (only the destination is
renamed). The real gaps are an ANCESTOR source (`/source` brings `/source/web` along) and
`/source/.`. Both measured.

Route count: five, then six, now seven. It has been wrong at every count, so it is now
stated as a running total with that history attached rather than as an enumeration.

Battery 51 -> 60, 0 missed.

Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 13:37:59 +02:00
timothyandClaude Opus 5 3a88f5dc3d fix(887): six meaning-change routes, not five — counted by checking rather than asserting
The previous commit said "five such routes" in three places. Checking rather than
restating found six, and the sixth is one this guard must NOT close itself: the gating
job runs in a `container:`, whose image decides which `npm` exists at all. That is
already pinned by `test_ci_image_pin_population.py`, so it is CITED — two guards on one
condition mask each other (ersatztv#685), and the way to find that out is to delete one
and look for a red, which nobody does.

Also measured rather than assumed: an INDIRECT script chain (`"test": "npm run inner"`
with `inner` running vitest) needs no clause of its own. The set-equality against
`PINNED_VITEST_SCRIPTS` reddens on it, because `inner` mentions vitest and `test` no
longer does — verified across four scenarios, three red and one green.

An enumeration is a claim like any other. This one was written from memory of what had
been fixed rather than from the code, and it was short by one.

Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 13:37:59 +02:00
timothyandClaude Opus 5 566cabea6d fix(887): the pin's real limit, measured — and three absolutes it does not support
A fourth cold review attacked the pin itself. BLOCKER empty, the mechanism upheld, and
every finding was prose claiming more than I had measured — plus one one-token gap that
was live.

THE ABSOLUTES, refuted by execution and now corrected in all three places they appeared
(guard docstring, `docs/guard-inventory.md`, the decision record's `rule:`):

  * "A pin cannot produce a false green." True only in the trivial reading. A pin is
    immune to a different SPELLING of the command — the entire class that defeated the
    parser nine times — and is NOT immune to the same text MEANING something else. Two
    mutants re-armed ersatztv#887 through `web/package.json` alone: `RUN npm run build`
    executes whatever that file says, so `"build": "vitest run && …"` puts the suite back
    into the gitless stage with every pin still matching, and `"prepare"` does it via
    `npm ci`. Now pinned: exactly one script may mention vitest, and its body is fixed.
  * Residual (1), "a stage that does not carry the SPA source is unpinned — correct,
    since without `web/` there is no suite there". False. The boundary is what
    `copies_spa_source` RECOGNISES, which is narrower than "has the suite available".
    Restated, with the case still outside it named: a stage copy that RENAMES the tree.
  * The substring sweep's "never a false green". Its reported failures are false reds;
    what it fails to REPORT is not. `SUITE_MENTIONS` is a hand-written SELECTOR — a third
    category beside population and pin, and the worst-behaved, because a population going
    short is caught by an equality and a stale pin reddens loudly, while a selector going
    short is silent. It was short by exactly one entry: `npm t`, npm's own alias, which
    this guard already names among the spellings that defeated the parser. A stage
    running `npm t -- --run` escaped it. Fixed, and the category is now named.

ALSO CLOSED: `run: |` -> `run: >` folded the two-line body into one command whose
whitespace-normalised text was byte-identical to the pin, so the marker script swallowed
the suite as its arguments — the body is now compared LINE BY LINE, since a newline
separates two commands. A SECOND step named `Test SPA` inherited the exemption both the
pin lookup and the sweep key on; exactly one is now required. And `COPY web*/` — a glob
that matches `web/` — was read as not carrying the source, leaving the receiving stage
unpinned.

Battery 45 -> 51, 0 missed. The remaining meaning-change route, an `ENV` rewriting `PATH`
so a pinned `RUN` resolves a different `npm`, is not modelled and is recorded as a
residual rather than implied away.

Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 13:37:58 +02:00
timothyandClaude Opus 5 b53c3bab47 fix(887): close what a pin cannot see — meaning changed without changing the text
A pin rejects any command it does not equal, so the remaining attack is to change what
the pinned text MEANS. Four such vectors, found by attacking the new mechanism rather
than reading it; the first was MEASURED escaping and the rest are the same class:

  * A NEW stage taking the SPA source across with `COPY --from=web-build /source/web`
    and running the suite there. `copies_spa_source` excluded every `--from=` copy on the
    grounds that a stage copy is not a context copy — true, but it can still carry the
    SOURCE TREE from a stage that has it. The receiving stage was therefore unpinned and
    unchecked, which is exactly the false-NEGATIVE direction this file's own residual
    warns about. A stage copy now counts when its SOURCE has a whole `web` path segment,
    which keeps the built-artifact copy this repo actually makes
    (`/source/ErsatzTV/wwwroot/app/.`) correctly out.
  * `working-directory:` moved off `web` — `npm test` somewhere else runs a different
    package, or none, with the pinned command text unchanged. Now pinned.
  * `defaults.run.shell` changed from `bash` to `sh`. `bash` here is `bash -e`, which is
    what makes a failing command fail the step; changing it changes whether a red suite
    blocks the image without touching the step at all. Now pinned.
  * A `SHELL` instruction in a pinned stage, which redefines what every later `RUN`
    executes. Refused outright rather than modelled — there is none in this repo, so the
    honest move is to reject the construct, not to reason about a replacement
    interpreter.

Battery 41 -> 45, 0 missed; the stage-copy mutant reddens three assertions. The residual
list gains the two cases that remain in this class and are NOT covered: a stage copy that
RENAMES the tree on the way in (no `web` segment in its source), and an `ENV` altering
`PATH` so a pinned `RUN` resolves a different `npm`. Naming them is the point — the
previous rounds' residual lists read as exhaustive while omitting the largest holes.

Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 13:37:58 +02:00
timothyandClaude Opus 5 376aad6774 fix(887): withdraw the command parser — pin the command TEXT instead
Round 4, and the third cold review found the same mechanism failing again, so it is
removed rather than patched a tenth time.

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

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

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

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

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

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

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

Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 13:37:58 +02:00
timothyandClaude Opus 5 090a29db3d fix(887): two defects the FIX introduced, found by attacking it rather than reading it
Both were measured, not reasoned, and both are the shape this repo keeps recording — the
fix round introducing an adjacent defect, and a unit test using a simpler input shape
than the real file has.

`npm test -- --run && echo ok || true` reported NO suppression. `&&`/`||` chain across a
whole list, so when the suite fails the `&&` right-hand side is skipped and the `||`
right-hand side runs: the list exits 0 and the suite's failure is swallowed even though
the `||` is not adjacent to it. The detector looked only at the separator IMMEDIATELY
after the suite segment. It is now scoped to the `;`-delimited list, which also catches a
backgrounded `npm test &` (status never awaited) and `( npm test ) || true`. A `;` ends
the list and resets, so `npm test; other || true` stays clean — that `||` is about the
other command.

`--exclude 2 > log` reported `['--exclude']`, losing the filter's own value: stripping
redirections as a PRE-PASS let the file-descriptor rule claim the `2` before the flag
could. Redirections are now consumed inside the walk, after flag values are taken.

The mutant battery grew from 17 to 24 and is 0-missed. The `docs/guard-inventory.md` row
now states the count and, explicitly, the grading: exactly ONE of the 24 is declared in
`mutation_manifest.py` and re-executed every suite; the other 23 were witnessed by hand
and are NOT standing. That is the same footing `pageSizeCallSites.guard.test.ts` states
for its nine, and saying so is the difference between evidence for the reach and a claim
of a per-run proof.

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

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

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

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

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

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

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

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

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

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

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

Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 13:37:58 +02:00
timothy cf5f42edf9 fix(858,859): a rule the classifier cannot read is not a rule that matches nothing (#897)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 10m49s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m41s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 7m18s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m26s
#859 was filed as a wrong STATED CAUSE. It was masking a live false-open in the merge gate.

Gitea reports a GLOB branch-protection rule with an EMPTY `branch_name` — the canonical
name lives only in `rule_name`. Measured 2026-08-30 on a scratch repo against 1.27.1.
jq's `//` fires on null and false but NOT on `""`, so `(.branch_name // .rule_name // "")`
resolved every glob rule to the empty string — a name with no metacharacters — and the
glob test, the entire basis of the classifier's undecidable-first ordering, never saw it.

Measured on the predecessor: glob `m*` (not requiring review-verdict/h10) beside plain
`main` (requiring it) resolved to `exact` on `main` and AUTO-GRANTED a scheduled merge,
while Gitea — ordering by Priority then plain-name-ness — may be applying `m*`. That is
#622's hole, reached through the ordering written to close it. Mirror case: a glob alone
resolved to `none` and DENIED about a rule that provably governs the base.

A name is now a non-empty string. Each field resolves to a NAME, a SKIP (absent/null/
empty — fall through), or POISON (present, wrong type — poisons whichever field carries
it). A rule with no usable name is a distinct `unreadable` verdict with its own operator
cause, instead of feeding `none`, whose whole authority is "the full rule list was read
and none matches". The short-circuit is STRUCTURAL: jq binds `as` eagerly, so the flat
form still evaluated `offs`/`nonascii` on the bad name and died before reaching the arm
meant to prevent that.

Also #859: `branch_protections` is fetched ONCE per run, not twice. The round trip is the
smaller half — it is mutable config, so two reads can disagree and the two arms then
decide about different repo states with neither able to notice.

#858: `verdict_script` resolves from `$repo_root`, not `$CLAUDE_PROJECT_DIR`. And the
finding that mattered more — `ETV_HOOK_FIRE_LIB` is `. `-SOURCED, so it is CODE running
before stdin is read and before `decide` exists. A first draft exempted it as "telemetry,
not a predicate"; cold review refuted that by execution: a decoy hook-fire-log.sh in an
env-var-named tree printing an allow and exiting 0 GRANTS THE MERGE, bypassing every
check. Classify a path by how it is CONSUMED, never by what it is called. This hook's copy
is self-located; the other twelve are #891 (high/security), which records the reachable
case — husky launches the prepush hooks by RELATIVE path, so the two roots diverge there.

check-required-contexts.sh gains an array-type gate (a JSON object previously printed
`nomatch`, a positive claim about server config from a body it cannot consume).

Verification: 1377 passed / 2 skipped; 11 declared mutants, 11 detected, disjoint
reddened sets; classifier executed across jq 1.8.2 and 1.6 with identical results; both
env-var tests ship a negative control, because the passing outcome is also what an inert
decoy produces.

Four cold review rounds plus a bounded prose check. Every round found defects the
previous round's fixes introduced — a type conflation that re-opened the auto-grant, a
comment asserting the opposite of the line its own commit changed, and a corrected
sentence whose identical twin survived in the same diff.

Docs: new record `process.hook-resolves-inputs-from-repo-root`; both inline sites cite it
rather than arguing it twice. docs/remote-state-inventory.md's row for the second read
updated. Follow-ups filed: #891 (the other 12 hooks), #895 ("all N tests green" claims).

fixes #858
fixes #859

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-30 11:33:50 +00:00
timothyandClaude Opus 5 (1M context) &lt;noreply@anthropic.com&gt; 0e40ac283b fix(870): an empty timeline page is not exhaustion — the walk reads to its cap (#896)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 5s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 10m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m56s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 7m2s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m35s
`count_pr_mutations` treated an empty page past page 1 as proof it had reached the end of the PR
timeline. Gitea does not mean that: `ListIssueCommentsAndTimeline` applies the LIMIT/OFFSET in
`FindComments` at the DATABASE level and filters AFTERWARDS, dropping `CommentTypeCode` rows and
inaccessible cross-references into a nil slice that serializes as bare `null`. A page of 50 inline
review comments is byte-identical to a page past the end while later pages still hold events, and
rows are ASCENDING, so the events a fence looks for are the furthest from page 1. Fifty comments,
which a PR author can create on their own PR, truncated both walks at the same place: both counts
agreed, the sha comparison agreed, and an ABA force-push yielded an exemption `success` over a diff
no single head justified.

The walk no longer infers the end from an empty page BEFORE its cap. Such a page is skipped; the
loop reads every page to its 20-page cap and trusts the counts only when the LAST page came back
empty. An empty FIRST page and any unreadable shape still end the walk untrusted.

NARROWED, NOT CLOSED, and the docs say so in one unit: the page-20 terminator is still trusted for
the same unprovable reason, so the defeat now costs a timeline of over 1000 rows rather than ~100,
with the same 50-row filtered block pinned to offsets 950..999.

Measured at Gitea 1.27.1, ruling out the cheaper fixes: `X-Total-Count` on this endpoint is the
post-filter length of the PAGE, not a total (`?limit=1` returns 1 on a 14-row timeline), while
`/activities/feeds` returns a true total; `limit` clamps to 50; the only query params are `since`,
`before`, `page`, `limit`, so the paged and serialized sets cannot be made to agree.

Also: each page bounded `--connect-timeout 5 --max-time 15` and retried once, mirroring
`page_statuses`, because the walk went from ~2 requests to a fixed 20 and the third call site runs
after the exemption `success` is posted. Costs stated rather than hidden — worst case 40 requests
and 20 sleeps, wall-clock pessimum 620s per walk, and the suite roughly doubled (202s -> 474s).

Seven tests, each mutation-witnessed red; three reproduce the defeat against the shipped predecessor.
Two independent cold reviews plus a re-review of the fix: no Blocker or High in the code. Their real
finding was prose claiming the hole was closed, and cost arithmetic wrong twice. One reviewer claim
was refuted by execution.

Fixes #870

Refs: #803, #706, #664, #751, #893
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) &lt;noreply@anthropic.com&gt;
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-30 11:29:38 +00:00
timothyandClaude Opus 5 528383cf3a fix(880): an absent recurrence array means unrestricted, an explicit [] is rejected (#892)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 10m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 7m34s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m59s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m27s
The three recurrence arrays are read CONJUNCTIVELY by
AlternateScheduleSelector.GetScheduleForDate, so an empty set matches no date.
`?? []` on an omitted array therefore returned HTTP 200 while storing an
alternate-schedule or template item that could never apply, silently -- while the
read side (#823) already read a NULL column as the All*() sets.

Absent and explicitly-empty are two different requests and get two answers:
ABSENT (missing, or explicit null) normalizes to AlternateScheduleSelector.All*(),
the same symbols the read side substitutes; EXPLICIT [] is rejected with a 422
naming the consequence, via RecurrenceSetBounds called from both replace handlers.

The rejection lives in the handlers, not the controller, because
api.ffmpeg-profile-numeric-bounds' "accept an UNCHANGED bad value" rule binds
hardest here: both PUT paths are whole-list replaces, so rejecting a pre-existing
empty set would make every OTHER item in the list uneditable. That comparison
needs the stored row. The validated set is derived from `incoming`, so the
highest-Index catch-all -- whose recurrence the handler discards -- is excluded by
construction.

Verified: full ErsatzTV.Tests suite green; three mutation proofs with disjoint
reddened sets; live-E2E against a real instance confirmed an OMITTED property
round-trips as unrestricted (the Newtonsoft missing-property chain unit tests
cannot reach), an explicit [] returns the 422, and [] on the catch-all is accepted.
Cross-family cold review BLOCKED the first implementation with 3 findings, all real
and all fixed; re-review returned MERGEABLE.

Follow-up #894 filed: the SPA can still build the empty state the server rejects.

fixes #880

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 09:29:02 +00:00
timothy 58681b3a79 fix(849): the verdict gate replaces every unknown state, and proves the clauses that claim to (#890)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 13s
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 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) Failing after 1m16s
2026-08-30 05:55:09 +00:00
timothyandClaude Opus 5 d4b36ac232 fix(849): restore 13 proofs round 9 deleted by accident, and one comment that argued both sides
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 22s
Review verdict / Set review-verdict status (pull_request_target) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m45s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 11m40s
review-verdict/h10 Review-verdict: MERGEABLE @ d4b36ac (base: main)
The round-9 cross-family review found no Blockers and no Highs, and independently confirmed
the clause deletion it was asked to check. What it did find is that round 9 removed FIFTEEN
test definitions and added four — a net loss of eleven — where the commit message claimed
two. Verified against the parent: 227 definitions before, 216 after.

The cause is mechanical and worth naming, because it produces a green suite: the round-9
edits replaced whole source RANGES (`s[:start] + new + s[end:]`) whose end anchor was the
next test rather than the end of the one being rewritten, so everything in between went with
it. The suite then passed because the tests were GONE, not because the code was right — the
exact shape this issue exists to prevent, reproduced in its own test file.

Among the casualties were round 4's proofs for two earlier BLOCKERS:

- `test_a_generic_PENDING_with_no_mark_also_becomes_the_sentinel` and its mutation, which
  pin the no-mark downgrade covering every re-derivable write rather than only `success`;
- `test_a_MALFORMED_creator_FIELD_...` and its mutation, which pin a wrong-typed field
  taking the fault route rather than reading as absent and licensing a re-derive.

Also lost: both `$own`-exclusion proofs, the no-op-repair skip proof, the id-asymmetry pair
(the reviewer's named example), and two write-failure propagation proofs.

All 13 unintended deletions are restored verbatim from the parent commit and ALL PASS against
round 9's code, so nothing had regressed — the harm was the missing evidence, not the
behaviour. The two deletions that WERE intended stay deleted: a test superseded by
`..._still_refuses`, and the positive control round 9 inverted.

Prose: the comment above the unreadable-element guard still argued a malformed neighbour is
safe noise once the target row was found, eleven lines above code that now refuses
unconditionally — two adjacent blocks giving opposite accounts of one rule, and the stale one
licenses reinstating the Blocker.

Refs: #849
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 07:10:21 +02:00
timothyandClaude Opus 5 c2c70e50ad fix(849): round 9 — sentinel TEXT is not sentinel STATE, and an unreadable neighbour is not noise
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 19s
PR Gates / Docs update reminder (pull_request) Successful in 22s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
review-verdict/h10 Awaiting review verdict for c2c70e5
Review verdict / Set review-verdict status (pull_request_target) Successful in 13s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 14m56s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 15m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m8s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 9m36s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 9s
The round-8 cross-family review found two more Blockers. Both are cases where a principle
this branch had already established was applied in one place and not the adjacent one.

## Sentinel text is not sentinel state

`ex_repair` and `ex_unverified` were set from the DESCRIPTION alone. A `success` carrying
`$REPAIR_DESC` verbatim — from a machine or an off-list account — therefore read as a
sentinel: the mid-run guard exited on it, and the mark's already-there test matched it and
returned without POSTing. A green stood on an unreviewed head, on a first-push event with no
successor guaranteed.

This is the same reasoning that removed the "this job's own output" exclusion one round
earlier: a description is not provenance. It is not state either. Both sentinels this job
writes are `pending` by construction, so requiring it costs nothing.

## An unreadable neighbour cannot be shown to be unrelated

Round 8 refused only when NO readable target row was found, reasoning that a malformed row
beside a good one is noise. An element whose `.context` cannot be read cannot be shown to be
a DIFFERENT context — so it may be a mangled rendering of this head's own rejection, and the
one-row-per-context invariant that would rule that out is exactly what a schema-corrupt
response has already broken. The branch's own POSITIVE CONTROL encoded the failing case: a
scalar beside an off-list `success`, which this branch re-derived and greened where
`origin/main` errored on the scalar and posted nothing. That test is inverted, not adjusted.

The cost is a stall on any head carrying a malformed element — the correct direction for a
required check, since it withholds a green rather than granting one.

## Two clauses deleted rather than proved

Chasing a proof for the mark's repair promotion showed its three clauses were MUTUALLY
REDUNDANT: each alone produces the outcome, so no single-clause mutation could show harm.
Tracing why revealed that two are unreachable as a sole cause — a repair sentinel at the
first read sets `ex_repair`, which forces `desc="$REPAIR_DESC"`, and one arriving mid-run is
caught by the sentinel guard unless this run is itself writing that string. So they are
redundant rather than unprovable, and they are gone. One clause, one mechanism, one proof.

refs #849

Refs: #849
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 06:05:02 +02:00
timothyandClaude Opus 5 879208d554 docs(849): re-apply the CLAUDE.md correction the rebase resolution dropped
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 10s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 17s
PR Gates / Docs update reminder (pull_request) Successful in 21s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 27s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 13s
review-verdict/h10 Awaiting review verdict for 879208d
Review verdict / Set review-verdict status (pull_request_target) Successful in 13s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 11m43s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 12m49s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 8m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Failing after 15m0s
The rebase onto #889 resolved a CLAUDE.md hunk in favour of upstream, which kept #845's new
clause and discarded #849's — leaving the file asserting that a rejection landing inside a
run's own write window is "a separate and still-open route". Both edits belong: they touch
one sentence for different reasons.

This message also repairs the TRAILER BLOCK for the whole branch, which CI caught and local
runs did not. Every commit here ended:

    refs #849
    Decisions-Edit: yes

    Co-Authored-By: ...

Git parses only the LAST paragraph as trailers, so the blank line put `Decisions-Edit: yes`
in the second-to-last one and it was never a trailer at all — `git log --format=%(trailers)`
showed only the Co-Authored-By pair. `refs #849` without a colon disqualifies that paragraph
independently. `decisions_validate.py` arms its rationale-prose exemption from ANY non-merge
commit in the range, so one correctly-formed block repairs all nine.

Refs: #849
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 05:27:30 +02:00
timothyandClaude Opus 5 691a14d039 fix(849): round 8 — a green main does not post, and two round-7 fixes that overshot
The first cross-family review in five rounds (Codex/GPT-5.6, once its quota reset). It found
a Blocker four same-family rounds had missed, and REVERSED two of round 7's fixes — which is
the more useful result, because both were made in response to a review and both overshot in
the direction the finding pointed.

## The Blocker: dropping unreadable elements became "no verdict exists"

Round 3 added `select(type == "object")` so a malformed NEIGHBOUR could not kill the step.
When it drops EVERY element, `first // {}` yields `{}`, all `ex_*` read empty, and the job
concludes no verdict exists — so a docs-only PR walks straight to the exemption. Measured:
`{"total_count":1,"statuses":[7]}` posts `Exempt: docs-only change` here and posted NOTHING
on `origin/main`, which raised jq error 5 and aborted under `set -e` before any write. An
input on which this branch greens a head that `main` fails closed on, and if that scalar is a
mangled rendering of the head's human `failure`, the rejection is what gets greened.

The asymmetry is now the rule: a malformed row BESIDE one we did read is noise; a malformed
row where we found NOTHING is the only evidence there was. The absence conclusion has to be
earned over a list with no unreadable elements in it.

## Two round-7 fixes that overshot

- **The arms judged both snapshots.** Round 6's review said they judged `$pre_*` while the
  POST replaces `$ex_*`; I made both veto, which is the mirror defect — an opening row since
  REPLACED by a machine `success` still vetoed, so the arm left that success gating the head.
  They judge the current row alone now. The opening snapshot keeps exactly one job: it can
  make the write STRONGER, never suppress it.
- **The "this job's own output" exclusion keyed on the DESCRIPTION.** A description is not
  provenance. Any workflow with `code: write` can POST a `creator: null` row and any
  repository writer can POST one with a creator, either wearing this job's text — so masking
  a human `failure` with a lookalike `pending` bought an abstention, and the successor
  re-derived it as ordinary machine output with the rejection below its own mark. Removed;
  the attempt is recorded because it is the tempting one, and there is no issuer field that
  could make it safe.

## A guard that could not be reached, folded into the one that can

The repair veto turned out unreachable: an `$ex_desc` of `$REPAIR_DESC` with a different
`$desc` is caught by the mid-run sentinel guard long before an arm runs, and when `$desc` IS
`$REPAIR_DESC` the promotion writes the same string. Rather than keep a guard no fixture can
reach — or delete it on the strength of a check three hundred lines away — the invariant is
enforced where it is local and provable: the mark carries the strongest description any
snapshot shows, then declines to write what is already there.

`ci.exemption-provenance` still called the post-final-count window a PERMANENT forged green
in its `rule:` frontmatter and body; the post-POST re-count made it transient two rounds ago.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 05:13:29 +02:00
timothyandClaude Opus 5 72cca9a280 fix(849): round 7 — the arms judged the wrong snapshot, and two comments that invited a bug
A fourth cold review of the tip. No Blockers, no High: it enumerated every POST site and
every exit and could not construct an input where this branch writes a `success` that
`origin/main` would not.

## The arms judged the wrong snapshot

`mark_declined_row_if_any`'s three refusals all read `$pre_*` — the FIRST read — while the
POST replaces whatever row is CURRENT. So a reviewer's verdict arriving between the two
reads slipped past every refusal written to protect it: the base mismatch clears
`ex_attributable` so the mid-run abstain declines, `pre_creator` is empty so the allow-list
loop declines, and the arm marks a row nobody evaluated. Executed trace, control and case.
Both snapshots are consulted now, and either one vetoes.

Recovery was not free, which is why it mattered: the next run's reconciliation counts that
`Review-verdict:` row as buried and upgrades to the human-only sentinel — exactly the cost
the refusal exists to avoid.

The arm also marked this job's OWN ordinary machine `pending`. Every PR past its first run
carries one, so "kept off the commonest path in this job" was true only of a head with no
status at all. Scoped on the DESCRIPTION rather than on `creator: null`, which would also
exclude a machine `success` from another workflow — the row this marking exists for.

## Two comments that invited a bug

- One still described the round-4 REGRESSION as the intended behaviour ("a malformed row
  reads as no creator, hence re-derived"), two lines below the block recording that it was
  fixed. Adjacent comments giving contradictory accounts of one line, and the stale one
  licenses reinstating it.
- The fault token's justification said "no Gitea status field contains a NUL". The token is
  SOH (0x01). That is not pedantry: `$'\000…'` is the EMPTY STRING in bash, so an editor
  correcting the code to match the comment would make every legitimately-absent field
  compare equal to the token and send every clean head down the fail-closed route — the gate
  would stall every PR.

## Docs

The record quoted a predicate that no longer exists (`[ "$ex_desc" != "$pre_desc" ]`, now
`$row_replaced`); `docs/ci-cd.md` stated the reconciliation witness unconditionally when the
code degrades to a description match where the server omits `id`; one of the six unproven
clauses carried a wrong `because` (the conclusion holds via `(.id | numbers) // -1` over a
validated array, not via the schema-fault route, which governs a different endpoint's row);
and the record's own counts read as a contradiction cold — 20 surviving MUTANTS collapse
onto 6 distinct CLAUSES, several clauses admitting more than one disarming edit. The
run-by-run provenance moved to the issue, where `docs.no-session-narrative` says it belongs.

Two existing mutation proofs lost their binding to the reworded clauses and failed loudly
rather than measuring the unmutated body, which is what that count assertion is for. Rebound.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 05:13:29 +02:00
timothyandClaude Opus 5 6fdf48de2d docs(849): drop a duplicated clause from the record, and record the sweep's yield
The `mechanics:` field ended with the same sentence twice — the enumeration of the
unreachable clauses was appended without removing the tail it replaced. Found by the
mutation-sweep agent while reading the record it was checking its own results against.

In its place, the number that makes the technique worth its cost: 60 mutants, 40 red, 20
survivors, on a tree that had already been through three per-finding review rounds by two
model families.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 05:13:29 +02:00
timothyandClaude Opus 5 bbaf1d76f8 fix(849): round 5+6 — the survivors a mutation SWEEP found, and the six that cannot be reached
Codex was unavailable for this round (usage quota), so the cross-family reviewer was
replaced by a same-family agent doing one mechanical job: enumerate every security-bearing
clause the diff adds, disarm each, and run the WHOLE suite per mutant. 60 mutants, 40 red,
20 survivors — a yield no per-finding review in this series came close to, because a review
looks at what the diff says it does and a sweep looks at what the tests actually pin.

## Proved (nine)

- the description type test in the RECONCILIATION `buried` filter — exact twin of the
  post-write one, which had a proof; without it a numeric description hard-errors
  `startswith`, the count comes back unusable, and the genuine verdict on the next row is
  lost with it;
- the `.status` / `.description` / `.id` type tests, parametrised over all four consumed
  fields so a fifth cannot be added without a case (`.creator`'s was the only one proved);
- both retry loops — the combined read and `repair_status_to`'s second POST. Against a stub
  that fails EVERY attempt a retrying reader and a one-shot reader are indistinguishable,
  which is how a retry ships unexercised; the fixtures now fail only the first attempt;
- the mid-run guard's self-exemption, which is what stops a sentinel-writing run abstaining
  on the row it was about to replace with an equivalent one;
- both repair-write failure paths (the repair and the post-POST replacement), reachable only
  with a stub that lets the FIRST post through and fails the rest — with every post failing
  the job dies on its own classification write and never reaches them;
- the two `state=pending` updates after a repair. The first is load-bearing beyond tidiness:
  without it a repaired head re-enters the post-POST check and, on a retarget it then
  observes, replaces `$REPAIR_DESC` with the weaker reconcilable sentinel — the same ordering
  inversion the floor beside it exists to prevent, reached by another route.

## Declared unreachable (six), enumerated rather than counted

The path-predicate failure branch; the empty-`row` refusal; page 2's non-numeric length; the
`$witness` normalisation; and the two unusable-count arms. Each is defence in depth behind a
filter that makes its input well-formed for every case a fixture can pose — the same standing
exception the post-write unusable-count arm already carried.

That set has gone two -> five -> six across three rounds as the sweep widened. Naming them is
the point: an inventory that undercounts reads as a checked claim and talks the next reader
out of verifying, which is the same defect as inventing coverage — and this branch has
already had to correct that twice.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 05:13:29 +02:00
timothyandClaude Opus 5 28e82fcb59 fix(849): round 4 — a regression round 3 introduced, and the clauses it left unproven
A third cold review, which ran the mutants itself, found one measured direction regression
against `origin/main`, one ordering inversion, and four clauses this branch claims as
fixes that survived mutation of their own text.

## The regression

Round 3 type-tested the four consumed fields of the existing `h10` row and resolved a
failure to `""`. For `.creator` that means "no creator" — unattributable — which is a
LICENCE TO RE-DERIVE. Measured, same fixture, both bodies: a head carrying
`h10=failure` with `"creator": 7` posts `Exempt: docs-only change` here and posted NOTHING
on `main`, which died on `.creator.login` before any write. Fail-closed became fail-open.

The rationale that produced it came from #763, whose site is the POST-WRITE filter: there,
dying leaves a green already published, so dropping the row is the safe direction. Here the
alternative is dying BEFORE any write. The deferral rationale did not transfer — which is
the shape this repo has a record for.

A wrong TYPE is now distinguished from a legitimately ABSENT value: `null` is the machine
creator, an unset description and every field of the `{}` no-verdict row; anything else is
unknown state and takes the route an unreadable ELEMENT already took.

## The ordering inversion

`mark_declined_row_if_any` was scoped to "the head carries any row", so it fired on a head
carrying `$REPAIR_DESC` and replaced the human-only marker with the machine-clearable one —
inverting the ordering the SAME commit added a floor to protect at the repair site. One
mechanism, three writers, and only two had the rule.

It also buried a verdict an ALLOW-LISTED reviewer wrote for another base. "Declined" is
decided against this event's `$BASE_REF`, so such a row is still the right answer for the
base it names and the successor run for that base short-circuits on it; burying it costs a
manual re-post on an ordinary retarget-onto-the-reviewed-base flow. Membership is tested on
the raw creator, not on `ex_human`, which the base check has already cleared — the question
is who wrote the row, not whether it governs this diff.

## The unproven clauses

Four claims survived mutation, including the headline one. The witness fixture had been
designed AROUND its own discriminator — its comment said a seed with an unrelated id "would
make this run carry the sentinel forward … and the guard under test would never be reached",
which is a description of the test not reaching it. Eleven proofs added, covering the
witness-by-id, the head arm's own call site (two callers of one helper, one fixture), the
mark helper's result propagation, and the round-4 behaviour above.

`raced_why`'s human value is a named constant now: it is the one such value that is also a
PREDICATE, compared twice, and a drift in either copy silently downgrades the human
`::error::` — the only message that tells a reviewer their verdict was buried.

## Docs

The renamed sentinel literal in two places; three documents still asserting the fence
"writes NOTHING"; the record's `mechanics:` still describing round 2's witness; the
replacement-site list, which had grown by four; a residual pointing "below" at something
above it; and `CLAUDE.md`'s "closed", which is stronger than the record it points at — that
record lists six residuals including both endpoints failing at once. The proof inventory is
stated as an invariant (every clause with a predecessor is mutated back to it) rather than a
count that rots.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 05:13:28 +02:00
timothyandClaude Opus 5 168fe21088 fix(849): round 3 — replace every unknown state, and prove the clauses that claim to
Two more cold reviews — cross-family (Codex/GPT-5.6) and a cold Claude reviewer that ran
the mutants itself — converged on two separate things: a remaining class of paths that
still left an unknown state standing, and, more importantly, that several clauses this
branch claimed as fixes SURVIVED mutation of the exact text they name.

## Behaviour

1. The reconciliation witness matches the CURRENT row's `id`, not merely a row with the
   sentinel's description. Description alone is satisfied by an OLDER identical sentinel —
   which is what a fixed point produces — so a read carrying only the earlier row cleared
   the sentinel while the verdict buried under the current one ended up below the fresh
   mark. Falls back to the description where the server omits `id`.
2. The two OBSERVED-mutation arms mark a head that carries a row this run declined, instead
   of only abstaining. They are still right not to post their CLASSIFICATION — computed
   against a base or head the PR may no longer have — but a declined row must not stay
   authoritative for the whole window until a successor finishes, and for a PR's FIRST push
   no successor is queued at all. Scoped to `pre_state` being non-empty, so the common path
   stays quiet.
3. `replace_unknown_state` RETURNS a status. Its first version ended the failure arm with a
   successful `echo`, so it reported 0 after both POSTs failed and the fence caller's
   `exit 0` reported an abstention that had not happened.
4. An `id` difference counts only when BOTH reads supplied one. A response that omits `id`
   beside one that includes it otherwise reads as a replacement, and this guard's reaction
   is to abstain — over a row the classification had already declined.
5. Every element and every consumed field of the combined response is type-checked before
   extraction, and a schema failure routes to the replacement. `.statuses` being an array
   was checked; its ELEMENTS were not, so one scalar made `select(.context == $c)`
   hard-error and `set -e` took the step down before any path could mark the head.
6. The path-predicate failure replaces rather than merely exiting, for the same reason.
7. `$UNVERIFIED_DESC` says "Status write", not "Exemption write". It is now written on paths
   that grant no exemption at all, and it is the operator-facing text of a required check.
8. The no-op-repair skip keeps the human `::error::`. Skipping the WRITE is right — the head
   already carries the strongest marker — but that message is the only place a reviewer is
   told their verdict was buried. `raced_why` is a sentence now, not the token `human`.

## Proof

The cold reviewer measured three of the six round-2 claims surviving mutation of their own
clause, one against the verbatim predecessor from the previous commit. Nine proofs added:
the no-mark downgrade's SCOPE (not just the description it writes), the page-2 refusals, the
untrusted-fence write, the row-`id` comparison, the repair floor, the no-op skip, both `$own`
exclusions, the write-result return, and the both-ids-present rule.

Two of those needed the test double to grow: the combined-status stub emitted no `id` at
all, so the `ex_id` clause had never once run with a non-empty value; and POSTs always
succeeded, so both write helpers' failure arms were unreachable.

The `$own` exclusions and the no-op skip are OUTCOME-redundant — mutating either alone leaves
the post sequence unchanged, which is how duplicate guards hide each other. Their proofs
assert the LOG, because what the exclusions alone decide is whether the job reports a race
against its own row. One clause is left deliberately unproven and named as such in the record
and the guard inventory rather than counted: the path-predicate failure branch has no fixture
that can reach it.

## Also

Round 2 left two comment paragraphs duplicated verbatim and a block header narrower than its
block; both fixed. Stale prose corrected in the workflow ("dies WITHOUT posting", "post-write
verification never runs for it", "this block only runs after a `success`"), `docs/ci-cd.md`
("the fence never re-counts", "the history is read twice" — it is three now),
`ci.exemption-provenance` and `docs/guard-inventory.md`.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 05:13:28 +02:00
timothyandClaude Opus 5 957a328f33 fix(849): round 2 — the uncertainty paths that still resolved toward success
Two independent cold reviews (Codex/GPT-5.6 cross-family, and a cold Claude reviewer in
its own worktree) converged on the same class: paths where "this job cannot establish
what is on the head" still resolved by leaving the head alone, which protects a real
verdict and leaves a forged one.

Behaviour:

1. The four page-2 completeness refusals now replace the unknown state too. They were
   excluded on the reasoning that the probe fires when NO row for this context was on page
   1, so there is no green of any provenance to leave standing — self-contradictory, since
   the only reason page 2 is read is that the row may be beyond page 1, which the probe's
   own message says. Accepted cost, stated in the record: a head with more CONTEXTS than
   the 50-row cap stalls every run; measured 2026-08-29, this repo puts 8 on a `main` head,
   and that case already stalled with an ABSENT check.
2. The no-mark downgrade covers every re-derivable write, not only `success`. Restricting
   it analysed the wrong PR: the damaging case is one that IS exemptible and got the
   generic `pending` only from a transient enumeration failure. That description carries no
   marker, nothing verifies it without a mark, and the next run re-derives it into the
   exemption with the human row below its own mark — route 2's damage through route 1's
   condition. `$REPAIR_DESC` stays exempt, being stronger and not re-derivable.
3. The fence branch that cannot trust its retarget count while holding a derived `success`
   writes the sentinel instead of abstaining. It is reached only after the classification
   DECLINED to inherit the row the head carries, so posting nothing left that row current;
   the message said the context "stays absent", true only of a head that had none.
4. Reconciliation needs a WITNESS: it may clear only over a complete history containing the
   sentinel's own row. `ex_unverified` means the combined endpoint just returned that row
   and `/statuses/{sha}` keeps one per POST, so a complete-but-empty history contradicts a
   write that demonstrably happened — and `page_statuses` accepts an empty page 1 as
   complete, which is what made it reachable. Both reviewers reproduced the clear-then-exempt
   outcome. The shipped positive test used exactly that impossible fixture, so it was
   pinning the defect; it now seeds the sentinel row, and an impossible-empty negative plus
   a witness mutation proof were added.
5. The mid-run "did this row change" comparison now includes the row ID. The two sentinels
   are byte-identical by design, so a mid-run replacement of one by another was invisible to
   a state/creator/description triple. Measured 2026-08-29 (Gitea 1.27.1, head 736649b3):
   the COMBINED endpoint carries `id` on every row, ids 14..30 ascending — the job had only
   ever read ids from `/statuses/{sha}`. Where a server omits it both sides are empty and
   the comparison degrades to the pre-existing text test.
6. The repair has a FLOOR — it may never write a description weaker than the one this run
   decided — and is skipped when it would rewrite what is already there. Widening the gate
   to every write meant a transient post-write read could rewrite a correct `$REPAIR_DESC`
   carry-forward with the machine-clearable sentinel, reversing the ordering rule the
   classification chain states.

Writing the sentinel and failing the job are separate decisions, which is why
`replace_unknown_state` and `replace_unknown_and_die` are two functions: the read refusals
were already non-zero exits on `main` and stay red; the fence branch exited 0 there and
still does, because an unreadable timeline is an ordinary hiccup and reddening every one is
noise this file elsewhere refuses to add.

Prose corrected where it now overclaimed: "the green never stands" after the post-POST
re-check is wrong — it is live between the POST and the repair, so the check makes a
permanent green TRANSIENT; "a later run reconciles this automatically" is wrong in the one
case where the replacement costs anything, since finding a masked verdict UPGRADES to the
human-only sentinel; and the mutation-proof framing claimed every mutant restores the exact
predecessor, when two do, one restores the shape #742 withdrew, and the rest disarm clauses
that have no predecessor. The quiet-timeline positive control now counts timeline walks,
because a single POST is also what a skipped re-check produces.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 05:13:28 +02:00
timothyandClaude Opus 5 e30702111f fix(849): verify every write, and mark a head nothing could verify
The gate's post-write verification had five routes that all ended the same way — an
exemption `success`, or a generic `pending` a later run turns into one, standing over a
human `failure`.

Two of these were attempted inside #742 and withdrawn, and the withdrawal is what shaped
this change. That attempt withheld the exemption by writing a GENERIC `pending`, which is
exactly what a later run re-derives into `success` — it moved which run posted the forged
green rather than stopping it — and it had no retry path, because this workflow triggers
only on `pull_request_target` types, so a transient failure on a PR's last event stalled an
exempt PR until a human nudged it. The fix therefore needs two properties at once: sticky,
so a later run cannot re-derive it, and reconcilable, so a blip does not cost a head its
exemption permanently. Neither the repair sentinel nor a generic `pending` has both, which
is why there is now a second sentinel rather than a reuse of the first.

What changed:

1. No high-water mark => the exemption is WITHHELD before the POST and the head is marked
   with the new `UNVERIFIED_DESC` sentinel. Withholding before the write rather than
   posting and repairing matters because the defect is known in advance: publishing a green
   to take it back opens a window branch protection, and an already-scheduled auto-merge,
   can see.
2. Post-write verification runs after EVERY write, not only `success`. A generic `pending`
   masks a rejection landing in its own write window just as well, and carries no marker,
   so the next run re-derives it with the human's row now below THAT run's mark.
3. `.description` is type-tested before `startswith`. `(.description // "")` does not
   replace a NUMBER, so `startswith` hard-errors on one, killing the whole count — the
   genuine verdict beside the malformed row is lost with it.
4. The retarget count is re-taken AFTER the POST on the exemption path, closing the
   PERMANENT forged green `ci.verdict-write-retarget-fence` listed as its residual 1. The
   retarget axis only: a push after the POST moves the head, so the status no longer gates
   that PR, while a retarget changes the effective diff with the sha unchanged.
5. An unreadable combined-status read retries once and then REPLACES the unknown state
   instead of declining to write. Declining protects a real verdict and leaves a FORGED one
   — an off-list `success` is the row #742 exists to revoke, revocation happens by
   re-deriving it, and the job then went red on a status branch protection does not read.

One defect this introduced and fixed on the way: widening the post-write gate to every
write made the job match its OWN row, because the machine-sentinel arm selects on a null
creator. A run taking the carry-forward path POSTed `$REPAIR_DESC`, then found "a sentinel
above the mark", then repaired to the identical description. `--arg own "$desc"` excludes
it, by description rather than by id — the id of the row just written is not knowable
there.

Reconciliation is what bounds the stall: a later run pages `/statuses/{sha}` in full and
either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it
upgrades to the repair sentinel, clearable only by a human — or finds none and clears it.
It is sound because the two endpoints disagree: a masked verdict is invisible on the
combined endpoint (latest row per context, which is the sentinel) and still present in the
per-POST history.

Tests: each fix is paired with a `test_MUTATION_…` proof that restores the exact
predecessor text through a new `_run_classify(mutate=…)` knob, whose count assertion is the
binding — a clause that has since moved substitutes zero times and fails loudly rather than
measuring the unmutated body. Two CHAINED tests feed run N's real output into run N+1,
because both sentinels are fixed points and a single hop cannot assert a fixed point: the
raced-`pending` repair must survive the run that would otherwise grant the exemption, and
the unverified sentinel must not decay while it cannot be reconciled.

Docs: new record `ci.verdict-unverified-write-sentinel`; the now-false guarantee prose in
`ci.verdict-write-retarget-fence` (its `rule:` frontmatter, the "resolves it" opener, "the
fence above closes", the truncating-block claim and residual 1), `ci.exemption-provenance`,
`docs/ci-cd.md`, `docs/remote-state-inventory.md` and `CLAUDE.md` corrected by concept
rather than by phrase, per the scope boundary recorded on the issue.

fixes #849
Decisions-Edit: yes

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

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

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

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

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

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

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

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

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

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

fixes #845

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-30 02:43:08 +00:00
timothyandClaude Opus 5 1d50241833 docs(853): workflow_dispatch can't be ref-restricted at 1.27.1 — and restricting it would close nothing (#888)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 12m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 8m19s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 8m10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 21s
Probed every reachable surface rather than stopping at the endpoint #853 already knew 404s: the dispatch API takes `ref` as a required free-form string with no allow-list; protected environments do not exist at 1.27.1 (0 of 308 documented paths mention "environment", secrets are org/repo/user-scoped only); the loaded `app.ini` sets two `[actions]` keys; and the CLI's sole Actions subcommand is `generate-runner-token`. So option 3 is unavailable.

Accepted on a different ground than the issue proposed. "Anyone with repository write can already do worse" is unfalsifiable and hides the cheaper route. The operative reason is that dispatch is not the cheapest path: `docker-build.yml`'s head-resolved `pull_request:` runs attacker-authored YAML, which reaches every secret in the store — six of its jobs hold `REGISTRY_PASSWORD` on that route and two are branch-protection required contexts. "Push a branch, open a PR" costs no act outside the ordinary contribution flow, where a dispatch costs one.

Corrections to #853's own table, verified against the tree: `dependency-scan.yml` references no secrets at all; the "four workflows" count is right.

Deliberately not applied: a `v*` tag protection (`tag_protections` is empty and 1.27.1 supports it) — protection-class config whose failure mode is a broken release cut, so it needs its own change and verification. Tracked with the `pull_request:` residual in #885.

The web UI was not swept, and the record says so explicitly rather than claiming exhaustiveness — a Gitea Actions control can exist with no API surface at all.

fixes #853

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

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

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

fixes #836

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

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

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

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

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

fixes #820

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-29 22:06:38 +00:00
207 changed files with 20796 additions and 1930 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ set -uo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# git hook: decides by exit code, and its stdout is live progress text.
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin decisions-guard "" stream || true
+1 -1
View File
@@ -19,7 +19,7 @@ set -euo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin design-sync-reminder "${1:-}" capture || true
+1 -1
View File
@@ -7,7 +7,7 @@ set -euo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin posttooluse-worktree-marker "" capture || true
@@ -17,7 +17,7 @@ set -uo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# git hook: decides by exit code, and its stdout is live progress text.
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin prepush-clean-worktree-check "" stream || true
+1 -1
View File
@@ -14,7 +14,7 @@ set -euo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# git hook: decides by exit code, and its stdout is live progress text.
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin prepush-donewhen "" stream || true
+1 -1
View File
@@ -11,7 +11,7 @@ set -uo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# git hook: decides by exit code, and its stdout is live progress text.
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin prepush-rebase-check "" stream || true
+4 -4
View File
@@ -12,9 +12,9 @@
# no forcing function was the one that got defaulted. A check that runs beats a rule you must remember
# (the same reasoning as pretooluse-bom-guard.sh).
#
# SCOPE — gate EVERY dispatch that names no model, not just implementer-looking ones. The first cut
# tried to be clever: it fired only when the prompt text matched implementer signals (`git commit`,
# `worktree`, `fixes #`…). Review of that version (#583) confirmed the heuristic both over- and
# SCOPE — gate EVERY dispatch that names no model, not just implementer-looking ones. A NARROWER
# cut was TRIED AND REJECTED: it fired only when the prompt text matched implementer signals (`git
# commit`, `worktree`, `fixes #`…). Measured (#583), the heuristic both over- and
# under-fired — a read-only recon brief mentioning "worktree" nagged, while "author the change and
# open a PR", "land this on the branch" and "make the changes and commit them" all sailed through
# silently, i.e. it missed the exact case it existed to catch. Prompt prose is not a reliable signal
@@ -42,7 +42,7 @@ set -uo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin pretooluse-agent-model "" capture || true
+1 -1
View File
@@ -6,7 +6,7 @@ set -euo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin pretooluse-agent-ram "" capture || true
+1 -1
View File
@@ -5,7 +5,7 @@ set -euo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin pretooluse-bash-guard "" capture || true
+1 -1
View File
@@ -20,7 +20,7 @@ set -uo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin pretooluse-bom-guard "" capture || true
+142 -43
View File
@@ -9,8 +9,8 @@
# "re-review the fix commit, not just the initial PR diff").
#
# EVERY ONE OF THOSE IS A SNAPSHOT, taken when the merge tool is called. The window is SMALL for an
# immediate merge and UNBOUNDED for a scheduled one. Small is not zero, and this comment used to say
# "sound", which is the overclaim ersatztv#778 removed: this hook returns `allow` and a SEPARATE call
# immediate merge and UNBOUNDED for a scheduled one. Small is not zero, and calling this gate
# "sound" is the overclaim ersatztv#778 removed: this hook returns `allow` and a SEPARATE call
# performs the merge, so a push can still land in between. The merge API accepts an optional
# `head_commit_id` that would make that call a true compare-and-set; a PreToolUse hook cannot add an
# argument, only refuse without one. With merge_when_checks_succeed, Gitea merges
@@ -45,9 +45,21 @@
# ETV_GITEA_URL overrides the base (default: the LAN instance; a LAN address, not a secret).
set -euo pipefail
# THE FIRE-LOG PATH BELOW IS SELF-LOCATED, not `${CLAUDE_PROJECT_DIR:-...}` — as is every other
# tracked hook's since ersatztv#891, byte-identically (`process.hook-resolves-inputs-from-repo-root`).
# Written here rather than beside the assignment because the instrumentation preamble that follows is
# machine-compared: `test_hook_fire_log.py::test_the_stripper_removes_EXACTLY_the_preamble_and_nothing_else`
# permits only its own recognised lines in that block, so a comment inside it fails the suite.
#
# That line is `. `-SOURCED, so whatever it names runs AS CODE inside this hook, before stdin is read
# and before `decide` exists. It is therefore not "telemetry" in any sense a gate can rely on.
# MEASURED 2026-08-30: with the env-var-first form, a `hook-fire-log.sh` in an env-var-named tree
# that prints an `allow` decision and exits 0 GRANTS THE MERGE outright, having bypassed every check
# below. Self-locating binds it to the tree this hook was loaded from and closes that.
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin pretooluse-merge-consent "" capture || true
@@ -174,11 +186,11 @@ fi
# posted before ersatztv#632 and gets NO opinion, rather than denying every in-flight PR the day
# this lands. The window closes on its own — verdicts are per-head and short-lived, so every verdict
# posted after this carries the field.
# "Could not check" is a THIRD outcome, distinct from both "matches" and "no base recorded". Cold
# review found the first draft collapsing it into the latter: an unreadable status response yielded
# an empty `recorded_base`, which took the graceful-adoption path and skipped validation silently —
# "Could not check" is a THIRD outcome, distinct from both "matches" and "no base recorded".
# Collapsing it into the latter is a false-open: an unreadable status response yields
# an empty `recorded_base`, which takes the graceful-adoption path and skips validation silently —
# after which a later, successful status read could still auto-grant. A transient failure would then
# have produced a "merge gate: satisfied" message for a comparison that never happened. Every
# produce a "merge gate: satisfied" message for a comparison that never happened. Every
# unreadable input here therefore falls through to a human (`ask`), never to silence.
# RE-READ THE BASE HERE, ONCE, FOR EVERY PATH BELOW (ersatztv#778).
#
@@ -195,8 +207,8 @@ fi
# of `process.check-and-use-pins-a-version`, so the guard enforcing that rule must not break it.
#
# This re-read first landed inside the scheduled-auto-merge branch only, which fixed the branch-
# protection lookup and left the #632 retarget DETECTION below still reading the stale snapshot. Cold
# review demonstrated the consequence with this repo's own fixture: scheduled+retarget denied, while
# protection lookup and left the #632 retarget DETECTION below still reading the stale snapshot.
# Measured on this repo's own fixture: scheduled+retarget denied, while
# immediate+retarget auto-GRANTED. That is the twin-missed shape — a fix applied to the path where it
# was noticed — so the re-read is hoisted above every consumer rather than duplicated into each.
prjson_now=$(gq "repos/$owner/$repo/pulls/$pr")
@@ -321,6 +333,50 @@ for n in $issues; do
fi
done
# ONE branch-protection READ per run (ersatztv#859). Two arms consume this endpoint — the scheduled
# path's `review-verdict/h10` required-check test, and the guard-scope freshness check at the bottom
# — and they used to issue independent GETs, so a scheduled auto-merge hit it twice (measured: the
# test stub recorded 2 URLs).
#
# THE ROUND TRIP IS THE SMALLER HALF. What matters is that branch protection is MUTABLE config: two
# reads can return two different answers, and the gap between them is a gap in which the two arms
# decide about different repo states — one concluding `review-verdict/h10` is required on the base
# while the other classifies a rule list that no longer says so. Neither arm can detect that; both
# would report confidently. Caching makes a single run internally consistent BY CONSTRUCTION, which
# is a property no retry or ordering change can supply.
#
# WHY #787 DID NOT ALREADY SHARE IT, since the obvious question is why two reads existed at all: the
# arms ask genuinely different QUESTIONS — one about `$base_ref` and its required contexts, one about
# `main` and snapshot freshness — so their classifications must stay separate. But they ask those
# questions of the same URL with the same credentials, so the RESPONSE is shareable even though the
# verdicts are not. Cache the bytes; never cache a verdict.
#
# This does NOT pin anything: protection can still change after the read, and the honest ceiling is
# unchanged (`process.check-and-use-pins-a-version`). It removes a second window, it does not remove
# the first.
bp_fetched=no
bp_cache=""
bp_cache_code=""
fetch_branch_protections() {
# Idempotent by design: every caller invokes it unconditionally and the FIRST one pays. A caller
# that had to know whether it was first would be a second place for the two arms to disagree.
if [ "$bp_fetched" = yes ]; then return 0; fi
bp_fetched=yes
local f
# A temp-file failure gets its own sentinel rather than an HTTP-shaped one, so each caller can
# keep the distinct message it had before this was shared. Reporting a mktemp failure as HTTP
# '000 — Gitea unreachable' would state a cause that did not happen, which is the defect class
# this whole file is organised around.
f=$(mktemp) || { bp_cache=""; bp_cache_code=mktemp-failed; return 0; }
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
bp_cache_code=$(curl -s -o "$f" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
else
bp_cache_code=$(curl -s -o "$f" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
fi
bp_cache=$(cat "$f" 2>/dev/null || true)
rm -f "$f"
}
# --- (a) CI combined status must be green (unless deferring to Gitea's own check-gate). ---
if [ "$mwcs" != "true" ]; then
[ -n "$sha" ] || decide ask "H6 merge gate: could not resolve PR #$pr head sha to check CI. Verify CI is green before merging."
@@ -445,7 +501,7 @@ else
# performs no matching and knows nothing about precedence, so a 200 from it means only "a rule
# with this NAME exists and lists this context", never "this context is required on this branch".
#
# It was used first, with the list consulted only on a 404, and cold review found what that left
# It was used first, with the list consulted only on a 404, and that design left a false-open
# behind: the precedence argument below guarded the 404 path while the 200 path — the one this
# repo actually takes — granted without it. Given a rule `main` requiring `review-verdict/h10` and
# a rule `m*` with better Priority that does not, Gitea applies `m*`, and the by-name hit on
@@ -453,13 +509,12 @@ else
# the twin rather than documenting it is the point: one fetch, one classifier, one argument, and
# no second path to keep in step. The ref no longer reaches a URL segment, so it needs no
# encoding either.
bp_file=$(mktemp) || decide ask "H6/H10 merge gate: could not allocate a temp file to read branch protection for '$base_ref'. Confirm the 'review-verdict/h10' required check manually before scheduling an auto-merge."
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
bp_code=$(curl -s -o "$bp_file" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
else
bp_code=$(curl -s -o "$bp_file" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
fetch_branch_protections
if [ "$bp_cache_code" = "mktemp-failed" ]; then
decide ask "H6/H10 merge gate: could not allocate a temp file to read branch protection for '$base_ref'. Confirm the 'review-verdict/h10' required check manually before scheduling an auto-merge."
fi
bp_list=$(cat "$bp_file" 2>/dev/null || true)
bp_code=$bp_cache_code
bp_list=$bp_cache
bp=""
if [ "$bp_code" = "200" ] && printf '%s' "$bp_list" | jq -e 'type == "array"' >/dev/null 2>&1; then
# DO NOT claim parity with Gitea's matcher — this code cannot have it, and asserting it would
@@ -522,15 +577,26 @@ else
# the two comments beside that arm were written to fix, so it is checked here rather than inherited.
classifier="$repo_root/scripts/lib/branch-rule-classifier.jq"
if [ ! -r "$classifier" ]; then
rm -f "$bp_file"
decide ask "H6/H10 merge gate: the shared branch-protection rule classifier is missing or unreadable at $classifier, so which rule governs '$base_ref' — and therefore whether 'review-verdict/h10' is required on it — could not be derived (ersatztv#787). Restore the file, or confirm the required checks manually."
fi
bp_verdict=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c -f "$classifier" 2>/dev/null || true)
case $(printf '%s' "$bp_verdict" | jq -r '.verdict // ""' 2>/dev/null || true) in
exact) bp=$(printf '%s' "$bp_verdict" | jq -c '.rule' 2>/dev/null || true); bp_code=200 ;;
undecidable) rm -f "$bp_file"
decide ask "H6/H10 merge gate: no branch-protection rule on this repo governs '$base_ref' decidably — a GLOB rule could govern it, or two rule names fold-equal, or a name is non-ASCII. This hook deliberately does not reimplement Gitea's glob matcher, so whether 'review-verdict/h10' is required on this base cannot be derived here (ersatztv#778). Confirm it in the repo's branch-protection settings, or merge immediately instead of scheduling." ;;
undecidable) decide ask "H6/H10 merge gate: no branch-protection rule on this repo governs '$base_ref' decidably — a GLOB rule could govern it, or two rule names fold-equal, or a name is non-ASCII. This hook deliberately does not reimplement Gitea's glob matcher, so whether 'review-verdict/h10' is required on this base cannot be derived here (ersatztv#778). Confirm it in the repo's branch-protection settings, or merge immediately instead of scheduling." ;;
none) bp_code=nomatch; bp="" ;;
# A DECLARED class of the classifier's contract (ersatztv#859), with its OWN sentinel — not
# merely its own arm. Giving it an arm that set `unreadable-rules`, the same value
# the catch-all sets, was measured to be a no-op: deleting that arm left the WHOLE suite
# green, because nothing downstream could tell the two apart. An arm no observation can
# distinguish is not a fix, it is a comment with syntax. (The invariant is "no test reddens",
# not a test count — a count goes stale the next time anyone adds one.)
#
# They are different findings and now say so. `unnamed-rule` means the list was READ and a rule
# in it carries no usable name; `unreadable-rules` means jq died or answered a word this hook
# does not know. Same decision (ask), different cause — and naming the cause accurately is the
# entire subject of this issue, so collapsing them here would have reproduced the defect being
# fixed, one arm over.
unreadable) bp_code=unnamed-rule; bp="" ;;
*) bp_code=unreadable-rules; bp="" ;;
esac
else
@@ -546,7 +612,6 @@ else
fi
bp=""
fi
rm -f "$bp_file"
# `nomatch` is the CLASSIFIER's verdict, deliberately not an HTTP code. Reusing 404 for it made
# this deny reachable from an HTTP 404 on the list read too — repo not found, or invisible to the
# credential, which Gitea also answers 404 — and then the reason claimed "the full rule list was
@@ -555,11 +620,26 @@ else
if [ "$bp_code" = "nomatch" ]; then
decide deny "H6/H10 merge gate: BLOCKED — no branch-protection rule on this repo can govern '$base_ref' (the full rule list was read and none matches), so 'review-verdict/h10' is not a required check on it. A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622). Restore branch protection on '$base_ref', or merge immediately (without merge_when_checks_succeed) once CI is green."
fi
# `unreadable-rules` is the CLASSIFIER failing on a 200 it could not parse — a numeric
# `branch_name` makes jq throw, and `//` does not catch it because it fires only on null/false.
# It gets its own sentinel for the same reason `nomatch` does: reporting "HTTP '000' — Gitea
# unreachable" about a successful 200 read states a cause that did not happen, which is the defect
# fixed one arm over for the deny.
# `unnamed-rule` is the classifier reporting a rule whose NAME it could not use. Two distinct
# shapes, and the reason string must cover both or it states a cause that did not happen: EITHER
# both fields supply no name (absent, null, or empty), OR one of them is present holding a
# non-string, which poisons the rule however good its sibling is. It is deliberately NOT reported as
# "no rule matches": a rule that cannot be read might be the rule Gitea is applying, so a list
# containing one supports no finding about which rule governs the base. That was the #859 defect —
# `""` is a valid name that matches nothing, so an unreadable rule DENIED with a stated cause that
# had not happened.
if [ "$bp_code" = "unnamed-rule" ]; then
decide ask "H6/H10 merge gate: a branch-protection rule on this repo carries no name this hook can use — either both 'branch_name' and 'rule_name' are absent/null/empty, or one of them is present holding something that is not a string. Which rule governs '$base_ref', and whether 'review-verdict/h10' is required on it, therefore could not be derived. A rule that cannot be read might be the one Gitea applies, so this is deliberately NOT reported as 'no rule matches' (ersatztv#859). Inspect the branch-protection rules, or merge immediately instead of scheduling."
fi
# `unreadable-rules` is the CLASSIFIER failing on a 200 this hook could not turn into a verdict —
# jq died, or answered a word this contract does not define. It gets its own sentinel for the same
# reason `nomatch` does: reporting "HTTP '000' — Gitea unreachable" about a successful 200 read
# states a cause that did not happen, which is the defect fixed one arm over for the deny.
#
# A numeric `branch_name` was the worked example here until ersatztv#859 and no longer reaches this
# arm: it is not a usable NAME, so the classifier now classifies it rather than throwing on it, and
# it lands on `unnamed-rule` above with the cause that actually applies. The example is corrected
# rather than dropped, because it is the one shape a reader is likely to reach for when testing.
if [ "$bp_code" = "unreadable-rules" ]; then
decide ask "H6/H10 merge gate: this repo's branch-protection rules came back in a shape this hook could not parse, so whether 'review-verdict/h10' is required on '$base_ref' is unknown. Check the rules manually, or merge immediately instead of scheduling."
fi
@@ -620,7 +700,19 @@ fi
# inside a fenced code block (documentation showing the convention counted as a real verdict), and a
# sha taken from the first `@<hex>` anywhere on the line (a markdown link could supply it). Every
# decision the classifier makes is documented there; this file only maps a class onto a hook decision.
verdict_script="${CLAUDE_PROJECT_DIR:-.}/scripts/check-review-verdict.sh"
# RESOLVED FROM `$repo_root`, never `$CLAUDE_PROJECT_DIR` — the rule, the threat model and the
# boundary are in `process.hook-resolves-inputs-from-repo-root` (ersatztv#858, #891). Written once there
# rather than twice here: this file carried two resolutions of the same question, and the guard-scope
# arm below is the other one. Two answers in one file is the state most likely to be "tidied" toward
# the weaker side, so neither site restates the argument now.
#
# Site-specific consequence only: a `$CLAUDE_PROJECT_DIR` naming a sibling worktree — routine here —
# would classify THIS PR's comments with THAT tree's copy of the H10 grammar.
#
# `ETV_HOOK_FIRE_LIB` at the top of this file is bound the same way, and for a STRONGER reason — it
# is sourced, so it is code. See the block above it. Since #891 every tracked hook binds it
# identically, and `test_hook_fire_log.py` fails any that stops doing so.
verdict_script="$repo_root/scripts/check-review-verdict.sh"
if [ ! -x "$verdict_script" ]; then
decide ask "H10 merge gate: verdict classifier not found at $verdict_script, so the review state can't be derived. Confirm the review covered the latest commit before merging."
fi
@@ -678,7 +770,7 @@ esac
# `else` (mwcs = true) and never executes on an immediate merge, which is the common case; hanging
# the freshness check off it would fire it only when an auto-merge is armed. This file already
# records that exact defect one section up — the base re-read "first landed inside the
# scheduled-auto-merge branch only", and cold review found scheduled+retarget denied while
# scheduled-auto-merge branch only", with scheduled+retarget denied while
# immediate+retarget auto-GRANTED. Same shape, so it is not repeated here.
#
# It reads `main` (the branch the snapshot names), NOT `$base_ref`. That is a DIFFERENT question
@@ -691,14 +783,12 @@ esac
# stale is a guard's scope — a different artifact, on a different clock. Denying would state
# something false about the change in front of the reader. Every non-`match` class asks, so a
# comparison that could not be made is surfaced rather than skipped (`unknown` is not `fine`).
# ONE base for both the checker and the snapshot, and it is `$repo_root` — derived from this file's
# own location — rather than `$CLAUDE_PROJECT_DIR`. Two reasons, and the second is the load-bearing
# one. Resolving them from different roots would let the hook classify one checkout's snapshot with
# another checkout's script, mismatched halves of a comparison whose whole job is to detect a
# mismatch. And an ENV VAR is not a sound input to a security decision: a wrong value pointing at a
# tree that happens to contain an executable checker returns `match` about a different checkout
# entirely, which silently authorizes the grant. A missing path only asks, so the failure is quiet
# exactly where it is worst.
# ONE base for both the checker and the snapshot, and it is `$repo_root` — see
# `process.hook-resolves-inputs-from-repo-root` for why an env var may not select either
# (ersatztv#787, #858). The reason specific to THIS arm is that both halves of a comparison are
# resolved here: from two different roots the hook would classify one checkout's snapshot with
# another checkout's script — mismatched halves of a comparison whose entire job is to detect a
# mismatch — and answer `match` about a tree nobody asked about.
ctx_base="$repo_root"
ctx_snapshot="$ctx_base/.gitea/required-status-contexts.json"
ctx_script="$ctx_base/scripts/check-required-contexts.sh"
@@ -725,24 +815,33 @@ if [ "$ctx_repo_fold" = "$target_repo_fold" ]; then
if [ ! -x "$ctx_script" ]; then
decide ask "H6 merge gate: the required-contexts checker is missing or not executable at $ctx_script, so whether the dropped-step guard's scope still matches branch protection on 'main' could not be derived (ersatztv#787). Check it manually, or restore the script."
fi
bpf=$(mktemp) || decide ask "H6 merge gate: could not allocate a temp file to read branch protection for the guard-scope freshness check (ersatztv#787)."
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
ctx_code=$(curl -s -o "$bpf" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
else
ctx_code=$(curl -s -o "$bpf" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
# THE SHARED READ (ersatztv#859). On a scheduled merge the arm above already fetched this; here that
# call is a cache hit, so the endpoint is read once per run instead of twice. On the IMMEDIATE path
# this is the only consumer and it performs the fetch itself, which is why the call sits AFTER the
# `[ ! -x "$ctx_script" ]` check above: a missing checker must ask without having touched the
# network, and a test pins exactly that by asserting no branch-protection URL was recorded.
fetch_branch_protections
if [ "$bp_cache_code" = "mktemp-failed" ]; then
decide ask "H6 merge gate: could not allocate a temp file to read branch protection for the guard-scope freshness check (ersatztv#787)."
fi
ctx_code=$bp_cache_code
# ONE temp file, and it holds the checker's STDERR. Until ersatztv#859 this was `mktemp` for the
# payload plus an unmanaged `$bpf.err` beside it — a second path mktemp never created and therefore
# never made unpredictable. The payload now comes from the shared cache over a pipe, so the only
# thing still needing a file is the diagnostic, and it gets the mktemp'd one.
ctx_err=$(mktemp) || decide ask "H6 merge gate: could not allocate a temp file for the guard-scope freshness check's diagnostics (ersatztv#787)."
if [ "$ctx_code" = "200" ]; then
# stderr is KEPT, not sent to /dev/null. The checker exits 2 with a diagnostic on a usage error —
# an unreadable snapshot, a branch mismatch, a missing classifier — and discarding it made all of
# those arrive at the operator as the catch-all's "returned 'nothing'", which names no cause. That
# is the same states-a-cause-that-did-not-happen shape this arm was careful about elsewhere.
ctx_class=$("$ctx_script" --branch main --snapshot "$ctx_snapshot" < "$bpf" 2>"$bpf.err" || true)
ctx_diag=$(tr '\n' ' ' < "$bpf.err" 2>/dev/null | cut -c1-300 || true)
ctx_class=$(printf '%s' "$bp_cache" | "$ctx_script" --branch main --snapshot "$ctx_snapshot" 2>"$ctx_err" || true)
ctx_diag=$(tr '\n' ' ' < "$ctx_err" 2>/dev/null | cut -c1-300 || true)
else
ctx_class=readfail
ctx_diag=""
fi
rm -f "$bpf" "$bpf.err"
rm -f "$ctx_err"
case "$ctx_class" in
match) : ;;
drift)
+1 -1
View File
@@ -5,7 +5,7 @@ set -euo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin pretooluse-nav-guard "" capture || true
+1 -1
View File
@@ -11,7 +11,7 @@ set -euo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin pretooluse-worktree-guard "" capture || true
+6 -1
View File
@@ -176,7 +176,7 @@ docker compose -f $D/compose.yaml up -d --no-deps ersatztv-test
### Mutation Endpoints (POST)
```bash
# Library scan
# Library scan (?deep=true also rescans items a media server reports unchanged)
POST /api/v1/libraries/{id}/scan
# Scan single show
@@ -472,6 +472,11 @@ docker start ersatztv
### Common Mistakes (check every time)
- **Playout not building**: Three things must all be correct: (1) `ProgramScheduleOneItem` row exists for the schedule item, (2) `PlaybackOrder=3` (Shuffle), (3) `ScheduleKind=1` on Playout. Missing any one results in 0 playout items — this is the most common issue.
- **Collection queries fail**: `CollectionItem` has no `Id` column — use `rowid` for counting. Content lookup goes through `MediaVersion.MovieId` → `MediaFile.Path` (not a generic MediaItemId join).
- **Channel won't start — "has no video stream on record"** (images before v26.16.0, which carries the ersatztv#924 fix, log a bare
`Sequence contains no matching element` at `FFmpegStreamSelector.SelectVideoStream`): the playing item's
`MediaVersion` has no `MediaStream` rows. Deep-scan its library — `POST /api/v1/libraries/{id}/scan?deep=true`;
a regular scan skips items whose server etag is unchanged and cannot heal them. If the file itself has no video
stream, no scan heals it. Jellyfin music videos scanned before that fix were all stored without streams (`scan.mediaserver-statistics-from-server`).
- **Channel logos forgotten**: After creating a channel, add an Artwork row (ArtworkKind=2) + logo file, then run `fix_logos.py` to push to Jellyfin. Without this, the channel shows no logo in the EPG.
- **Playout reset required**: After any schedule/collection change, run `POST /api/v1/channels/{id}/playout/reset`. Wait 5-10s for the playout to build before verifying item count.
+241
View File
@@ -0,0 +1,241 @@
export const meta = {
name: 'ersatztv-issue-build',
description: 'Close one ersatztv issue or bundle in its own worktree via PR: claim, recon, implement, local gate, adversarial review before the push, fix loop, single push, PR, closing record',
phases: [{ title: 'Recon' }, { title: 'Implement' }, { title: 'Review' }, { title: 'Fix' }, { title: 'Land' }],
}
// args: { issues: [n,...], slug, title, body_summary, done_condition, files_likely, area, size, risk: 'routine'|'rubric',
// needs_e2e, port: the slot's ETV_UI_PORT, avoid: [{issues, files}], trailer: 'Co-Authored-By: ...\nClaude-Session: ...',
// effort?: 'xhigh' for lock/threading/migration work, model?: override for the implementer/fixer }
if (!args || !Array.isArray(args.issues) || !args.issues.length || !args.trailer || !/Claude-Session: \S+/.test(args.trailer) || !(Number.isInteger(args.port) && args.port > 1024 && args.port < 65000)) {
return { error: 'args.issues (non-empty), args.trailer (with a Claude-Session: line) and an integer args.port in (1024, 65000) are required' }
}
const issues = args.issues
const ISSUE = issues[0]
const REF = issues.map(n => '#' + n).join(', ')
const BRANCH = `${issues.join('-')}-${(args.slug || 'work')}`
const WT = `/Users/timothy/orca/workspaces/ersatztv/wt-${issues.join('-')}`
const SHARED = '/Users/timothy/ersatztv'
const API = 'http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv'
const big = args.size === 'large'
const rubric = args.risk === 'rubric'
const implModel = args.model || (args.size === 'small' ? 'sonnet' : 'opus')
const implEffort = args.effort || (args.size === 'small' ? 'medium' : 'high')
const TRAILER = args.trailer
const SESSION_URL = TRAILER.split('\n').filter(l => l.startsWith('Claude-Session:')).map(l => l.replace('Claude-Session: ', '')).join('\n')
const COMMON = `Project: ersatztv, a fork of the ErsatzTV IPTV channel server (C#/.NET + a React SPA under web/). Shared checkout ${SHARED} is READ-ONLY for you: never commit there and never read its git log or HEAD as truth about main (process.shared-tree-readonly) — origin/main after a fetch is the only truth.
Issue(s) ${REF}: "${args.title}".
Issue body (condensed by a picker; read the real thing): ${args.body_summary}
DONE CONDITION: ${args.done_condition}
Read every issue in the bundle and all its comments yourself: curl -s -u "$ETV_GITEA_BASICAUTH" ${API}/issues/${ISSUE} and ${API}/issues/${ISSUE}/comments (the env var is set; never write the credential into a file or a commit).
Other slots of this session are working IN PARALLEL and will edit these files; do not touch them, and if your fix genuinely needs one of them, stop and report it instead of editing:
${JSON.stringify(args.avoid || [], null, 1)}
Working rules, non-negotiable:
- Docs-first is a HARD RULE: read CLAUDE.md, then docs/README.md's task-signal map and ONLY the sections it points to for this task, then docs/contributing.md for the code you touch. Decisions resolve through docs/decisions/README.md by key, never by chasing a file path named in an old comment. Do not reverse-engineer conventions from source before reading these.
- Docs-update is part of done, same PR: an endpoint change updates docs/api-conventions.md's checklist and regenerates v1.json + endpoint-index.md via ./scripts/update-openapi.sh (build the app project first, then the script, then npm run generate:api under web/); a screen or route change updates docs/blazor-route-parity.md + docs/domain-model.md; a new or reversed convention gets a record under docs/decisions/records/<area>/ and a regenerated catalog (PYTHONPATH=. python3 scripts/build_decisions_catalog.py — the catalog docs/decisions/README.md is generated and shared with other slots: never hand-edit it, regenerate it, and resolve a rebase conflict in it by regenerating); a new or retitled doc updates docs/README.md.
- A TvContext model change needs a migration in BOTH providers: scripts/add-migration.sh <Name>.
- Tests are NUnit + Shouldly + NSubstitute in the existing *.Tests projects; vitest under web/. Pin the behaviour with a test that reddens when the fix alone is removed; never set ETV_UPDATE_GOLDENS or ETV_UPDATE_PLAYOUT_GOLDENS.
- Dependencies use Central Package Management: versions live only in Directory.Packages.props.
- Docs record the end state, never the investigation (docs.no-session-narrative): the path goes in the commit message and the issue comment. Date any measurement you write into a doc.
- Gitea labels take their own endpoint: POST ${API}/issues/{n}/labels {"labels":[100]} adds in-progress, DELETE ${API}/issues/{n}/labels/100 removes it; PATCH silently ignores labels.
- Never use bare git stash (the stash stack is shared across worktrees; commit WIP instead). Never push to main (it is refused server-side anyway). Never amend or force-push a pushed branch; a fix after the push is a new commit. Never cd out of your worktree except to read the shared checkout read-only.
- Kill only PIDs you started; never pkill by name — other sessions run dotnet and Playwright on this machine.`
const WORKTREE = `Worktree: ${WT} on branch ${BRANCH}. Check git -C ${SHARED} worktree list; if absent: git -C ${SHARED} fetch origin && git -C ${SHARED} worktree add ${WT} -b ${BRANCH} origin/main (absolute path, as written). Then give it its own web/node_modules: if cmp -s ${SHARED}/web/package-lock.json ${WT}/web/package-lock.json then cp -Rc ${SHARED}/web/node_modules ${WT}/web/node_modules, else (cd ${WT}/web && npm ci). Do ALL work inside ${WT}. If git commit is denied by the worktree-owner guard, the worktree belongs to ANOTHER session (orchestrated worktrees carry no marker): never overwrite the marker — STOP and report done=false with the guard's message. Commit as you go; every commit message ends with these trailer lines exactly:
${TRAILER}`
const CLAIM = `CLAIM FIRST, the four-way check from the kickoff (process.parallel-session-claim), for EVERY issue in the bundle: git -C ${SHARED} fetch origin; curl the open PRs (${API}/pulls?state=open&limit=50, page until empty) for a body saying fixes/refs ${REF}; ${issues.map(n => `git -C ${SHARED} ls-remote --heads origin '*${n}*'`).join('; ')}; read each issue's comments for a claim that predates the label. If a PR, branch or comment shows another session already on ${REF} (other than this orchestrator's note, if any), STOP and report done=false with the evidence. Otherwise add the in-progress label and post a claiming comment naming branch ${BRANCH} and worktree ${WT}, on every issue in the bundle. If an issue body has no "## Done-when" section, append one (PATCH ${API}/issues/{n} with the full body): one unticked box per concrete completion criterion drawn from the issue, plus "- [ ] Adversarial review passed". The merge gate derives consent from those boxes; the orchestrator ticks them from your evidence, so write criteria that can be evidenced.`
const gateFor = (port, where) => `LOCAL GATE (process.local-gate-before-push) — run it inside ${where} and read the real output; a skipped test is not a passing one:
- .NET: dotnet build the solution, then dotnet test on every test project that covers what you touched (ErsatzTV.Tests, ErsatzTV.Core.Tests, ErsatzTV.Scanner.Tests, ErsatzTV.FFmpeg.Tests, ErsatzTV.Architecture.Tests — all of them for anything under ErsatzTV.Core). Before any push touching .cs: BOM-check the touched set with od -A n -t x1 -N 3 <file> (efbbbf = BOM) and run bash -c 'dotnet format whitespace . --folder --verify-no-changes --include <files>' (process.bom-format-detection-recipe).
- SPA: cd web && npm run check:api && npm run lint && npm run typecheck && npm run build && npm test.
- scripts/, .claude/, .husky/, .gitea/: PYTHONPATH=. python3 -m pytest scripts/tests -q, plus ruff check and ruff format --check on any Python you touched. A new executable under scripts/ or .claude/hooks/ needs its row in docs/remote-state-inventory.md and, if it is a guard, in docs/guard-inventory.md — the suites say so.
- Docs: python3 scripts/check-doc-narrative.py --diff origin/main and answer what it flags (it is advisory, the rule is not).
- Live-E2E${args.needs_e2e ? ' IS REQUIRED for this change (write path or UI)' : ' only if you changed a write path or a screen'}: ETV_UI_PORT=${port} scripts/e2e-local.sh <fresh CONFIG_DIR> — port ${port} is yours; one run at a time in that worktree; curl the endpoints, never a browser tab; when done, kill the PID the launcher printed and nothing else. The launcher's pre-flight refuses a busy port and names the holder: report that, do not pick another port and never kill the holder.
- Builds on this Mac are capped at 3–4 concurrent and other slots are building too: run the .NET and web gates sequentially, not in parallel with each other.`
const GATE = gateFor(args.port, WT)
const REPORT_SCHEMA = {
type: 'object',
required: ['done', 'summary', 'verified', 'left', 'commits', 'head_sha'],
properties: {
done: { type: 'boolean' },
summary: { type: 'string', description: 'what was built, file by file' },
verified: { type: 'string', description: 'exact gate commands run and their real output summary (test counts, E2E result)' },
left: { type: 'string', description: 'what is not done and why; what the next agent must know' },
commits: { type: 'string', description: 'git log --oneline origin/main..HEAD' },
pr_url: { type: 'string' },
head_sha: { type: 'string', description: 'git rev-parse HEAD of YOUR WORKTREE after your last commit (not a PR head) — the finisher derives fix commits from these' },
patch_changed: { type: 'boolean', description: 'finisher only: true if the pre-push rebase changed the patch-id (a conflict resolved or an artifact regenerated)' },
},
}
const FINDINGS_SCHEMA = {
type: 'object', required: ['findings', 'verdict'],
properties: {
verdict: { type: 'string', enum: ['merge', 'send-back'] },
findings: { type: 'array', items: { type: 'object', required: ['severity', 'file', 'summary', 'evidence'], properties: {
severity: { type: 'string', enum: ['blocking', 'should-fix', 'nit'] }, file: { type: 'string' }, summary: { type: 'string' }, evidence: { type: 'string' } } } },
},
}
const RUNNER_SCHEMA = {
type: 'object', required: ['findings', 'verdict', 'ran'],
properties: {
ran: { type: 'boolean', description: 'false if codex produced no VERDICT line — required, because the fallback branches on it' },
verdict: FINDINGS_SCHEMA.properties.verdict, findings: FINDINGS_SCHEMA.properties.findings,
},
}
const LAND_SCHEMA = {
type: 'object', required: REPORT_SCHEMA.required.concat(['patch_changed']),
properties: REPORT_SCHEMA.properties,
}
const RECON_SCHEMA = {
type: 'object', required: ['plan', 'facts', 'risks', 'test_plan'],
properties: {
plan: { type: 'string', description: 'files, handlers, components, signatures, exact edits' },
facts: { type: 'string', description: 'what the docs the task-signal map names and the existing code say, with paths and decision keys' },
risks: { type: 'string' }, test_plan: { type: 'string', description: 'tests to add and the gate or E2E route that proves the done condition' },
},
}
let recon = null
if (big) {
phase('Recon')
recon = await agent(`${COMMON}
You are the recon agent. Read-only, in ${SHARED}. Read the docs the task-signal map names for this task, then find every fact an implementer needs to close ${REF} without re-deriving it: the exact handlers, components, signatures, call sites and guards, the existing tests, and which gate or E2E route proves the done condition. For a multi-site sweep use the csharp-lsp MCP tools, not the LSP tool (docs/local-lsp-tooling.md). Produce a concrete plan.`,
{ label: 'recon', model: 'opus', effort: 'high', schema: RECON_SCHEMA })
}
phase('Implement')
const impl = await agent(`${COMMON}
${WORKTREE}
${CLAIM}
${recon ? `Recon (verify what you rely on):\nPLAN: ${recon.plan}\nFACTS: ${recon.facts}\nRISKS: ${recon.risks}\nTEST PLAN: ${recon.test_plan}\n` : ''}
You are the implementer. Close ${REF} completely: pin the behaviour with tests named for the branch they protect, update the docs the change obligates, commit. Then git fetch origin and rebase onto origin/main if it moved (never merge main in; regenerate generated artifacts), run the LOCAL GATE and STOP — do not push; reviewers read your worktree first, and a finisher pushes once after the review loop is clean. ${GATE}
Report done=true with the gate output when the worktree is ready for review, with pr_url empty and head_sha = git rev-parse HEAD of the worktree after your last commit.`,
{ label: `impl:${REF}`, model: implModel, effort: implEffort, schema: REPORT_SCHEMA })
if (!impl) return { issues, error: 'implementer returned nothing' }
if (!impl.done) return { issues, error: 'implementer stopped', impl }
const reviewCommon = (e2ePort) => `${COMMON}
${gateFor(e2ePort, 'your own isolated worktree (never ' + WT + ')')}
Worktree ${WT}, branch ${BRANCH}, not yet pushed; diff: git -C ${WT} diff origin/main...HEAD. Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or run tests, do it in your own isolated worktree, never in ${WT}: git fetch ${WT} ${BRANCH} && git checkout --detach FETCH_HEAD puts the unpushed branch there; run the .NET and web gates sequentially — other slots are building; E2E there on port ${e2ePort} (the GATE above is written for your worktree and that port).`
const LENSES = [
{ key: 'correctness', model: 'opus', isolation: 'worktree', prompt: 'correctness against the done condition: run the gate and, for a write path or screen, the live-E2E route yourself, and read the output; try to break the change with the edge cases the issue and the docs name; check the pinning test actually reddens when the fix alone is reverted (mutate the clause, not the file).' },
{ key: 'conformance', model: 'sonnet', prompt: 'repo conformance: docs-update obligations met in this diff (endpoint → api-conventions + regenerated v1.json/endpoint-index; screen/route → blazor-route-parity + domain-model; convention → decision record + regenerated catalog; new doc → README index); no narrative in docs; every new script or hook has its inventory row; CPM respected; both-provider migration if the model changed; tests are NUnit/vitest in the existing projects; no BOM in touched .cs; no edit to a file another slot owns (listed above); commit trailers present; branch rebased on current origin/main; nothing pushed yet.' },
]
let xfamilyFailedRound = null
let xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)'
async function codexRunner(round) {
const r = await agent(`${reviewCommon(Number(args.port) + 3)}
You run the cross-family review — the diff touches a class where process.independent-review-rubric requires a reviewer from another model family, and you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of the diff of branch ${BRANCH} against origin/main in ${WT} for issue(s) ${REF} with done condition "${args.done_condition}", listing findings as blocking / should-fix / nit with file and evidence, ending with a line VERDICT: merge or VERDICT: send-back. Run it EXACTLY like this, in the background, output to a file, stdin from /dev/null (it hangs otherwise): codex exec -C ${WT} -s read-only "$(cat <prompt>)" < /dev/null > <out> 2>&1 — then wait for the process to exit (poll pgrep on its PID with Monitor; measured 2026-07-28 in the #672 session, a real review took ~35 minutes for a 7-file diff) and read the file. Return its findings faithfully in the schema with ran=true; if the file has no VERDICT line the run failed (quota, tool error) — return ran=false, verdict merge, no findings, and put the file's tail in a single nit finding so the failure is visible; never invent a verdict.`,
{ label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: RUNNER_SCHEMA })
return r
}
async function codexFallback(round, r) {
xfamily = `codex could not run in round ${round} (${r ? 'no VERDICT line' : 'runner returned nothing'}); substituted a cold same-family review-only agent per process.independent-review-rubric — retry cross-family next window`
log(`${REF}: ${xfamily}`)
return agent(`${reviewCommon(Number(args.port) + 2)}
You are a COLD, review-only substitute for a cross-family reviewer that could not run. You have seen none of this branch before. Lens: adversarial correctness AND security of the diff against the done condition — the classes process.independent-review-rubric names (locks/concurrency, auth/security, API write paths, migrations, large C# diffs). Run the gate in your own worktree and read the output; report only what you verified, with evidence. blocking = done condition or a repo rule violated; should-fix = real defect; nit = style. Verdict send-back if any blocking.`,
{ label: `review:fallback:r${round}`, phase: 'Review', model: 'opus', effort: 'high', isolation: 'worktree', schema: FINDINGS_SCHEMA })
}
async function review(round) {
// Per round, like blocking/sendBack: a substitute that failed in round 1 says nothing about the tree
// that lands after round 2, and a stale xfamily string must never reach the PR body.
xfamilyFailedRound = null
xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)'
// The Codex runner builds nothing, so it may run beside the lenses; the FALLBACK is a second
// worktree-isolated .NET reviewer and starts only after both lenses have returned.
const runnerPromise = rubric ? codexRunner(round).catch(() => null) : Promise.resolve(null)
const lenses = (await parallel(LENSES.map(l => () => agent(`${reviewCommon(Number(args.port) + 1)}
Review round ${round} of the branch for ${REF}. Lens: ${l.prompt}
Be adversarial; report only what you verified, with evidence. blocking = done condition or a repo rule violated, or a test that passes for the wrong reason; should-fix = real defect; nit = style. Verdict send-back if any blocking.`,
{ label: `review:${l.key}:r${round}`, phase: 'Review', model: l.model, effort: 'high', isolation: l.isolation, schema: FINDINGS_SCHEMA })))).filter(Boolean)
if (!rubric) return lenses
const r = await runnerPromise
if (r && r.ran === true) return lenses.concat([r])
let fb = null
try { fb = await codexFallback(round, r) } catch (e) { log(`${REF}: fallback reviewer threw: ${e && e.message}`) }
if (!fb) { xfamily += ` — the substitute ALSO failed in round ${round}; no cross-family-equivalent review ran`; xfamilyFailedRound = round }
return fb ? lenses.concat([fb]) : lenses
}
let round = 1
const actionable = rs => rs.flatMap(r => r.findings.filter(f => f.severity === 'blocking' || f.severity === 'should-fix'))
const countBy = (rs, sev) => rs.flatMap(r => r.findings).filter(f => f.severity === sev).length
let knownHead = impl.head_sha
let reviews = (await review(round)).filter(Boolean)
if (!reviews.length) return { issues, error: `review round ${round} produced no reviews (every lens failed); not pushed`, history: [] }
let blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking'))
let sendBack = actionable(reviews)
const history = [{ round, reviews, fix: null, fix_range: null }]
while (sendBack.length && round < 3) {
log(`${REF} round ${round}: ${blocking.length} blocking, ${sendBack.length - blocking.length} should-fix — sending back`)
const fix = await agent(`${COMMON}
${WORKTREE}
You are the fixer. Reviewers found these problems in the unpushed branch; fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong:
${JSON.stringify(reviews.flatMap(r => r.findings.filter(f => f.severity !== 'nit')), null, 1)}
Then re-run the LOCAL GATE and STOP without pushing; the reviewers read the worktree again. ${GATE}
Report, with head_sha = git rev-parse HEAD of the worktree after your last commit.`,
{ label: `fix:r${round}`, phase: 'Fix', model: implModel, effort: implEffort, schema: REPORT_SCHEMA })
if (!fix || !fix.done) return { issues, error: `fixer for round ${round} ${fix ? 'stopped' : 'returned nothing'}; not pushed`, fix, history }
history[history.length - 1].fix = fix
history[history.length - 1].fix_range = fix.head_sha && fix.head_sha !== knownHead ? `${knownHead}..${fix.head_sha}` : null
knownHead = fix.head_sha || knownHead
round++
reviews = (await review(round)).filter(Boolean)
if (!reviews.length) return { issues, error: `review round ${round} produced no reviews (every lens failed); not pushed`, history }
blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking'))
sendBack = actionable(reviews)
history.push({ round, reviews, fix: null, fix_range: null })
}
if (blocking.length) return { issues, error: 'blocking findings after two fix rounds; not pushed', blocking_remaining: blocking, history }
if (sendBack.length) return { issues, error: 'should-fix findings still open after two fix rounds; not pushed — the orchestrator decides', should_fix_remaining: sendBack, history }
if (xfamilyFailedRound) return { issues, error: `the cross-family runner and its substitute both failed in round ${xfamilyFailedRound}; not pushed`, cross_family: xfamily, history }
const FIX_RANGES = history.map(h => h.fix_range).filter(Boolean)
const REVIEW_HISTORY = history.map(h => `round ${h.round}: ${h.reviews.length} lens(es); ${countBy(h.reviews, 'blocking')} blocking, ${countBy(h.reviews, 'should-fix')} should-fix, ${countBy(h.reviews, 'nit')} nit` + (h.fix ? (h.fix_range ? `; answered by the fix commit(s) in git log --oneline ${h.fix_range}` : '; answered without a new commit (findings refuted with evidence in the fixer report)') : '; clean — loop ended')).join('\n')
phase('Land')
const FINISH = `FINISH, in this order. Record the patch-id first: git diff $(git merge-base origin/main HEAD)..HEAD | git patch-id --stable. Then git fetch origin; if origin/main moved, rebase onto it (never merge main in; regenerate, never hand-resolve, generated artifacts — the decisions catalog by its generator), re-run the LOCAL GATE, and recompute the patch-id: report patch_changed=true if it differs. ${GATE}
Then ONE push: git push -u origin ${BRANCH}. Open the PR with the Gitea API (POST ${API}/pulls; head=${BRANCH}, base=main, title, body). The body must contain "fixes #N" for every issue in the bundle so the merge closes them, the root cause for a bug fix, the measured numbers, the review history VERBATIM as recorded by the workflow, one line per round, between the markers <<REVIEW HISTORY and REVIEW HISTORY>>:
<<REVIEW HISTORY
${REVIEW_HISTORY}
REVIEW HISTORY>>
${FIX_RANGES.length ? `followed by what each fix commit changed, read from git show and not from memory, for exactly the commits git log --oneline lists in these ranges: ${FIX_RANGES.join('; ')}` : (history.some(h => h.fix) ? 'and a sentence saying every finding was answered without a new commit, as the history block records' : 'and a sentence saying no fix commit exists because round one was clean')}, then the cross-family review status verbatim — "${xfamily}" — and every deliberately-left item with an issue number (file follow-up issues where needed). End the body with:
🤖 Generated with [Claude Code](https://claude.com/claude-code)
${SESSION_URL}
Arm the CI monitor: note the head sha and read ${API}/commits/<sha>/status once. Then the closing-an-issue skill (invoke it through the Skill tool if you have it, otherwise read .claude/skills/closing-an-issue/SKILL.md) with two modifications: do NOT close the issue — the merge closes it — and do NOT tick any "## Done-when" box; instead the "## Closing record" comment you post on each issue, linking the PR, ends with a "Done-when evidence" list giving, for every box, the command or artifact that evidences it — the orchestrator ticks from that. Remove nothing; the orchestrator removes the worktree after the merge. Report the PR URL, the head sha and patch_changed.`
const land = await agent(`${COMMON}
${WORKTREE}
You are the finisher. The branch has passed its review loop (${round} round(s)); nothing is pushed yet. ${FINISH}`,
{ label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: LAND_SCHEMA })
if (!land || !land.done) return { issues, error: 'finisher stopped', land, history }
if (!land.pr_url || !land.head_sha) return { issues, error: 'finisher reported done without a PR URL or head sha — the branch may already be pushed; read its report before re-running', land, history }
log(`${REF} PR: ${land.pr_url || 'none'} @ ${land.head_sha || '?'}${land.patch_changed ? ' (patch changed by the pre-push rebase)' : ''}`)
let post_rebase_reviews = null
if (land.patch_changed) {
log(`${REF}: patch changed on rebase — one more review round on the pushed head before any verdict`)
round++
post_rebase_reviews = (await review(round)).filter(Boolean)
if (!post_rebase_reviews.length) return { issues, error: 'the post-rebase review round produced no reviews (every lens failed); pushed, no verdict may be posted', pr_url: land.pr_url, head_sha: land.head_sha, cross_family: xfamily, history }
const late = actionable(post_rebase_reviews)
if (late.length) return { issues, error: 'blocking or should-fix findings on the pushed head after the pre-push rebase; no verdict may be posted', pr_url: land.pr_url, head_sha: land.head_sha, findings_remaining: late, cross_family: xfamily, history, post_rebase_reviews }
}
return { issues, pr_url: land.pr_url, head_sha: land.head_sha, patch_changed: !!land.patch_changed, cross_family: xfamily, impl, land, history, post_rebase_reviews }
+52
View File
@@ -0,0 +1,52 @@
export const meta = {
name: 'ersatztv-pick-next',
description: 'Pick the next N ersatztv issues by the kickoff queue rules from scripts/select-queue.sh and live Gitea state, mutually non-colliding and avoiding what other slots hold, then adversarially verify the set',
phases: [{ title: 'Pick' }, { title: 'Refute' }],
}
// args: { taken: [{issues:[n], files:[...]}], closed: [n...], notes: 'free text', count: how many picks to return (default 3) }
const taken = (args && args.taken) || []
const closed = (args && args.closed) || []
const notes = (args && args.notes) || ''
const count = (args && args.count) || 3
const RULES = `Work read-only in /Users/timothy/ersatztv (the shared checkout; do not modify files, push, label or comment). Never read its git log or HEAD as truth about main: run git -C /Users/timothy/ersatztv fetch origin first, then read origin/main.
Read docs/handoffs/chicorytv-issue-queue.md fully — "Current phase", "Two concurrent tracks", the Selection and Bundles rules, and step 3's four-way claim check — and docs/handoffs/orchestration.md.
Ranking is NOT yours to derive: run ETV_GITEA_BASICAUTH="$ETV_GITEA_BASICAUTH" scripts/select-queue.sh 40 (the env var is already set) and take its order as given. It already excludes in-progress, parked, PRs, bot-authored issues and anything with an open blocker. Resolve only its CLAIM? and UMBRELLA? flags, by reading the flagged issue's body and comments.
Gitea REST: base http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv, auth -u "$ETV_GITEA_BASICAUTH", curl only. Issue: GET /issues/{n}; comments: GET /issues/{n}/comments; open PRs: GET /pulls?state=open&limit=50 (page until a page comes back empty — the endpoint caps limit at 50). Remote branches naming an issue: git -C /Users/timothy/ersatztv ls-remote --heads origin '*<n>*'.
A pick is claimable only if the four-way check is clean: no open PR whose body says fixes/refs #n, no remote branch naming n, no claiming comment on the issue (a claim can precede the label), and the issue is still open after the fetch.
Bundles: after choosing an issue, scan its milestone, its cross-references and its labels for small independent siblings that are cheap to sweep in the same worktree; a bundle is one pick with several issue numbers. Never bundle issues that a taken slot already holds.
ALREADY TAKEN by this orchestrator (in flight, with the files each edits): ${JSON.stringify(taken)}
Closed this session: ${JSON.stringify(closed)}
Orchestrator notes: ${notes}`
const PICK = { type: 'object', required: ['issues', 'title', 'slug', 'rationale', 'body_summary', 'done_condition', 'files_likely', 'area', 'size', 'risk', 'needs_e2e', 'skipped'], properties: {
issues: { type: 'array', items: { type: 'integer' } }, title: { type: 'string' },
slug: { type: 'string', description: 'short kebab-case branch slug, e.g. null-font-family' },
rationale: { type: 'string' },
body_summary: { type: 'string', description: 'body plus all comments, condensed but complete; include the Done-when section verbatim if the issue has one' },
done_condition: { type: 'string' },
files_likely: { type: 'array', items: { type: 'string' } },
area: { type: 'string', enum: ['spa', 'api', 'core', 'scanner', 'ffmpeg', 'ci', 'scripts', 'docs', 'mixed'] },
size: { type: 'string', enum: ['small', 'medium', 'large'] },
risk: { type: 'string', enum: ['routine', 'rubric'], description: 'rubric = touches locks/concurrency, auth/security, an API write-path handler, a DB migration, or will exceed ~150 changed C# lines (process.independent-review-rubric); needs a cross-family review' },
needs_e2e: { type: 'boolean', description: 'true for a write path or UI change (testing.live-e2e-prepush-timing)' },
skipped: { type: 'string', description: 'each higher-ranked issue skipped and the reason' } } }
const SCHEMA = { type: 'object', required: ['picks'], properties: { picks: { type: 'array', items: PICK, description: 'in queue order; each later pick avoids the files of every earlier one' } } }
const VERDICT = { type: 'object', required: ['refuted', 'reason'], properties: { refuted: { type: 'boolean' }, reason: { type: 'string' }, bad_picks: { type: 'array', items: { type: 'integer' }, description: 'issue numbers of the picks that fail, if not all' }, better: { type: 'array', items: { type: 'integer' } } } }
phase('Pick')
const res = await agent(`${RULES}
Walk the selector's order and return up to ${count} issues or natural bundles, in that order, each of which (a) passes the four-way claim check, (b) edits no file a taken slot OR AN EARLIER PICK edits, (c) does not depend on another open issue (an earlier pick counts as open; a blocked-by dependency the selector already dropped), (d) is not a screen, handler or script an earlier pick is already on, (e) is not needs-hands or needs-the-user in disguise (a live-prod measurement nobody can take from here, a design question the body leaves open). Read each candidate's body and comments before accepting or rejecting it. Size is not a reason to skip: a large issue at the top of the queue is a pick, say size=large. Classify risk honestly — a write-path handler is rubric even when the diff is small. Stop early if the eligible queue runs out and say so in the last pick's skipped field; fewer than ${count} is fine, a colliding pair is not.`, { label: 'picker', model: 'sonnet', effort: 'medium', schema: SCHEMA })
const picks = (res && res.picks) || []
if (!picks.length) return { picks: [], refutations: [], note: 'the picker returned no eligible pick', raw: res }
log('picks: ' + picks.map(p => '#' + p.issues.join('+#')).join(', '))
phase('Refute')
const desc = picks.map(p => `- #${p.issues.join(', #')} "${p.title}" (size ${p.size}, risk ${p.risk}, area ${p.area}, e2e ${p.needs_e2e}). Rationale: ${p.rationale}. Files: ${p.files_likely.join(', ')}. Skipped: ${p.skipped}`).join('\n')
const votes = await parallel([
'ordering and claims: re-run scripts/select-queue.sh and the four-way claim check on every pick; refute if a higher-ranked eligible issue was skipped without a valid reason, the picks are out of selector order, or a pick is already claimed by a PR, branch or comment',
'collisions and classification: read the code each pick will touch; refute if any pick edits a file a taken slot or another pick edits, or the same docs section, or depends on an open issue; also refute a risk=routine pick that touches a lock, auth, an API write-path handler or a migration, and a needs_e2e=false pick that changes a write path or a screen',
].map((lens, i) => () =>
agent(`${RULES}
Picks, in order:
${desc}
Lens: ${lens}. Try to refute; name the failing picks in bad_picks and a better ordering in better.`, { label: `refute:${i}`, model: 'sonnet', effort: 'medium', schema: VERDICT })))
return { picks, refutations: votes.filter(Boolean).filter(v => v.refuted) }
+204
View File
@@ -0,0 +1,204 @@
export const meta = {
name: 'ersatztv-resume-branch',
description: 'Resume a paused ersatztv branch: finish or fix, rebase onto origin/main, local gate, adversarial review, fix loop, push, PR body and closing record refreshed',
phases: [{ title: 'Work' }, { title: 'Review' }, { title: 'Fix' }, { title: 'Land' }],
}
// args: { issues, branch, wt, pr (number or ''), mode: 'fix'|'implement', title, risk: 'routine'|'rubric', needs_e2e,
// port: the slot's ETV_UI_PORT, trailer, brief: path to a JSON file holding done_condition, findings, recon, context }
if (!args || !Array.isArray(args.issues) || !args.issues.length || !args.trailer || !/Claude-Session: \S+/.test(args.trailer) || !(Number.isInteger(args.port) && args.port > 1024 && args.port < 65000) || !args.wt || !args.branch || !args.brief) {
return { error: 'args.issues (non-empty), trailer (with a Claude-Session: line), an integer port in (1024, 65000), wt, branch and brief are required' }
}
const issues = args.issues
const REF = issues.map(n => '#' + n).join(', ')
const WT = args.wt
const BRANCH = args.branch
const SHARED = '/Users/timothy/ersatztv'
const API = 'http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv'
const rubric = args.risk === 'rubric'
const TRAILER = args.trailer
const SESSION_URL = TRAILER.split('\n').filter(l => l.startsWith('Claude-Session:')).map(l => l.replace('Claude-Session: ', '')).join('\n')
const COMMON = `Project: ersatztv, a fork of the ErsatzTV IPTV channel server (C#/.NET + a React SPA under web/). Shared checkout ${SHARED} is READ-ONLY for you: never commit there and never read its git log or HEAD as truth about main (process.shared-tree-readonly) — origin/main after a fetch is the only truth.
Issue(s) ${REF}: "${args.title}".
YOUR BRIEF is the JSON file ${args.brief}: read it first with cat. It holds done_condition, context from the orchestrator, findings (the last review round) and recon where they apply.
Read every issue in the bundle and all its comments: curl -s -u "$ETV_GITEA_BASICAUTH" ${API}/issues/N and ${API}/issues/N/comments (the env var is set; never write the credential into a file or a commit).
Working rules, non-negotiable:
- Docs-first is a HARD RULE: read CLAUDE.md, then docs/README.md's task-signal map and ONLY the sections it points to for this task, then docs/contributing.md for the code you touch. Decisions resolve through docs/decisions/README.md by key.
- Docs-update is part of done, same PR: an endpoint change updates docs/api-conventions.md's checklist and regenerates v1.json + endpoint-index.md via ./scripts/update-openapi.sh (build the app project first, then the script, then npm run generate:api under web/); a screen or route change updates docs/blazor-route-parity.md + docs/domain-model.md; a convention gets a record under docs/decisions/records/<area>/ and a regenerated catalog (PYTHONPATH=. python3 scripts/build_decisions_catalog.py — the catalog is generated and shared with other slots: never hand-edit it, regenerate it, and resolve a rebase conflict in it by regenerating); a new doc updates docs/README.md. A TvContext change needs both providers' migrations via scripts/add-migration.sh.
- Tests are NUnit + Shouldly + NSubstitute; vitest under web/. Never set ETV_UPDATE_GOLDENS or ETV_UPDATE_PLAYOUT_GOLDENS. Dependencies only in Directory.Packages.props.
- Docs record the end state, never the investigation; the path goes in the commit message.
- Gitea labels: POST ${API}/issues/{n}/labels {"labels":[100]} / DELETE ${API}/issues/{n}/labels/100; PATCH ignores labels.
- Never use bare git stash. Never push to main. The ONLY sanctioned rewrite of a pushed branch is a rebase onto origin/main pushed with --force-with-lease (process.orchestrated-session); a fix is a new commit, never an amend. Never cd out of the worktree except to read the shared checkout read-only. Kill only PIDs you started.
Worktree: ${WT} on branch ${BRANCH}; it exists, do ALL work inside it. Give it its own web/node_modules if missing (cp -Rc from ${SHARED}/web when the lockfiles match, else npm ci). If git commit is denied by the worktree-owner guard, the worktree belongs to ANOTHER session (orchestrated worktrees carry no marker): never overwrite the marker — STOP and report done=false with the guard's message. Every commit message ends with these trailer lines exactly:
${TRAILER}`
const gateFor = (port, where) => `LOCAL GATE (process.local-gate-before-push) — inside ${where}, real output, a skipped test is not a pass:
- .NET: dotnet build, then dotnet test on every test project covering what the branch touches (all of them for anything under ErsatzTV.Core); BOM-check touched .cs with od -A n -t x1 -N 3 and bash -c 'dotnet format whitespace . --folder --verify-no-changes --include <files>'.
- SPA: cd web && npm run check:api && npm run lint && npm run typecheck && npm run build && npm test.
- scripts/, .claude/, .husky/, .gitea/: PYTHONPATH=. python3 -m pytest scripts/tests -q, plus ruff on touched Python.
- Docs: python3 scripts/check-doc-narrative.py --diff origin/main.
- Live-E2E${args.needs_e2e ? ' IS REQUIRED (write path or UI)' : ' only for a write path or screen change'}: ETV_UI_PORT=${port} scripts/e2e-local.sh <fresh CONFIG_DIR> — port ${port} is yours; one run at a time in that worktree; curl, never a browser tab; kill the PID the launcher printed when done and nothing else; a busy port is reported, never taken over.
- Run the .NET and web gates sequentially; other slots are building.`
const GATE = gateFor(args.port, WT)
const REBASE = `Rebase onto origin/main FIRST: git fetch origin; git rebase origin/main; resolve conflicts faithfully, keeping both sides' intent; regenerate generated artifacts rather than hand-resolving them. A commit titled "WIP: orchestrator checkpoint" holds uncommitted work from the paused session and must be folded into the commit it belongs to, never left in history — if it sits directly on that commit: git reset --soft HEAD~1 && git commit --amend --no-edit; otherwise: git commit --fixup=<target> is already its shape, so GIT_SEQUENCE_EDITOR=true git rebase --autosquash <target>~1 folds it non-interactively.`
const REPORT_SCHEMA = {
type: 'object', required: ['done', 'summary', 'verified', 'left', 'commits', 'head_sha'],
properties: {
done: { type: 'boolean' }, summary: { type: 'string' },
verified: { type: 'string', description: 'exact gate commands run and their real output summary' },
left: { type: 'string' }, commits: { type: 'string', description: 'git log --oneline origin/main..HEAD' }, pr_url: { type: 'string' }, head_sha: { type: 'string', description: 'git rev-parse HEAD of YOUR WORKTREE after your last commit (not a PR head) — the finisher derives fix commits from these' },
patch_changed: { type: 'boolean', description: 'finisher only: true if a second rebase before the push changed the patch-id' },
},
}
const FINDINGS_SCHEMA = {
type: 'object', required: ['findings', 'verdict'],
properties: {
verdict: { type: 'string', enum: ['merge', 'send-back'] },
findings: { type: 'array', items: { type: 'object', required: ['severity', 'file', 'summary', 'evidence'], properties: {
severity: { type: 'string', enum: ['blocking', 'should-fix', 'nit'] }, file: { type: 'string' }, summary: { type: 'string' }, evidence: { type: 'string' } } } },
},
}
const RUNNER_SCHEMA = {
type: 'object', required: ['findings', 'verdict', 'ran'],
properties: {
ran: { type: 'boolean', description: 'false if codex produced no VERDICT line — required, because the fallback branches on it' },
verdict: FINDINGS_SCHEMA.properties.verdict, findings: FINDINGS_SCHEMA.properties.findings,
},
}
const LAND_SCHEMA = {
type: 'object', required: REPORT_SCHEMA.required.concat(['patch_changed']),
properties: REPORT_SCHEMA.properties,
}
phase('Work')
let work
if (args.mode === 'implement') {
work = await agent(`${COMMON}
You are the implementer, continuing a paused session. Read git log and git show for the branch's commits first; a WIP checkpoint commit is the paused implementer's partial edit. ${REBASE} The brief's recon is a plan; verify what you rely on. Finish the done condition completely, with a regression test that reddens against the unfixed code. Run the LOCAL GATE and STOP without pushing; reviewers read the worktree first; report head_sha = git rev-parse HEAD of the worktree after your last commit. ${GATE}`,
{ label: `impl:${REF}`, model: 'opus', effort: 'high', schema: REPORT_SCHEMA })
} else {
work = await agent(`${COMMON}
You are the fixer, continuing a paused session. The PR is #${args.pr}. ${REBASE} Then the brief's findings are the last review round's: fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong. Run the LOCAL GATE and STOP without pushing; reviewers read the worktree first; report head_sha = git rev-parse HEAD of the worktree after your last commit. ${GATE}`,
{ label: `fix:${REF}`, model: 'opus', effort: 'high', schema: REPORT_SCHEMA })
}
if (!work) return { issues, error: 'work agent returned nothing' }
if (!work.done) return { issues, error: 'work agent stopped', work }
const reviewCommon = (e2ePort) => `${COMMON}
${gateFor(e2ePort, 'your own isolated worktree (never ' + WT + ')')}
Diff: git -C ${WT} diff origin/main...HEAD (rebased, not yet pushed). ${args.pr ? `PR #${args.pr} exists: its pushed head, its body and any earlier closing record are INTENTIONALLY behind this worktree until the finisher pushes after this review loop and resyncs them — a stale PR head or body is not a finding, and neither is "not pushed".` : 'No PR exists yet; the finisher opens it after this loop.'} Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or test, do it in your own isolated worktree, never in ${WT}: git fetch ${WT} ${BRANCH} && git checkout --detach FETCH_HEAD puts the branch there; gates sequentially; E2E there on port ${e2ePort} (the GATE above is written for your worktree and that port).`
const LENSES = [
{ key: 'correctness', model: 'opus', isolation: 'worktree', prompt: 'correctness against the done condition: run the gate and, for a write path or screen, the live-E2E route yourself, and read the output; try to break the change with the edge cases the issue and the docs name; check the pinning test reddens when the fix alone is reverted.' },
{ key: 'conformance', model: 'sonnet', prompt: 'repo conformance: docs-update obligations met; no narrative in docs; inventory rows for new scripts/hooks; CPM respected; both-provider migration if the model changed; no BOM in touched .cs; no WIP commit left in history; branch rebased on current origin/main; commit trailers present; PR body will carry fixes #N for each issue.' },
]
let xfamilyFailedRound = null
let xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)'
async function codexRunner(round) {
const r = await agent(`${reviewCommon(Number(args.port) + 3)}
You run the cross-family review required by process.independent-review-rubric; you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of branch ${BRANCH} against origin/main in ${WT} for ${REF} with done condition from the brief, findings as blocking / should-fix / nit with file and evidence, ending with VERDICT: merge or VERDICT: send-back. Run EXACTLY: codex exec -C ${WT} -s read-only "$(cat <prompt>)" < /dev/null > <out> 2>&1 in the background, wait for the PID to exit (Monitor; measured 2026-07-28 in the #672 session, ~35 minutes for a 7-file diff), read the file, return its findings faithfully with ran=true; no VERDICT line means the run failed — return ran=false, verdict merge, no findings, and the file's tail in one nit finding; never invent a verdict.`,
{ label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: RUNNER_SCHEMA })
return r
}
async function codexFallback(round, r) {
xfamily = `codex could not run in round ${round} (${r ? 'no VERDICT line' : 'runner returned nothing'}); substituted a cold same-family review-only agent per process.independent-review-rubric — retry cross-family next window`
log(`${REF}: ${xfamily}`)
return agent(`${reviewCommon(Number(args.port) + 2)}
You are a COLD, review-only substitute for a cross-family reviewer that could not run. Lens: adversarial correctness AND security of the diff against the done condition in the brief. Run the gate in your own worktree; report only what you verified, with evidence. blocking = done condition or a repo rule violated; should-fix = real defect; nit = style. Verdict send-back if any blocking.`,
{ label: `review:fallback:r${round}`, phase: 'Review', model: 'opus', effort: 'high', isolation: 'worktree', schema: FINDINGS_SCHEMA })
}
async function review(round) {
// Per round, like blocking/sendBack: a substitute that failed in round 1 says nothing about the tree
// that lands after round 2, and a stale xfamily string must never reach the PR body.
xfamilyFailedRound = null
xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)'
// The Codex runner builds nothing, so it may run beside the lenses; the FALLBACK is a second
// worktree-isolated .NET reviewer and starts only after both lenses have returned.
const runnerPromise = rubric ? codexRunner(round).catch(() => null) : Promise.resolve(null)
const lenses = (await parallel(LENSES.map(l => () => agent(`${reviewCommon(Number(args.port) + 1)}
Review round ${round} of the branch for ${REF}. Lens: ${l.prompt}
Be adversarial; report only what you verified, with evidence. blocking = done condition or a repo rule violated, or a test that passes for the wrong reason; should-fix = real defect; nit = style. Verdict send-back if any blocking.`,
{ label: `review:${l.key}:r${round}`, phase: 'Review', model: l.model, effort: 'high', isolation: l.isolation, schema: FINDINGS_SCHEMA })))).filter(Boolean)
if (!rubric) return lenses
const r = await runnerPromise
if (r && r.ran === true) return lenses.concat([r])
let fb = null
try { fb = await codexFallback(round, r) } catch (e) { log(`${REF}: fallback reviewer threw: ${e && e.message}`) }
if (!fb) { xfamily += ` — the substitute ALSO failed in round ${round}; no cross-family-equivalent review ran`; xfamilyFailedRound = round }
return fb ? lenses.concat([fb]) : lenses
}
let round = 1
const actionable = rs => rs.flatMap(r => r.findings.filter(f => f.severity === 'blocking' || f.severity === 'should-fix'))
const countBy = (rs, sev) => rs.flatMap(r => r.findings).filter(f => f.severity === sev).length
let knownHead = work.head_sha
let reviews = (await review(round)).filter(Boolean)
if (!reviews.length) return { issues, error: `review round ${round} produced no reviews (every lens failed); not pushed`, history: [] }
let blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking'))
let sendBack = actionable(reviews)
const history = [{ round, reviews, fix: null, fix_range: null }]
while (sendBack.length && round < 3) {
log(`${REF} round ${round}: ${blocking.length} blocking, ${sendBack.length - blocking.length} should-fix — sending back`)
const fix = await agent(`${COMMON}
You are the fixer. Reviewers found these problems in the unpushed, rebased branch; fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong:
${JSON.stringify(reviews.flatMap(r => r.findings.filter(f => f.severity !== 'nit')), null, 1)}
Re-run the LOCAL GATE and STOP without pushing; report head_sha = git rev-parse HEAD of the worktree after your last commit. ${GATE}`,
{ label: `fix:r${round}`, phase: 'Fix', model: 'opus', effort: 'high', schema: REPORT_SCHEMA })
if (!fix || !fix.done) return { issues, error: `fixer for round ${round} ${fix ? 'stopped' : 'returned nothing'}; not pushed`, fix, history }
history[history.length - 1].fix = fix
history[history.length - 1].fix_range = fix.head_sha && fix.head_sha !== knownHead ? `${knownHead}..${fix.head_sha}` : null
knownHead = fix.head_sha || knownHead
round++
reviews = (await review(round)).filter(Boolean)
if (!reviews.length) return { issues, error: `review round ${round} produced no reviews (every lens failed); not pushed`, history }
blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking'))
sendBack = actionable(reviews)
history.push({ round, reviews, fix: null, fix_range: null })
}
if (blocking.length) return { issues, error: 'blocking findings after two fix rounds; not pushed', blocking_remaining: blocking, history }
if (sendBack.length) return { issues, error: 'should-fix findings still open after two fix rounds; not pushed — the orchestrator decides', should_fix_remaining: sendBack, history }
if (xfamilyFailedRound) return { issues, error: `the cross-family runner and its substitute both failed in round ${xfamilyFailedRound}; not pushed`, cross_family: xfamily, history }
const FIX_RANGES = history.map(h => h.fix_range).filter(Boolean)
const REVIEW_HISTORY = history.map(h => `round ${h.round}: ${h.reviews.length} lens(es); ${countBy(h.reviews, 'blocking')} blocking, ${countBy(h.reviews, 'should-fix')} should-fix, ${countBy(h.reviews, 'nit')} nit` + (h.fix ? (h.fix_range ? `; answered by the fix commit(s) in git log --oneline ${h.fix_range}` : '; answered without a new commit (findings refuted with evidence in the fixer report)') : '; clean — loop ended')).join('\n')
phase('Land')
const FINISH = `FINISH: record the patch-id (git diff $(git merge-base origin/main HEAD)..HEAD | git patch-id --stable); git fetch origin; if origin/main moved again, rebase onto it, re-run the LOCAL GATE, and recompute the patch-id — report patch_changed=true if it differs. ${GATE}
Then git push --force-with-lease origin ${BRANCH}. ${args.pr ? `Update PR #${args.pr}'s body (PATCH ${API}/pulls/${args.pr}) so it describes the branch as it now is` : `Open a PR (POST ${API}/pulls; head=${BRANCH}, base=main)`}: the body must contain "fixes #N" for every issue in the bundle, the root cause for a bug fix, the measured numbers, the review history VERBATIM as recorded by the workflow, one line per round, between the markers <<REVIEW HISTORY and REVIEW HISTORY>>:
<<REVIEW HISTORY
${REVIEW_HISTORY}
REVIEW HISTORY>>
${FIX_RANGES.length ? `followed by what each fix commit changed, read from git show and not from memory, for exactly the commits git log --oneline lists in these ranges: ${FIX_RANGES.join('; ')}` : (history.some(h => h.fix) ? 'and a sentence saying every finding was answered without a new commit, as the history block records' : 'and a sentence saying no fix commit exists because round one was clean')}, then the cross-family review status verbatim — "${xfamily}" — every deliberately-left item with an issue number, and end with:
🤖 Generated with [Claude Code](https://claude.com/claude-code)
${SESSION_URL}
Read ${API}/commits/<head-sha>/status once to arm the CI monitor. Post or update the "## Closing record" comment on each issue (the closing-an-issue skill's template) linking the PR, without closing the issue and without ticking any "## Done-when" box; end it with a "Done-when evidence" list naming, for every box, the command or artifact that evidences it — the orchestrator ticks from that. Report the PR URL, the head sha and patch_changed.`
const land = await agent(`${COMMON}
You are the finisher. The rebased branch has passed its review loop (${round} round(s)); nothing is pushed yet. ${FINISH}`,
{ label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: LAND_SCHEMA })
if (!land || !land.done) return { issues, error: 'finisher stopped', land, history }
if (!(land.pr_url || args.pr) || !land.head_sha) return { issues, error: 'finisher reported done without a PR or head sha — the branch may already be pushed; read its report before re-running', land, history }
log(`${REF} PR: ${land.pr_url || args.pr} @ ${land.head_sha || '?'}${land.patch_changed ? ' (patch changed by the pre-push rebase)' : ''}`)
let post_rebase_reviews = null
if (land.patch_changed) {
log(`${REF}: patch changed on rebase — one more review round on the pushed head before any verdict`)
round++
post_rebase_reviews = (await review(round)).filter(Boolean)
if (!post_rebase_reviews.length) return { issues, error: 'the post-rebase review round produced no reviews (every lens failed); pushed, no verdict may be posted', pr_url: land.pr_url || args.pr, head_sha: land.head_sha, cross_family: xfamily, history }
const late = actionable(post_rebase_reviews)
if (late.length) return { issues, error: 'blocking or should-fix findings on the pushed head after the pre-push rebase; no verdict may be posted', pr_url: land.pr_url || args.pr, head_sha: land.head_sha, findings_remaining: late, cross_family: xfamily, history, post_rebase_reviews }
}
return { issues, pr_url: land.pr_url || args.pr, head_sha: land.head_sha, patch_changed: !!land.patch_changed, cross_family: xfamily, work, land, history, post_rebase_reviews }
+16 -5
View File
@@ -36,9 +36,15 @@ on:
# from the pushed ref like everything else in this file, so a branch that DELETES it re-enables
# the route. What closes is the DRIVE-BY case — an ordinary push of a legitimate `docker/ci`
# change publishing an image nobody asked for, with no deliberate act anywhere. This is NOT a
# boundary against a malicious or compromised writer and must not be cited as one; that class is
# ersatztv#853. `workflow_dispatch` is self-supplied from the selected branch in the same way, and
# is the deliberate publish path (docs/ci-cd.md -> "CI toolchain image").
# boundary against a malicious or compromised writer and must not be cited as one. That class was
# probed and ACCEPTED in ersatztv#853 (`ci.workflow-dispatch-ref-unrestricted`): Gitea 1.27.1 cannot
# restrict `workflow_dispatch` by ref, and restricting it would close nothing anyway:
# docker-build.yml's head-resolved `pull_request:` runs attacker-authored YAML, which reaches every
# secret in the store — so it covers renovate.yml's RENOVATE_TOKEN too, without dispatching
# renovate.yml at all. Only the DISPATCH third is settled; the `v*` tag push and the PR route
# itself remain open in ersatztv#885. `workflow_dispatch` is loaded from the ref it is dispatched
# on, exactly as the `branches:` filter below is loaded from the pushed ref, and is the deliberate
# publish path (docs/ci-cd.md -> "CI toolchain image").
#
# A `v*` tag push does not match this trigger either: there is no `tags:` key, and a `branches:`
# filter is compared against a branch ref. The exact matcher semantics are not probed here; the
@@ -54,8 +60,13 @@ on:
# this file no longer republishes on its own, so pair it with a `docker/ci/**` edit.
#
# `paths:` here and `ci-image-pin`'s `expected` pathspec in pr-checks.yml MUST name the same
# sources, and nothing mechanically enforces that since the shared self-reference went —
# ersatztv#855.
# sources. Since the shared self-reference went, `scripts/tests/test_ci_image_paths_pin_agreement.py`
# is what holds them together: it derives BOTH lists from these two workflows and compares them for
# set equality (ersatztv#855). The two are written in different glob dialects, so it models exactly
# one pair of spellings — `<dir>/**` here against the pathspec `<dir>` — and REFUSES anything else
# rather than canonicalising a pattern space whose spellings the two consumers treat differently.
# Change this list and that guard goes red until the pathspec follows; write it any other way and
# it goes red asking for the new shape to be modelled.
workflow_dispatch:
push:
branches: [main]
+54 -41
View File
@@ -120,16 +120,32 @@ env:
# is NOT granted, and that holds at any owner default — it is not conditional on Restricted being on.
# Only `review-verdict.yml` needs write; it declares that at the job and says why there. Full
# rationale and the per-workflow credential audit: docs/ci-cd.md -> "Workflow token scope".
# Every credentialed thing this file does uses the scoped REGISTRY_* PAT, never the injected token:
# its registry pushes, its five `container:` image pulls, its three commit-status GET steps
# (`ETV_STATUS_AUTH` in jobs `test`, `migrations` and `functional-e2e`, each a read-only GET via
# scripts/ci-detect-already-validated.sh) and its registry tag READ (`ETV_REGISTRY_AUTH` in job
# `toolchain-preflight`, via scripts/ci-toolchain-image-resolves.sh). The injected token therefore
# serves only its eight `actions/checkout` steps. Note this file needs no `packages:` unit for that
# same reason: the `container:` blocks carry explicit `credentials:`.
# NO JOB ON THE `pull_request` ROUTE NAMES A STORED SECRET (ersatztv#885,
# `ci.pr-route-carries-no-stored-credential`). Gitea resolves a `pull_request` run from the PR HEAD,
# so this file is attacker-authored on that route and every `secrets.*` it names is materialised
# into the run. The scoped REGISTRY_* PAT is therefore held by `build` alone, which is gated
# `if: github.event_name != 'pull_request'`. The jobs that used to hold it now work without it:
# the five `container:` pulls and `toolchain-preflight`'s registry tag READ go through the registry's
# anonymous bearer-token flow, and the three commit-status GETs
# (scripts/ci-detect-already-validated.sh in `test`, `migrations` and `functional-e2e`) read the
# combined-status API unauthenticated. Those are TWO dependencies, on two different objects, with
# opposite failure directions — do not collapse them into one "keep it public or CI breaks loudly".
# The `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version of it
# reports `"repository": null`), so THIS repo's visibility is not what gates the anonymous pull
# token. (1) The five pulls and the preflight need that PACKAGE to stay anonymously pullable, and
# losing it IS loud: every `container:` job dies at image pull, before it runs a step, both
# required contexts among them, and `toolchain-preflight` names the cause in its own 401/403
# message. (2) The three commit-status GETs need `timothy/ersatztv` itself to stay publicly
# readable, and losing that is SILENT: `curl -sf` fails, ci-detect-already-validated.sh falls
# through to `skip=false`, and the jobs stay GREEN — only the ersatztv#420 cross-run skip quietly
# stops firing, which costs a redundant re-validation and never a skip that was not earned. The
# invariant is held by
# scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret.
# The injected token serves only this file's eight `actions/checkout` steps. Note it needs no
# `packages:` unit: the container pulls are anonymous, not token-authenticated.
# (Sites above are named by JOB, not by line number: this file is ~1150 lines, so any edit above a
# citation silently invalidates it — which is how the first version of this comment went stale two
# lines after it was written.)
# citation silently invalidates it — a line-number citation here has gone stale within two lines
# of being written.)
permissions:
code: read
@@ -150,8 +166,6 @@ jobs:
with:
persist-credentials: false
- name: Resolve the pinned toolchain tag in the registry
env:
ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark resolve
scripts/ci-toolchain-image-resolves.sh
@@ -165,9 +179,6 @@ jobs:
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
env:
CI_EXECUTION_CLASS: toolchain
CI_JOB_ROLE: guard
@@ -195,8 +206,6 @@ jobs:
scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark revalidate
scripts/ci-detect-already-validated.sh
@@ -389,9 +398,6 @@ jobs:
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
# Independent gate (not a 'needs' of build yet) so the new MySql-service dependency
# can't block image builds until it's proven reliable on the runner. Promote to a
# required check / build dependency once green. (ersatztv#13)
@@ -468,8 +474,6 @@ jobs:
scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark revalidate
scripts/ci-detect-already-validated.sh
@@ -611,9 +615,6 @@ jobs:
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
env:
CI_EXECUTION_CLASS: toolchain
CI_JOB_ROLE: guard
@@ -632,8 +633,6 @@ jobs:
run: scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: scripts/ci-detect-already-validated.sh
- name: Cache NuGet packages
@@ -727,9 +726,9 @@ jobs:
# runs at all — the image is not built, let alone pushed. Fail-closed by dependency, not by
# assertion.
#
# WHY IT RUNS THE REAL PYTEST rather than a bespoke scanner. The first cut of #767 hand-parsed the
# workflow YAML in stdlib Python, to avoid provisioning PyYAML on `build`'s bare runner. Two
# independent reviews found ~10 false NEGATIVES in that parser within one round (flow mappings
# WHY IT RUNS THE REAL PYTEST rather than a bespoke scanner. A stdlib hand-parser of the workflow
# YAML was TRIED AND REJECTED in #767 (its appeal: no PyYAML to provision on `build`'s bare
# runner). That parser had ~10 false NEGATIVES, all found at once (flow mappings
# `{run: …}`, a quoted `"run":` key, aliases, multiline quoted scalars) — i.e. it was strictly
# WEAKER than the check it was meant to backstop, in the one direction that matters for a security
# gate. Running the existing PyYAML-based test needs no second implementation of "what is a `run:`
@@ -785,16 +784,16 @@ jobs:
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark ban
PYTHONPATH=. python3 -m pytest scripts/tests/test_ci_dropped_step_guard.py scripts/tests/test_ci_release_path_scan_job.py -q
# THE POSITIVE CONTROL, and it is deliberately NOT a test (ersatztv#767). The step above proves
# the ban HOLDS; it cannot prove the ban would NOTICE. Review disarmed the entire gate with one
# repo-root `pytest.ini` (`addopts = -k "not delimiter_banned"`) or `conftest.py`
# (`pytest_collection_modifyitems`), which deselects the ban test and every test guarding it,
# the ban HOLDS; it cannot prove the ban would NOTICE. DEMONSTRATED: ONE repo-root `pytest.ini`
# (`addopts = -k "not delimiter_banned"`) or `conftest.py` (`pytest_collection_modifyitems`)
# disarms the entire gate, deselecting the ban test and every test guarding it,
# leaving all jobs green with a delimiter sitting in `Smoke`. Nothing inside pytest can be
# trusted to catch that, because pytest's own configuration outranks it.
#
# So this poisons the checked-out workflow, re-runs the SAME command, and fails the job if it
# PASSES. It runs in the real checkout — an isolated copy does not inherit the repo-root config
# a disarm would live in, which made the first version of this script report healthy while the
# job's real invocation was deselected. The workflow file is restored by an EXIT trap.
# a disarm would live in, so a check run in a copy reports healthy while the
# job's real invocation is deselected. The workflow file is restored by an EXIT trap.
- name: Prove the ban would DETECT a delimiter (ersatztv#767)
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark selfcheck
@@ -865,7 +864,27 @@ jobs:
INFO_VERSION="${VERSION}"
TAGS=("${IMAGE}:prod" "${IMAGE}:${VERSION}" "${IMAGE}:${SHORT}")
else
DESC=$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)
# `git describe` MUST resolve here, and a failure is fatal rather than defaulted
# (ersatztv#836). This job checks out `fetch-depth: 0`, so the tags are present; the
# only thing that ever stopped `describe` from seeing them was the detector step above
# grafting this complete clone shallow. The old `|| echo v0.0.0` was a fallback that
# cannot fail, so from 2026-07-17 (when #416 introduced the depth) until #836 every
# `:latest` image was published carrying
# `InformationalVersion 0.0.0-<sha>` and nothing anywhere went red — the defect was
# found by reading the string out of a running container, which is not a detector.
# Failing the job instead means no `:latest` is published at all: visible, recoverable,
# and never a mislabelled image promoted downstream. The tag path above never calls
# `describe`, so a release cut is unaffected by this.
# stderr is discarded on the CAPTURE and re-run for the diagnostic, rather than folded
# in with `2>&1`: a git warning on the SUCCESS path would otherwise land inside DESC and
# become part of the version string — the same shape of silent corruption this whole
# step is being hardened against.
if ! DESC=$(git describe --tags --abbrev=0 2>/dev/null); then
echo "is-shallow-repository=$(git rev-parse --is-shallow-repository)"
git describe --tags --abbrev=0 || true
echo "::error::git describe --tags --abbrev=0 failed, so this image would ship InformationalVersion 0.0.0-${SHORT} instead of a real version (ersatztv#836). The usual cause is a --depth fetch grafting this complete clone shallow; the two lines above say which."
exit 1
fi
INFO_VERSION="${DESC#v}-${SHORT}"
TAGS=("${IMAGE}:latest" "${IMAGE}:${SHORT}")
fi
@@ -1024,9 +1043,6 @@ jobs:
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
if: github.event_name == 'pull_request'
env:
CI_EXECUTION_CLASS: toolchain
@@ -1129,9 +1145,6 @@ jobs:
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
if: github.event_name == 'pull_request'
env:
CI_EXECUTION_CLASS: toolchain
+20 -10
View File
@@ -106,9 +106,14 @@ jobs:
# runner placement than for build recipe. Make a recipe change alongside a `docker/ci/**`
# edit — a comment bump suffices, and it is the ONLY remedy: pinning the workflow-only
# commit is rejected here, because `expected` is the last `docker/ci` commit.
# Nothing MECHANICALLY couples this pathspec to `ci-image.yml`'s `on.push.paths`; before
# This pathspec and `ci-image.yml`'s `on.push.paths` MUST name the same sources; before
# #744 the shared self-reference kept them in step. Divergence is silent and green in the
# dangerous direction — tracked in ersatztv#855.
# dangerous direction, so it is enforced rather than asserted:
# `scripts/tests/test_ci_image_paths_pin_agreement.py` derives BOTH lists from the two
# workflows and compares them for set equality (ersatztv#855). It takes this pathspec from
# the ASSIGNMENT below rather than from any `git log` in the job, and models only a plain
# `<dir>` against `<dir>/**` there — any other spelling is refused rather than compared.
# Change this pathspec and that guard goes red until `on.push.paths` follows.
# See docs/ci-cd.md -> "Publishing from a branch is a dispatch, not a push".
#
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
@@ -301,11 +306,16 @@ jobs:
# close. A distinct job name keeps a real failure unambiguous.
#
# Runs UNCONDITIONALLY on every PR rather than behind a `scripts/**` path filter. The suite's
# corpus tests are fixture/tmp-repo based, but test_post_review_verdict.py and
# test_merge_consent_exemption.py execute the REAL `scripts/post-review-verdict.sh` and
# `.claude/hooks/pretooluse-merge-consent.sh`, so its true input set spans at least two top-level
# directories. A `scripts/**` filter would silently miss a `.claude/hooks/**` edit — and at ~10s a
# filter buys nothing but drift.
# corpus tests are fixture/tmp-repo based, but several execute REAL artifacts from other top-level
# directories: test_post_review_verdict.py runs `scripts/post-review-verdict.sh`,
# test_merge_consent_exemption.py runs `.claude/hooks/pretooluse-merge-consent.sh`, and since
# ersatztv#845 test_post_review_verdict.py ALSO reads `.gitea/workflows/review-verdict.yml` —
# the writer derives the H10 allow-list from it, so editing that literal changes the suite's
# outcome. Its true input set therefore spans at least three top-level directories, and this
# enumeration is the kind that goes stale: a `scripts/**` filter would silently miss a
# `.claude/hooks/**` or `.gitea/workflows/**` edit. The reason is the INPUT SET, not the cost —
# the suite was ~10s when that was decided and is minutes now, and filtering on `scripts/**`
# would still be wrong.
prove-fix:
name: "Fix proofs (Proves trailers)"
runs-on: small
@@ -530,9 +540,9 @@ jobs:
# is installed, but the one-shot WRITE path `migrate_decisions_split.py` uses PyYAML by
# design — and `test_migration_equivalence.py` imports that module, so the suite needs it.
# `pytest` and `yaml` are the complete third-party set, established by an AST import scan over
# all of scripts/ rather than by reading the files that seemed relevant: the first cut of this
# job claimed "pure stdlib", passed locally on a machine that happened to have PyYAML, and
# went red in CI on a collection error.
# all of scripts/ rather than by reading the files that seemed relevant — reading only those
# yields "pure stdlib", a claim that passes locally on a machine that happens to have PyYAML
# and goes red in CI on a collection error.
- name: Install test dependencies
run: python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml
# jq gets its OWN step because its VERSION, not merely its presence, is load-bearing
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -83,7 +83,7 @@ main in) and re-run the local gate whenever the fetch shows movement.
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use `/done <issue>` to run through this automatically.
**Merge-consent is derived from state, not asserted (`## Done-when` convention — ersatztv#303 H6 + H10).** Any issue whose PR will merge to `main` should carry a `## Done-when` section in its **issue body** — a checklist of completion criteria (always include an "adversarial review passed" box; add per-issue criteria like tests-green, docs-updated, live-E2E). Two hooks derive merge-consent from it so a premature merge is blocked *by construction*, not by memory:
- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **auto-grants** a merge (emits `permissionDecision: allow`, so **no** redundant mechanical prompt fires) only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked **and** a `Review-verdict:` comment references the PR's *current head sha* (**H10**); **denies** on an unticked box, red CI, or a stale/negative review verdict; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no `Review-verdict:` comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state **is** the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate **asks** (ersatztv#314). **The H10 review-verdict convention**: after an adversarial/Codex review of a PR (or its latest fix commit), run **`scripts/post-review-verdict.sh <pr> <MERGEABLE|APPROVED|LGTM|BLOCKED|NOT-MERGEABLE> [note]`** — it posts both the `Review-verdict: … @ <head-sha>` comment and the sha-bound `review-verdict/h10` commit status, proving the *latest* commit was reviewed rather than a stale earlier diff (ersatztv#242). Do not hand-write the comment: the **status** is the required check branch protection enforces, and a comment alone leaves it absent. **The credential you post with must be an account on `H10_REVIEWERS` in `.gitea/workflows/review-verdict.yml`** (`timothy` today) — since ersatztv#742 the gate inherits an existing `success` only from an allow-listed creator (an existing `failure` is left alone on a weaker attributability test, so an attributable rejection VISIBLE AT THE FIRST READ is not re-derived into a green — a rejection landing later, inside a run's own write window, is a separate and still-open route, ersatztv#849), so a verdict posted with any other account is written, reported as success by the script, and then silently re-derived on the next PR event (ersatztv#845).
- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **auto-grants** a merge (emits `permissionDecision: allow`, so **no** redundant mechanical prompt fires) only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked **and** a `Review-verdict:` comment references the PR's *current head sha* (**H10**); **denies** on an unticked box, red CI, or a stale/negative review verdict; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no `Review-verdict:` comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state **is** the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate **asks** (ersatztv#314). **The H10 review-verdict convention**: after an adversarial/Codex review of a PR (or its latest fix commit), run **`scripts/post-review-verdict.sh <pr> <MERGEABLE|APPROVED|LGTM|BLOCKED|NOT-MERGEABLE> [note]`** — it posts both the `Review-verdict: … @ <head-sha>` comment and the sha-bound `review-verdict/h10` commit status, proving the *latest* commit was reviewed rather than a stale earlier diff (ersatztv#242). Do not hand-write the comment: the **status** is the required check branch protection enforces, and a comment alone leaves it absent. **The credential you post with must be an account on `H10_REVIEWERS` in `.gitea/workflows/review-verdict.yml`** (`timothy` today) — since ersatztv#742 the gate inherits an existing `success` only from an allow-listed creator (an existing `failure` is left alone on a weaker attributability test, so an attributable rejection VISIBLE AT THE FIRST READ is not re-derived into a green — a rejection landing later, inside a run's own write window, was a separate route and is NARROWED since ersatztv#849 — every path that cannot establish what the head carries now replaces that unknown state with a sticky sentinel instead of leaving it standing; see `ci.verdict-unverified-write-sentinel` for the residuals it names), and since ersatztv#845 the script ENFORCES that coupling rather than assuming it: it reads its own status back and refuses, before writing the verdict comment, unless the recorded `.creator.login` is on that allow-list — so a POSITIVE verdict posted with any other account fails loudly at your terminal instead of being reported as success. The gate still re-derives such a status on the next PR event — that part is unchanged; what the check removes is the tool telling you it worked. **The membership requirement is `success`-only**, mirroring the gate: a `BLOCKED` verdict is honoured from ANY attributable account, so an off-list reviewer can still record a rejection. **The status is still written** — the check runs after the POST, because it measures the creator Gitea recorded rather than what the credential claims — and what is withheld is the verdict COMMENT, which leaves the merge hook at condition (c) with nothing to classify, i.e. an `ask`. So a refused positive verdict leaves a green `review-verdict/h10` standing on that head that the gate itself will not inherit; branch protection binds the context NAME and not its issuer, so do not read that green as consent. The allow-list is derived from the workflow by `scripts/lib/h10-reviewers.sh`; it is never restated.
- **The gate is enforced server-side, per sha (ersatztv#622).** `review-verdict/h10` is a required status check on `main`. Because a commit status belongs to one sha, a commit pushed *after* an auto-merge is scheduled clears it and blocks the merge — closing the hole where `merge_when_checks_succeed` froze consent at scheduling time and Gitea later merged an unreviewed head. Renovate-authored and docs-only PRs are auto-passed by `.gitea/workflows/review-verdict.yml`, **except** when they touch `.claude/`, `.codex/`, `.gitea/`, `.husky/`, `scripts/` or `docker/ci/`. See `docs/ci-cd.md` → Review-verdict gate.
- `.husky/pre-push` → `prepush-donewhen.sh` — a fail-open backstop that blocks a direct `git push origin main` whose commits `fix #N` an issue with unticked boxes. **Since ersatztv#743 that push can no longer happen at all** (see below), so this hook is now belt-and-braces for a path the server refuses.
@@ -23,7 +23,8 @@ public static class ChannelGraphicsDefaults
{
// HLS Direct is skipped because ErsatzTV is not transcoding there -- there is no frame
// pipeline to draw into, and the editor disables the toggle for the same reason. Identity is
// the element's filename, never its user-editable Name (the #67 lesson).
// the element's full seeded path (`GraphicsElementDefaults.OnNowNextSeededPath`), never its
// user-editable Name (the #67 lesson, sharpened from filename to full path by #568).
if (channel.StreamingMode is StreamingMode.HttpLiveStreamingDirect)
{
return;
@@ -47,8 +47,13 @@ public class UpdateChannelHandler(
{
Either<BaseError, string> resolvedLogo = await ResolveLogoPath(request, cancellationToken);
return await resolvedLogo.Match(
Right: async logoPath => Right<BaseError, ChannelViewModel>(
await ApplyUpdateRequest(dbContext, c, request, logoPath, cancellationToken)),
Right: logoPath =>
ApplyUpdateRequestTranslatingLostRace(
dbContext,
c,
request,
logoPath,
cancellationToken),
Left: e => Task.FromResult(Left<BaseError, ChannelViewModel>(e)));
},
Fail: errors => Task.FromResult(Left<BaseError, ChannelViewModel>(errors.Join())));
@@ -76,6 +81,56 @@ public class UpdateChannelHandler(
return cached;
}
// Validation and the write are two statements, not one atomic step: RefreshGraphicsElements
// deletes elements whose template file is gone, and a delete landing between the two turns the
// join insert back into the FK violation the validator exists to prevent -- the unhandled 500
// again (#568). A transaction does not close that window either: neither provider locks the rows
// the validator merely READ, so the concurrent delete still commits. Ask the existence question
// again on the failure path instead, and return the same 422 the validator would have returned;
// a DbUpdateException from any other cause keeps its own exception rather than being reported as
// a client error.
//
// What is re-asked is the WHOLE of Validate, not the graphics-element half: every FK on this
// full-replace DTO -- FFmpegProfileId, WatermarkId, FallbackFillerId, MirrorSourceChannelId and
// the graphics element ids -- is written by ApplyUpdateRequest and can lose the same race, and a
// recovery path that names its fields one by one silently omits the next FK the DTO gains.
// Re-running the validator set is what keeps the two paths from drifting: a check added to
// Validate is covered here by construction.
private async Task<Either<BaseError, ChannelViewModel>> ApplyUpdateRequestTranslatingLostRace(
TvContext dbContext,
Channel channel,
UpdateChannel request,
string logoPath,
CancellationToken cancellationToken)
{
try
{
return Right<BaseError, ChannelViewModel>(
await ApplyUpdateRequest(dbContext, channel, request, logoPath, cancellationToken));
}
catch (DbUpdateException)
{
// a fresh context: the failed save left the original one tracking the changes that
// could not be written, so the same query there could be answered from those. The
// channel entity is still the tracked one from the failed context, which Validate reads
// only in memory (MirrorSourceMustBeValid's own-playout count) and never re-queries.
await using TvContext recheckContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> recheck =
await Validate(recheckContext, request, channel, cancellationToken);
Option<BaseError> maybeError = recheck.Match(
Succ: _ => Option<BaseError>.None,
Fail: errors => Some(errors.Join()));
foreach (BaseError error in maybeError)
{
return Left<BaseError, ChannelViewModel>(error);
}
throw;
}
}
private async Task<ChannelViewModel> ApplyUpdateRequest(
TvContext dbContext,
Channel c,
@@ -229,14 +284,15 @@ public class UpdateChannelHandler(
.Apply((_, _, _, _, _) => channel);
// combine the page-only Group rule plus the FK existence checks (FFmpeg profile / watermark /
// fallback filler) with the channel validation; splitting keeps tuple arity within
// LanguageExt's supported applicative range while still accumulating all errors
// fallback filler / graphics elements) with the channel validation; splitting keeps tuple
// arity within LanguageExt's supported applicative range while still accumulating all errors
return (ValidateGroup(request.Group),
await FFmpegProfileMustExist(dbContext, request, cancellationToken),
await WatermarkMustExist(dbContext, request, cancellationToken),
await FillerPresetMustExist(dbContext, request, cancellationToken),
await GraphicsElementIdsMustExist(dbContext, request, cancellationToken),
channelValidation)
.Apply((_, _, _, _, c) => c);
.Apply((_, _, _, _, _, c) => c);
}
private static async Task<Validation<BaseError, int>> FFmpegProfileMustExist(
@@ -295,6 +351,28 @@ public class UpdateChannelHandler(
return BaseError.New($"Fallback filler {request.FallbackFillerId} does not exist.");
}
// The reconcile in ApplyUpdateRequest blindly Adds a ChannelGraphicsElement for every incoming
// id; an id with no matching GraphicsElement row would otherwise hit
// FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId at SaveChangesAsync and surface as
// an unhandled 500 (there is no global exception filter). Reject it here instead, for parity
// with every other FK field on this full-replace DTO (#568). The count cap, the request field
// named in the message and the cap on echoed ids all live in Validators.IdsMustExist, shared
// with the two UpdateDecoHandler twins so the three cannot drift apart.
private static Task<Validation<BaseError, Unit>> GraphicsElementIdsMustExist(
TvContext dbContext,
UpdateChannel request,
CancellationToken cancellationToken) =>
Validators.IdsMustExist(
request,
r => r.GraphicsElementIds,
"Graphics element",
idsAreConsumed: true,
(ids, token) => dbContext.GraphicsElements
.Where(e => ids.Contains(e.Id))
.Select(e => e.Id)
.ToListAsync(token),
cancellationToken);
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
TvContext dbContext,
UpdateChannel request,
@@ -22,7 +22,7 @@ public class GetAllGraphicsElementsForApiHandler(IDbContextFactory<TvContext> db
.Select(e => new
{
Vm = ProjectToViewModel(e),
BuiltIn = Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName
BuiltIn = GraphicsElementDefaults.IsOnNowNext(e.Path, e.Kind)
})
.OrderBy(x => x.Vm.Name == x.Vm.FileName)
.ThenBy(x => x.Vm.Name)
@@ -1,4 +1,5 @@
using System.Threading.Channels;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
@@ -86,6 +87,29 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
var incoming = request.Items.Except([highest]).ToList();
// Reject an EXPLICITLY empty recurrence set before any mutation (#880). The checked set is
// `incoming` -- the exact list whose DaysOfWeek/DaysOfMonth/MonthsOfYear the loops below
// write -- so the check and its subject cannot drift apart. That EXCLUDES the highest-Index
// catch-all by construction: its recurrence is discarded along with its date range (only its
// ProgramScheduleId is read, further down), so an empty set there cannot make anything "never
// apply" and rejecting it would state a reason that is false for that item.
foreach (ReplacePlayoutAlternateSchedule item in incoming)
{
ProgramScheduleAlternate stored = existing.FirstOrDefault(e => e.Id == item.Id);
Option<BaseError> recurrenceError = RecurrenceSetBounds.Validate(
item.DaysOfWeek,
item.DaysOfMonth,
item.MonthsOfYear,
stored?.DaysOfWeek,
stored?.DaysOfMonth,
stored?.MonthsOfYear);
foreach (BaseError error in recurrenceError)
{
return error;
}
}
var toAdd = incoming.Filter(x => existing.All(e => e.Id != x.Id)).ToList();
var toRemove = existing.Filter(e => incoming.All(m => m.Id != e.Id)).ToList();
var toUpdate = incoming.Except(toAdd).ToList();
@@ -44,6 +44,25 @@ public class ReplacePlayoutTemplateItemsHandler(
List<ReplacePlayoutTemplate> incoming = request.Items;
// Same rule as the alternate-schedule path (#880), over ALL items: unlike that one, every
// template item's recurrence IS stored, so there is no catch-all to exclude here.
foreach (ReplacePlayoutTemplate item in incoming)
{
PlayoutTemplate stored = existing.FirstOrDefault(e => e.Id == item.Id);
Option<BaseError> recurrenceError = RecurrenceSetBounds.Validate(
item.DaysOfWeek,
item.DaysOfMonth,
item.MonthsOfYear,
stored?.DaysOfWeek,
stored?.DaysOfMonth,
stored?.MonthsOfYear);
if (recurrenceError.IsSome)
{
return recurrenceError;
}
}
var toAdd = incoming.Filter(x => existing.All(e => e.Id != x.Id)).ToList();
var toRemove = existing.Filter(e => incoming.All(m => m.Id != e.Id)).ToList();
var toUpdate = incoming.Except(toAdd).ToList();
@@ -19,7 +19,50 @@ public class UpdateDecoHandler(
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Deco> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request, cancellationToken));
return await validation.Match(
Succ: deco => ApplyUpdateRequestTranslatingLostRace(dbContext, deco, request, cancellationToken),
Fail: errors => Task.FromResult(Left<BaseError, Unit>(errors.Join())));
}
// Mirrors UpdateChannelHandler.ApplyUpdateRequestTranslatingLostRace (#568): validation and the
// write are two statements, so a concurrent delete of a validated watermark or graphics element
// -- RefreshGraphicsElements deletes elements whose template file is gone -- lands the join
// insert on the FK violation the validators exist to prevent, as an unhandled 500. A transaction
// does not close that window either (neither provider locks the rows the validator merely READ),
// so ask the existence questions again on the failure path and return the same 422; a
// DbUpdateException from any other cause keeps its own exception.
//
// The whole of Validate is re-asked rather than a named pair of fields, for the same reason as
// the channel twin: a recovery path that enumerates its own fields omits the next one the DTO
// gains, while re-running the validator set covers a check added to Validate by construction.
private async Task<Either<BaseError, Unit>> ApplyUpdateRequestTranslatingLostRace(
TvContext dbContext,
Deco existing,
UpdateDeco request,
CancellationToken cancellationToken)
{
try
{
return await ApplyUpdateRequest(dbContext, existing, request, cancellationToken);
}
catch (DbUpdateException)
{
// a fresh context: the failed save left the original one tracking the changes that
// could not be written, so the same query there could be answered out of those.
await using TvContext recheckContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Deco> recheck = await Validate(recheckContext, request, cancellationToken);
Option<BaseError> maybeError = recheck.Match(
Succ: _ => Option<BaseError>.None,
Fail: errors => Some(errors.Join()));
foreach (BaseError error in maybeError)
{
return Left<BaseError, Unit>(error);
}
throw;
}
}
private async Task<Unit> ApplyUpdateRequest(
@@ -31,7 +74,7 @@ public class UpdateDecoHandler(
existing.Name = request.Name;
// watermark
bool hasWatermark = request.WatermarkMode is (DecoMode.Override or DecoMode.Merge);
bool hasWatermark = ConsumesWatermarkIds(request);
existing.WatermarkMode = request.WatermarkMode;
existing.UseWatermarkDuringFiller = hasWatermark && request.UseWatermarkDuringFiller;
@@ -59,7 +102,7 @@ public class UpdateDecoHandler(
}
// graphics elements
bool hasGraphicsElements = request.GraphicsElementsMode is (DecoMode.Override or DecoMode.Merge);
bool hasGraphicsElements = ConsumesGraphicsElementIds(request);
existing.GraphicsElementsMode = request.GraphicsElementsMode;
existing.UseGraphicsElementsDuringFiller = hasGraphicsElements && request.UseGraphicsElementsDuringFiller;
@@ -218,8 +261,64 @@ public class UpdateDecoHandler(
UpdateDeco request,
CancellationToken cancellationToken) =>
(await DecoMustExist(dbContext, request, cancellationToken), await ValidateDecoName(dbContext, request),
ValidateBreakContent(request))
.Apply((deco, _, _) => deco);
ValidateBreakContent(request),
await WatermarkIdsMustExist(dbContext, request, cancellationToken),
await GraphicsElementIdsMustExist(dbContext, request, cancellationToken))
.Apply((deco, _, _, _, _) => deco);
// The mode decides whether an id list is data or dead weight: ApplyUpdateRequest reconciles the
// join table only under Override/Merge and Clear()s it otherwise, ignoring the ids entirely. The
// validators below read these same two predicates rather than restating the mode test, so a
// validator can never reject an id the apply path was going to discard (#568). The SPA sends both
// id lists regardless of the mode selector, so that shape arrives from the real editor: a draft
// holding an element that has since been deleted must still be able to save the deco back to
// Inherit. The predicate is handed to Validators.IdsMustExist rather than short-circuiting the
// call, because only the EXISTENCE half belongs to the apply path: a discarded list was still
// parsed and materialized out of the request body, so the raw-count cap has to apply under
// every mode.
private static bool ConsumesWatermarkIds(UpdateDeco request) =>
request.WatermarkMode is (DecoMode.Override or DecoMode.Merge);
private static bool ConsumesGraphicsElementIds(UpdateDeco request) =>
request.GraphicsElementsMode is (DecoMode.Override or DecoMode.Merge);
// Mirrors UpdateChannelHandler.GraphicsElementIdsMustExist (#568): the reconcile in
// ApplyUpdateRequest blindly Adds a DecoWatermark/DecoGraphicsElement for every incoming id, and
// an id with no matching row hits the FK constraint at SaveChangesAsync and surfaces as an
// unhandled 500 (there is no global exception filter). These are top-level fields on
// ReplaceDecoRequest, the same position as graphicsElementIds on UpdateChannelRequest -- not the
// "deep FK ids nested inside item-list request bodies" carve-out in api-conventions.md. Both go
// through Validators.IdsMustExist, the one place the count cap, the request field named in the
// message and the cap on echoed ids are written.
private static Task<Validation<BaseError, Unit>> WatermarkIdsMustExist(
TvContext dbContext,
UpdateDeco request,
CancellationToken cancellationToken) =>
Validators.IdsMustExist(
request,
r => r.WatermarkIds,
"Watermark",
idsAreConsumed: ConsumesWatermarkIds(request),
(ids, token) => dbContext.ChannelWatermarks
.Where(w => ids.Contains(w.Id))
.Select(w => w.Id)
.ToListAsync(token),
cancellationToken);
private static Task<Validation<BaseError, Unit>> GraphicsElementIdsMustExist(
TvContext dbContext,
UpdateDeco request,
CancellationToken cancellationToken) =>
Validators.IdsMustExist(
request,
r => r.GraphicsElementIds,
"Graphics element",
idsAreConsumed: ConsumesGraphicsElementIds(request),
(ids, token) => dbContext.GraphicsElements
.Where(e => ids.Contains(e.Id))
.Select(e => e.Id)
.ToListAsync(token),
cancellationToken);
private static Task<Validation<BaseError, Deco>> DecoMustExist(
TvContext dbContext,
@@ -0,0 +1,74 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Scheduling;
/// <summary>
/// Validates the three recurrence sets shared by <c>ProgramScheduleAlternate</c> and
/// <c>PlayoutTemplate</c> (ersatztv#880). One validator called from BOTH replace handlers, mirroring
/// <c>FFmpegProfileBounds</c> — the exemplar for `api.ffmpeg-profile-numeric-bounds`, whose shape this
/// follows deliberately.
/// </summary>
/// <remarks>
/// <para>
/// An EMPTY set is rejected because the three are read CONJUNCTIVELY by
/// <c>AlternateScheduleSelector.GetScheduleForDate</c> — a miss on any one continues to the next
/// item — so an empty one matches NO date and stores an item that can never apply. Rejecting
/// rather than substituting is the point: accept-then-rewrite would make an explicit `[]`
/// indistinguishable from an omitted field, which is the very collapse this issue removed.
/// </para>
/// <para>
/// An UNCHANGED empty set that the row ALREADY holds is let through. Both PUT paths are
/// whole-list replaces, so a hard rejection would make every OTHER item in the playout
/// uneditable over a row the operator never touched — the same reason
/// `api.ffmpeg-profile-numeric-bounds` rejects only a NEWLY submitted out-of-range value. A row
/// whose stored set is NULL is NOT exempt: null means unrestricted, so submitting `[]` for it is
/// a new emptying, not an unchanged legacy value.
/// </para>
/// <para>
/// This runs on the COMMAND, after the request records have normalized an ABSENT array to the
/// All*() sets, so an empty set reaching here is one a caller sent EXPLICITLY. That also means a
/// direct (non-HTTP) caller is held to the same rule rather than being able to write a dead row.
/// </para>
/// </remarks>
public static class RecurrenceSetBounds
{
public static Option<BaseError> Validate(
ICollection<DayOfWeek> daysOfWeek,
ICollection<int> daysOfMonth,
ICollection<int> monthsOfYear,
ICollection<DayOfWeek> storedDaysOfWeek,
ICollection<int> storedDaysOfMonth,
ICollection<int> storedMonthsOfYear)
{
if (IsNewlyEmpty(daysOfWeek, storedDaysOfWeek))
{
return Some(BaseError.New(Message("DaysOfWeek", "no day of the week")));
}
if (IsNewlyEmpty(daysOfMonth, storedDaysOfMonth))
{
return Some(BaseError.New(Message("DaysOfMonth", "no day of the month")));
}
if (IsNewlyEmpty(monthsOfYear, storedMonthsOfYear))
{
return Some(BaseError.New(Message("MonthsOfYear", "no month")));
}
return Option<BaseError>.None;
}
// "send null" rather than "omit the property": all three are listed in the schema's `required` array
// in v1.json (they are nullable, not optional), so a client generated from the published contract
// cannot omit them. Omitting also works at runtime -- Newtonsoft maps a missing property and an
// explicit null to the same thing -- but naming only that would tell a conforming client to send
// something its own schema forbids.
private static string Message(string field, string consequence) =>
$"[{field}] must not be empty; an empty set matches {consequence}, so the item would never apply. " +
"Send null to leave it unrestricted";
// A new item (no stored row) has `stored` null, so an empty set is newly empty and is rejected.
// Only a stored set that is ITSELF already empty exempts an empty submission.
private static bool IsNewlyEmpty<T>(ICollection<T> submitted, ICollection<T> stored) =>
submitted is { Count: 0 } && stored is not { Count: 0 };
}
@@ -18,8 +18,7 @@ public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextF
/// Rows read per round trip when walking the list-valued (JSON-array) columns on
/// <c>SongMetadata</c>, and the ceiling on rows read per request.
/// <para>
/// These count ACTUAL ROWS, and arriving at that took four tries — each earlier attempt bounded a
/// quantity that sounded like rows and was not. A fixed <c>LIMIT</c> budget bounded the RESULT, and
/// These count ACTUAL ROWS. A fixed <c>LIMIT</c> budget bounded the RESULT, and
/// the pre-filter (allowed to over-match) starved it with rows that could not match. Keyset paging
/// with a <c>LIMIT</c> bounded CANDIDATES RETURNED — but a query matching nothing must evaluate
/// every eligible row before it can return an empty page, so rows inspected stayed unbounded. A
@@ -36,8 +35,7 @@ public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextF
/// </para>
/// <para>
/// <b>Be precise about what is bounded: LOGICAL ROWS RETURNED AND MATERIALIZED, and the number of
/// round trips. Not physical work, and not bytes.</b> Two things break the stronger reading, and an
/// earlier version of this comment asserted it anyway:
/// round trips. Not physical work, and not bytes.</b> Two things break the stronger reading:
/// <list type="bullet">
/// <item>
/// MySQL purge lag. Deleted clustered-index records survive until purge runs, and a range
@@ -142,7 +140,7 @@ public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextF
// over-match, even though the column collation (utf8mb4_0900_ai_ci) is accent-insensitive: the driver
// binds the LIKE pattern with a BINARY collation, so the comparison is accent-sensitive in practice.
// A hand-typed probe using a LITERAL pattern DOES over-match; that is a different query from the one
// this code runs, and mistaking the two is how an earlier revision of the decision record got it wrong.
// this code runs, and mistaking the two gives a false read on whether this predicate over-matches.
if (source is not null && ContainsNonAscii(query) && IsSqlite(dbContext))
{
values.AddRange(
@@ -472,7 +470,7 @@ public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextF
/// ordering key</i>, which positions the scan and never discards a row, whereas a residual
/// predicate throws away rows the engine already produced. <c>LIMIT</c> only truncates what
/// survives a residual predicate, so with one present it bounds the output rather than the row
/// count — which is how every earlier revision scanned past its own bound. With none, <c>LIMIT n</c>
/// count — a gap wide enough to scan straight past a nominal row-count bound. With none, <c>LIMIT n</c>
/// yields <c>n</c> logical rows. Null payloads are dropped in memory by
/// <see cref="ParseElements" />.
/// </para>
@@ -0,0 +1,87 @@
using System.Linq.Expressions;
using ErsatzTV.Core;
namespace ErsatzTV.Application;
public static partial class Validators
{
/// <summary>
/// The largest id list a full-replace write path accepts in one of its top-level id fields.
/// Deliberately far above any real payload -- the lists it bounds select from tables an
/// operator curates by hand (graphics elements, watermarks), where a few dozen rows is a
/// large install -- so the bound is a ceiling on abuse, not a product limit anyone can reach
/// by using the editor (#568).
/// </summary>
public const int MaximumIdListCount = 512;
// A 422 that echoes every rejected id turns an oversized request into an oversized response.
// Enough ids to fix the payload by hand, then a count.
private const int MaximumReportedMissingIds = 10;
/// <summary>
/// The shared existence check for a top-level list of FK ids on a full-replace request:
/// bound the list, resolve which of its ids exist through <paramref name="findExisting" />,
/// and reject the rest with a 422 that names the request field it came from.
/// </summary>
/// <param name="idsAreConsumed">
/// Whether the apply path will actually read this list — false where another field of the
/// same request (a deco's <c>DecoMode</c>) makes the reconcile discard it. It gates the
/// EXISTENCE half only, never the count: a validator may not reject an id the apply path
/// was going to throw away, but the raw list was still parsed and materialized out of the
/// request body whatever is done with it afterwards, so the cap is the request's bound and
/// not the apply path's (#568).
/// </param>
/// <remarks>
/// The count is taken from the RAW list, before <c>Distinct</c> and before any database
/// work: deduplication is not what the request costs. A million-entry list of one repeated
/// id parses, allocates and materializes in full whatever the distinct count turns out to
/// be, so a cap applied after <c>Distinct</c> would bound the query and leave the request
/// itself unbounded.
/// </remarks>
public static async Task<Validation<BaseError, Unit>> IdsMustExist<T>(
T input,
Expression<Func<T, List<int>>> expression,
string noun,
bool idsAreConsumed,
Func<List<int>, CancellationToken, Task<List<int>>> findExisting,
CancellationToken cancellationToken)
{
string field = GetMemberName(expression);
List<int> submitted = expression.Compile()(input) ?? [];
if (submitted.Count > MaximumIdListCount)
{
return BaseError.New(
$"[{field}] contains {submitted.Count} ids; at most {MaximumIdListCount} are accepted. " +
"The whole list is materialized into one existence query and then reconciled against every " +
"row already attached, so a longer list turns a single request into unbounded work.");
}
if (!idsAreConsumed)
{
return Unit.Default;
}
List<int> requested = submitted.Distinct().ToList();
if (requested.Count == 0)
{
return Unit.Default;
}
List<int> existingIds = await findExisting(requested, cancellationToken);
List<int> missingIds = requested.Except(existingIds).OrderBy(id => id).ToList();
if (missingIds.Count == 0)
{
return Unit.Default;
}
return BaseError.New($"[{field}] {noun}(s) do not exist: {DescribeIds(missingIds)}");
}
private static string DescribeIds(IReadOnlyList<int> ids) =>
ids.Count <= MaximumReportedMissingIds
? string.Join(", ", ids)
: $"{string.Join(", ", ids.Take(MaximumReportedMissingIds))} (and " +
$"{ids.Count - MaximumReportedMissingIds} more)";
}
@@ -191,4 +191,65 @@ public class FFmpegStreamSelectorTests
}
}
}
// ersatztv#924: a version stored without a video stream (Jellyfin music videos whose statistics were never
// read) used to surface First()'s bare "Sequence contains no matching element" from HLS session start.
[TestFixture]
public class SelectVideoStream
{
private static FFmpegStreamSelector Selector() =>
new(
new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()),
Substitute.For<IStreamSelectorRepository>(),
Substitute.For<IConfigElementRepository>(),
new MockFileSystem(),
Substitute.For<ILanguageCodeService>(),
Substitute.For<ILogger<FFmpegStreamSelector>>());
[Test]
public async Task Should_Select_The_Video_Stream()
{
var version = new MediaVersion
{
Id = 12,
Streams =
[
new MediaStream { Index = 0, MediaStreamKind = MediaStreamKind.Audio },
new MediaStream { Index = 1, MediaStreamKind = MediaStreamKind.Video, Codec = "h264" }
]
};
MediaStream stream = await Selector().SelectVideoStream(version);
stream.Index.ShouldBe(1);
}
[Test]
public async Task Should_Name_The_Version_And_File_When_There_Is_No_Video_Stream()
{
var version = new MediaVersion
{
Id = 1520,
MediaFiles = [new MediaFile { Path = "/data/music/artist1/song1.mkv" }],
Streams = [new MediaStream { Index = 0, MediaStreamKind = MediaStreamKind.Audio }]
};
InvalidOperationException ex =
await Should.ThrowAsync<InvalidOperationException>(() => Selector().SelectVideoStream(version));
ex.Message.ShouldContain("Media version 1520 (/data/music/artist1/song1.mkv) has no video stream");
ex.Message.ShouldContain("deep scan");
}
[Test]
public async Task Should_Fail_Clearly_When_Streams_And_Files_Were_Never_Loaded()
{
var version = new MediaVersion { Id = 7, MediaFiles = null, Streams = null };
InvalidOperationException ex =
await Should.ThrowAsync<InvalidOperationException>(() => Selector().SelectVideoStream(version));
ex.Message.ShouldContain("Media version 7 ((no media file)) has no video stream");
}
}
}
@@ -359,9 +359,9 @@ public class WatermarkSelectorDecoResolutionTests
/// </summary>
/// <remarks>
/// The channel-level fallback is deliberately an INDEPENDENTLY RESOLVABLE `ChannelLogo` watermark whose
/// cached file exists. An earlier version of this test gave the fallback the same missing custom path as
/// the playout-item watermark, which made it unfalsifiable: a wrongly-widened guard would have fallen
/// through to a fallback that also resolved to None, so the assertion held either way.
/// cached file exists. Giving the fallback the same missing custom path as the playout-item watermark
/// would make the test unfalsifiable: a wrongly-widened guard would fall through to a fallback that also
/// resolves to None, so the assertion would hold either way.
/// </remarks>
[Test]
public void Missing_But_Named_Custom_Playout_Item_Watermark_Should_Not_Fall_Through()
@@ -14,8 +14,10 @@ namespace ErsatzTV.Core.Tests.Scheduling;
// different algorithm keyed on the same PlaybackOrder), and
// - every order the two engines don't support returns None, so each caller logs its own #70 warning
// instead of silently scheduling nothing.
// The Scripted engine has no golden (its external-process/HTTP transport is integration-only, #563), so
// this direct helper test is the in-process regression net for the shared construction it drives.
// The Scripted engine has no golden: its external-process/HTTP transport is permanently outside the
// automated suite, and the engine behind it is covered in-process instead (decision
// testing.scripted-engine-in-process-net, docs/testing.md -> "Scripted playout coverage"). This direct
// helper test is the regression net for the shared construction that engine drives.
[TestFixture]
public class ContentEnumeratorBuilderTests
{
@@ -1,23 +1,50 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling.Engine;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling.Engine;
/// <summary>
/// Characterization of the <see cref="SchedulingEngine" /> build API — the surface a scripted schedule
/// drives, one method per <c>ScriptedScheduleController</c> action. Covers content registration
/// (<c>AddCollection</c>), the scheduling instructions (<c>AddCount</c>, <c>AddAll</c>,
/// <c>AddDuration</c>, <c>PadUntilExact</c>), EPG grouping, per-item history, the no-progress halt, and
/// the anchor round-trip that a Continue build restores from.
/// <para>
/// Deliberately out of scope here: the <c>Cli.Wrap</c> launch of the user-authored script process
/// (exit code, timeout, stdout capture) and the Kestrel/HTTP/auth transport it calls back over. Those
/// are permanently outside the automated suite; the controller adapter that sits between them and this
/// engine is pinned by <c>ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests</c>. Decision:
/// <c>testing.scripted-engine-in-process-net</c>.
/// </para>
/// <para>
/// Every fixture here is timezone-independent by construction: it uses only Chronological order plus
/// <c>AddCount</c>/<c>AddAll</c>/<c>AddDuration</c>/<c>PadUntilExact</c>, all of which preserve the
/// instant. <c>WaitUntil(TimeOnly)</c> and <c>PadUntil(string)</c> read the LOCAL day and time-of-day
/// and are therefore excluded. See docs/testing.md → Timezone independence.
/// </para>
/// </summary>
[TestFixture]
public class SchedulingEngineTests
{
private const string ContentKey = "content";
private const string CollectionName = "Test Collection";
// Pinned build window, offset zero: the engine writes PlayoutItem.Start/Finish as UtcDateTime, so every
// assertion below is on an instant rather than a wall-clock reading.
private static readonly DateTimeOffset Start = new(2026, 1, 15, 6, 0, 0, TimeSpan.Zero);
[Test]
public void Continue_Across_Time_Change()
{
var engine = new SchedulingEngine(
Substitute.For<IMediaCollectionRepository>(),
Substitute.For<IGraphicsElementRepository>(),
Substitute.For<IChannelRepository>(),
Substitute.For<ILogger<SchedulingEngine>>());
SchedulingEngine engine = NewEngine(Substitute.For<IMediaCollectionRepository>());
var anchor = new PlayoutAnchor
{
@@ -25,11 +52,468 @@ public class SchedulingEngineTests
};
var start = new DateTimeOffset(new DateTime(2025, 11, 20), TimeSpan.FromHours(-6));
var finish = start.AddDays(1);
DateTimeOffset finish = start.AddDays(1);
engine.BuildBetween(start, finish);
// should not throw
engine.RestoreOrReset(anchor);
}
[Test]
public async Task AddCollection_Then_AddCount_Lays_Items_Back_To_Back()
{
SchedulingEngine engine = await ResetEngineWithCollection();
engine.AddCount(ContentKey, 4, Option<FillerKind>.None, null, false).ShouldBeTrue();
List<PlayoutItem> items = engine.GetState().AddedItems;
items.Count.ShouldBe(4);
// chronological order is the collection's release-date order, which is item id order here
items.Select(i => i.MediaItemId).ShouldBe([1, 2, 3, 4]);
items[0].Start.ShouldBe(Start.UtcDateTime);
for (var i = 1; i < items.Count; i++)
{
items[i].Start.ShouldBe(items[i - 1].Finish);
}
foreach (PlayoutItem item in items)
{
item.FillerKind.ShouldBe(FillerKind.None);
item.InPoint.ShouldBe(TimeSpan.Zero);
item.OutPoint.ShouldBe(item.Finish - item.Start);
item.PlayoutId.ShouldBe(1);
}
// outside an EPG group every item opens its own guide group
items.Select(i => i.GuideGroup).ShouldBe([1, 2, 3, 4]);
// 30 + 45 + 60 + 20 minutes of content
TimeSpan scheduled = TimeSpan.FromMinutes(155);
items[^1].Finish.ShouldBe(Start.UtcDateTime + scheduled);
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start + scheduled);
}
[Test]
public async Task AddAll_Schedules_Every_Item_Once()
{
SchedulingEngine engine = await ResetEngineWithCollection();
engine.AddAll(ContentKey, Option<FillerKind>.None, null, false).ShouldBeTrue();
List<PlayoutItem> items = engine.GetState().AddedItems;
items.Select(i => i.MediaItemId).ShouldBe([1, 2, 3, 4, 5, 6]);
// 30 + 45 + 60 + 20 + 90 + 15 minutes
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start + TimeSpan.FromMinutes(260));
}
[Test]
public async Task AddDuration_Stops_Before_Overrunning_The_Target()
{
SchedulingEngine engine = await ResetEngineWithCollection();
engine.AddDuration(
ContentKey,
"2:00:00",
fallback: null,
trim: false,
discardAttempts: 0,
stopBeforeEnd: true,
offlineTail: false,
Option<FillerKind>.None,
customTitle: null,
disableWatermarks: false)
.ShouldBeTrue();
DateTimeOffset target = Start.AddHours(2);
List<PlayoutItem> items = engine.GetState().AddedItems;
// 30 + 45 fits; the third item (60) does not, and nothing is trimmed
items.Select(i => i.MediaItemId).ShouldBe([1, 2]);
items[^1].Finish.ShouldBe(Start.UtcDateTime + TimeSpan.FromMinutes(75));
items[^1].Finish.ShouldBeLessThan(target.UtcDateTime);
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start + TimeSpan.FromMinutes(75));
}
[Test]
public async Task AddDuration_Trims_The_Last_Item_When_Trim_Is_Set()
{
SchedulingEngine engine = await ResetEngineWithCollection();
engine.AddDuration(
ContentKey,
"2:00:00",
fallback: null,
trim: true,
discardAttempts: 0,
stopBeforeEnd: true,
offlineTail: false,
Option<FillerKind>.None,
customTitle: null,
disableWatermarks: false)
.ShouldBeTrue();
DateTimeOffset target = Start.AddHours(2);
List<PlayoutItem> items = engine.GetState().AddedItems;
items.Select(i => i.MediaItemId).ShouldBe([1, 2, 3]);
items[^1].Finish.ShouldBe(target.UtcDateTime);
items[^1].OutPoint.ShouldBe(items[^1].Finish - items[^1].Start);
items[^1].OutPoint.ShouldBe(TimeSpan.FromMinutes(45));
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(target);
}
[Test]
public async Task PadUntilExact_Fills_To_The_Target_Instant()
{
SchedulingEngine engine = await ResetEngineWithCollection();
DateTimeOffset target = Start.AddHours(2);
engine.PadUntilExact(
ContentKey,
target,
fallback: null,
trim: true,
discardAttempts: 0,
stopBeforeEnd: true,
offlineTail: false,
Option<FillerKind>.None,
customTitle: null,
disableWatermarks: false)
.ShouldBeTrue();
List<PlayoutItem> items = engine.GetState().AddedItems;
items.Count.ShouldBeGreaterThan(0);
items[0].Start.ShouldBe(Start.UtcDateTime);
items[^1].Finish.ShouldBe(target.UtcDateTime);
// the target is an instant, so a machine-local offset must not move it
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(target);
}
[Test]
public async Task AddDuration_Rejects_An_Unparseable_Duration()
{
SchedulingEngine engine = await ResetEngineWithCollection();
engine.AddDuration(
ContentKey,
"not-a-duration",
fallback: null,
trim: false,
discardAttempts: 0,
stopBeforeEnd: true,
offlineTail: false,
Option<FillerKind>.None,
customTitle: null,
disableWatermarks: false)
.ShouldBeFalse();
engine.GetState().AddedItems.ShouldBeEmpty();
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start);
}
[Test]
public async Task AddDuration_Rejects_Offline_Tail_Without_Stop_Before_End()
{
SchedulingEngine engine = await ResetEngineWithCollection();
engine.AddDuration(
ContentKey,
"2:00:00",
fallback: null,
trim: false,
discardAttempts: 0,
stopBeforeEnd: false,
offlineTail: true,
Option<FillerKind>.None,
customTitle: null,
disableWatermarks: false)
.ShouldBeFalse();
engine.GetState().AddedItems.ShouldBeEmpty();
}
[TestCase("add_all")]
[TestCase("add_count")]
[TestCase("add_duration")]
[TestCase("pad_to_next")]
[TestCase("pad_until")]
[TestCase("pad_until_exact")]
public async Task Unknown_Content_Key_Returns_False_And_Schedules_Nothing(string instruction)
{
SchedulingEngine engine = await ResetEngineWithCollection();
const string Unknown = "no-such-key";
bool result = instruction switch
{
"add_all" => engine.AddAll(Unknown, Option<FillerKind>.None, null, false),
"add_count" => engine.AddCount(Unknown, 1, Option<FillerKind>.None, null, false),
"add_duration" => engine.AddDuration(
Unknown,
"1:00:00",
null,
false,
0,
true,
false,
Option<FillerKind>.None,
null,
false),
"pad_to_next" => engine.PadToNext(
Unknown,
15,
null,
false,
0,
true,
false,
Option<FillerKind>.None,
null,
false),
"pad_until" => engine.PadUntil(
Unknown,
"07:00",
false,
null,
false,
0,
true,
false,
Option<FillerKind>.None,
null,
false),
"pad_until_exact" => engine.PadUntilExact(
Unknown,
Start.AddHours(1),
null,
false,
0,
true,
false,
Option<FillerKind>.None,
null,
false),
_ => throw new ArgumentOutOfRangeException(nameof(instruction))
};
result.ShouldBeFalse();
// the false is only meaningful if nothing was scheduled behind it
engine.GetState().AddedItems.ShouldBeEmpty();
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start);
}
[Test]
public async Task Empty_Collection_Is_Skipped()
{
var repository = Substitute.For<IMediaCollectionRepository>();
repository.GetCollectionItemsByName(CollectionName, Arg.Any<CancellationToken>())
.Returns(new List<MediaItem>());
SchedulingEngine engine = ResetEngine(repository);
await engine.AddCollection(ContentKey, CollectionName, PlaybackOrder.Chronological, CancellationToken.None);
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeFalse();
engine.GetState().AddedItems.ShouldBeEmpty();
}
[Test]
public async Task Filler_Kind_And_Custom_Title_Reach_The_Item()
{
SchedulingEngine engine = await ResetEngineWithCollection();
engine.AddCount(ContentKey, 1, FillerKind.PreRoll, "Bumper", true).ShouldBeTrue();
PlayoutItem item = engine.GetState().AddedItems.Single();
item.FillerKind.ShouldBe(FillerKind.PreRoll);
item.CustomTitle.ShouldBe("Bumper");
item.DisableWatermarks.ShouldBeTrue();
}
[Test]
public async Task Guide_Group_Is_Locked_Across_An_Epg_Group()
{
SchedulingEngine engine = await ResetEngineWithCollection();
engine.LockGuideGroup(advance: true, customTitle: "Block");
engine.AddCount(ContentKey, 3, Option<FillerKind>.None, null, false).ShouldBeTrue();
engine.UnlockGuideGroup();
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeTrue();
List<PlayoutItem> items = engine.GetState().AddedItems;
items.Count.ShouldBe(4);
List<PlayoutItem> grouped = items.Take(3).ToList();
grouped.Select(i => i.GuideGroup).Distinct().Count().ShouldBe(1);
grouped.ShouldAllBe(i => i.CustomTitle == "Block");
// unlocking resumes per-item advancement from the group's number
items[3].GuideGroup.ShouldBe(grouped[0].GuideGroup + 1);
items[3].CustomTitle.ShouldBeNull();
}
[Test]
public async Task History_Is_Recorded_Per_Item()
{
SchedulingEngine engine = await ResetEngineWithCollection();
engine.AddCount(ContentKey, 3, Option<FillerKind>.None, null, false).ShouldBeTrue();
List<PlayoutItem> items = engine.GetState().AddedItems;
List<PlayoutHistory> history = engine.GetState().AddedHistory;
history.Count.ShouldBe(items.Count);
string expectedKey = HistoryDetails.KeyForSchedulingContent(ContentKey, PlaybackOrder.Chronological);
for (var i = 0; i < history.Count; i++)
{
history[i].Key.ShouldBe(expectedKey);
history[i].PlayoutId.ShouldBe(1);
history[i].PlaybackOrder.ShouldBe(PlaybackOrder.Chronological);
history[i].Index.ShouldBe(i);
history[i].When.ShouldBe(items[i].Start);
history[i].Finish.ShouldBe(items[i].Finish);
}
}
[Test]
public void Is_Done_Throws_After_Twenty_Consecutive_Calls_Without_Progress()
{
SchedulingEngine engine = ResetEngine(Substitute.For<IMediaCollectionRepository>());
ISchedulingEngineState state = engine.GetState();
// the first read establishes the baseline; each of the next 19 increments the no-progress counter
for (var i = 0; i < 20; i++)
{
state.IsDone.ShouldBeFalse();
}
Should.Throw<InvalidOperationException>(() => _ = state.IsDone);
}
[Test]
public async Task Is_Done_Counter_Resets_When_Time_Advances()
{
SchedulingEngine engine = await ResetEngineWithCollection();
ISchedulingEngineState state = engine.GetState();
for (var i = 0; i < 20; i++)
{
state.IsDone.ShouldBeFalse();
}
// one instruction that advances CurrentTime clears the counter, so the budget starts over
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeTrue();
for (var i = 0; i < 20; i++)
{
state.IsDone.ShouldBeFalse();
}
}
[Test]
public async Task Anchor_Round_Trips_Through_Restore()
{
SchedulingEngine first = await ResetEngineWithCollection();
first.AddCount(ContentKey, 2, Option<FillerKind>.None, null, false).ShouldBeTrue();
List<PlayoutItem> firstItems = first.GetState().AddedItems;
int lastGuideGroup = firstItems[^1].GuideGroup;
PlayoutAnchor anchor = first.GetAnchor();
anchor.NextStart.ShouldBe(firstItems[^1].Finish);
SchedulingEngine second = NewEngine(CollectionRepository());
second.WithPlayoutId(1)
.WithMode(PlayoutBuildMode.Continue)
.WithSeed(0)
.BuildBetween(Start, Start.AddDays(1))
.WithReferenceData(EmptyReferenceData())
.RestoreOrReset(anchor);
// the anchor carries an instant, not a wall-clock reading
second.GetState().CurrentTime.ToUniversalTime().ShouldBe(new DateTimeOffset(anchor.NextStart, TimeSpan.Zero));
await second.AddCollection(ContentKey, CollectionName, PlaybackOrder.Chronological, CancellationToken.None);
second.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeTrue();
PlayoutItem resumed = second.GetState().AddedItems.Single();
resumed.Start.ShouldBe(anchor.NextStart);
// the guide group continues from the serialized context instead of restarting at 1
resumed.GuideGroup.ShouldBe(lastGuideGroup + 1);
}
private static SchedulingEngine NewEngine(IMediaCollectionRepository repository) =>
new(
repository,
Substitute.For<IGraphicsElementRepository>(),
Substitute.For<IChannelRepository>(),
Substitute.For<ILogger<SchedulingEngine>>());
// WithReferenceData must precede RestoreOrReset and AddCollection: both dereference
// PlayoutReferenceData.PlayoutHistory, so a different order fails with a null reference that reads
// like an engine bug. This is the same order ScriptedPlayoutBuilder uses.
private static SchedulingEngine ResetEngine(IMediaCollectionRepository repository)
{
SchedulingEngine engine = NewEngine(repository);
engine.WithPlayoutId(1)
.WithMode(PlayoutBuildMode.Reset)
.WithSeed(0)
.BuildBetween(Start, Start.AddDays(1))
.WithReferenceData(EmptyReferenceData())
.RestoreOrReset(Option<PlayoutAnchor>.None);
return engine;
}
private static async Task<SchedulingEngine> ResetEngineWithCollection()
{
SchedulingEngine engine = ResetEngine(CollectionRepository());
await engine.AddCollection(ContentKey, CollectionName, PlaybackOrder.Chronological, CancellationToken.None);
return engine;
}
private static IMediaCollectionRepository CollectionRepository()
{
var repository = Substitute.For<IMediaCollectionRepository>();
repository.GetCollectionItemsByName(CollectionName, Arg.Any<CancellationToken>())
.Returns(_ => TestCollection());
return repository;
}
// Distinct release dates make chronological order deterministic (id order); distinct durations make
// every boundary in an assertion unambiguous.
private static List<MediaItem> TestCollection() =>
[
FakeMovie(1, 30),
FakeMovie(2, 45),
FakeMovie(3, 60),
FakeMovie(4, 20),
FakeMovie(5, 90),
FakeMovie(6, 15)
];
private static Movie FakeMovie(int id, int minutes) =>
new()
{
Id = id,
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(minutes) }],
MovieMetadata =
[
new MovieMetadata
{
Title = $"Movie {id:D2}",
ReleaseDate = new DateTime(2005, 1, 1).AddDays(id)
}
]
};
private static PlayoutReferenceData EmptyReferenceData() =>
new(null, Option<Deco>.None, [], [], null, [], [], TimeSpan.Zero);
}
+27 -4
View File
@@ -1,4 +1,4 @@
using System.Collections.Immutable;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
@@ -38,8 +38,31 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
_logger = logger;
}
public Task<MediaStream> SelectVideoStream(MediaVersion version) =>
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask();
// ersatztv#924: a version with no video stream on record (its statistics were never read, or were stored
// empty) cannot be played. Name the version and its file and say how the statistics get populated, instead
// of surfacing First()'s bare "Sequence contains no matching element".
public Task<MediaStream> SelectVideoStream(MediaVersion version)
{
foreach (MediaStream videoStream in Optional(version.Streams).Flatten()
.Filter(s => s.MediaStreamKind == MediaStreamKind.Video)
.HeadOrNone())
{
return videoStream.AsTask();
}
string path = Optional(version.MediaFiles).Flatten()
.HeadOrNone()
.Map(f => f.Path)
.IfNone("(no media file)");
return Task.FromException<MediaStream>(
new InvalidOperationException(
$"Media version {version.Id} ({path}) has no video stream on record, so it cannot be played. " +
"If its media statistics were never read, rescan the item's library to read them (for a " +
"Jellyfin/Emby/Plex library, a deep scan, since a regular scan skips items the server reports " +
"unchanged). If the file itself has no video stream, a rescan cannot help: remove it from " +
"video channels."));
}
public async Task<Option<MediaStream>> SelectAudioStream(
MediaItemAudioVersion version,
@@ -115,7 +138,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
}
break;
// let default fall through
// let default fall through
}
}
catch (Exception ex)
@@ -1,10 +1,52 @@
using System.IO;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Graphics;
public static class GraphicsElementDefaults
{
// Built-in "On Now / Next" text element; identity is by filename, never by user-editable Name.
// Built-in "On Now / Next" text element filename -- a component of OnNowNextSeededPath below,
// never itself an identity check (#568: a filename-only comparison is folder-agnostic).
public const string OnNowNextFileName = "on-now-next.yml";
// Display name only. Never use it for identity -- that is the filename above (#67 / #74).
// Display name only. Never use it for identity -- that is IsOnNowNext below (#67 / #74 / #568).
public const string OnNowNextName = "On Now / Next";
// The full path the seeder writes the built-in template to (GraphicsElementSeeder.SeedOnNowNext
// builds `target` the same way). A `builtIn` discriminator must match THIS, not
// `Path.GetFileName(...) == OnNowNextFileName` -- a filename-only comparison is folder-agnostic:
// a user element named exactly `on-now-next.yml` in any of the other four template folders
// (image/motion/subtitle/script) would also report `builtIn:true` (#568). `Kind == Text` alone
// does not close this either, since a second text template could share the filename in principle.
public static string OnNowNextSeededPath =>
Path.Combine(FileSystemLayout.GraphicsElementsTextTemplatesFolder, OnNowNextFileName);
/// <summary>
/// The one identity test for the built-in On Now / Next element: the exact file the seeder
/// wrote, at the exact path it wrote it to, of the kind it wrote it as. Ordinal on purpose,
/// and so case-sensitive on purpose.
/// </summary>
/// <remarks>
/// <para>
/// <c>Kind</c> is part of the identity rather than a second test any caller may add or
/// skip: the seeder's own "does the built-in row exist yet?" check resolves through this
/// predicate, so a row of another kind at the seeded path answering yes would suppress
/// the Text row every consumer resolves. A caller applying only the path half would
/// report that wrong-kind row as the built-in element while the seeder refused to treat
/// it as one -- the two sites disagreeing about the same row, which is the defect this
/// predicate exists to make impossible (#568).
/// </para>
/// <para>
/// Callers compare in memory rather than in a <c>Where</c> clause, because in SQL the
/// answer would be the PROVIDER's to give: <c>GraphicsElement.Path</c> takes no explicit
/// collation (<c>TvContext.OnModelCreating</c> pins one only on the listed name/title
/// columns), so SQLite compares it case-sensitively while MySQL uses the server default,
/// which is normally case-INsensitive. Evaluating one discriminator site in SQL and the
/// other in memory would let the two disagree on MySQL alone. The SQLite test suite
/// cannot tell the two apart -- BINARY collation and an ordinal comparison agree on
/// every input -- so this is held by keeping the comparison out of SQL, not by a test.
/// </para>
/// </remarks>
public static bool IsOnNowNext(string path, GraphicsElementKind kind) =>
kind == GraphicsElementKind.Text && string.Equals(path, OnNowNextSeededPath, StringComparison.Ordinal);
}
@@ -173,14 +173,25 @@ public static class GraphicsElementSeeder
return;
}
bool exists = await context.GraphicsElements.AnyAsync(e => e.Path == target, cancellationToken);
if (exists)
// "Does the built-in row already exist?" is the same question every consumer asks later, so
// ask it with the same code instead of re-deriving it here. As its own SQL comparison
// (`AnyAsync(e => e.Path == target)`) it could answer differently in two ways, and either
// one leaves the built-in element undiscoverable after startup (#568):
// * 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, the canonical row was
// never created, and the ordinal lookup below then matched nothing;
// * it ignored `Kind`, so a row of another kind sitting at the seeded path suppressed the
// Text row the lookup actually resolves.
// Creating the row stays idempotent because `target` IS the path the lookup matches -- held
// by `Repeated_Seeding_Does_Not_Accumulate_Element_Rows`, which reddens if the two drift.
if ((await GetBuiltInElementId(context, cancellationToken)).IsSome)
{
return;
}
// Name is display-only (identity is the filename), but leaving it null sorts the built-in
// element into the unnamed bucket at the bottom of the SPA list until the first refresh.
// Name is display-only (identity is the full seeded path, `target` above -- #568), but
// leaving it null sorts the built-in element into the unnamed bucket at the bottom of the
// SPA list until the first refresh.
await context.GraphicsElements.AddAsync(
new Core.Domain.GraphicsElement
{
@@ -246,29 +257,42 @@ public static class GraphicsElementSeeder
}
/// <summary>
/// Identity is the filename, never the user-editable Name (the #67 lesson carried into #74).
/// The Kind is part of it: the five template folders are separate namespaces, so an unrelated
/// image/motion/subtitle/script element may legitimately be named `on-now-next.yml` too, and
/// filename alone would hand back whichever row the unordered query happened to return first.
/// Identity is the full seeded path, never the user-editable Name (the #67 lesson carried into
/// #74) and never the bare filename (#568: filename-only matching is folder-agnostic, so a user
/// element named exactly `on-now-next.yml` in a different template folder would also match).
/// The <c>Kind</c> half of that identity is load-bearing rather than decorative:
/// <c>EnsureBuiltInElementRow</c> asks this method whether the row it is about to create already
/// exists, so a row of another kind at the seeded path must NOT answer yes -- it would suppress
/// the Text row every consumer resolves.
/// </summary>
/// <remarks>
/// Both halves are <see cref="GraphicsElementDefaults.IsOnNowNext(string,GraphicsElementKind)"/>
/// in memory rather than a <c>Where</c> clause. The path half must be, or the match would be the
/// provider's collation to decide and this site would disagree with the API's `builtIn` (which
/// compares in memory) on MySQL. The <c>Kind</c> half could be a SQL filter -- it is an enum,
/// not a string -- but then this site would hold half the identity and the predicate the other
/// half, and the API site could apply the predicate alone and quietly answer for rows this one
/// rejects. That is exactly the disagreement #568 found, so identity is one predicate applied
/// whole, at every site.
/// </remarks>
public static async Task<Option<int>> GetBuiltInElementId(
TvContext context,
CancellationToken cancellationToken)
{
List<(int Id, string Path)> candidates = await context.GraphicsElements
.Where(e => e.Kind == GraphicsElementKind.Text)
.Select(e => new { e.Id, e.Path })
List<(int Id, string Path, GraphicsElementKind Kind)> candidates = await context.GraphicsElements
.Select(e => new { e.Id, e.Path, e.Kind })
.ToListAsync(cancellationToken)
.Map(rows => rows.Select(r => (r.Id, r.Path)).ToList());
.Map(rows => rows.Select(r => (r.Id, r.Path, r.Kind)).ToList());
var matches = candidates
.Where(c => System.IO.Path.GetFileName(c.Path) == GraphicsElementDefaults.OnNowNextFileName)
.OrderBy(c => c.Id)
List<int> matches = candidates
.Where(c => GraphicsElementDefaults.IsOnNowNext(c.Path, c.Kind))
.Select(c => c.Id)
.OrderBy(id => id)
.ToList();
// Lowest id wins if two text templates somehow share the filename, so the choice is stable
// across restarts rather than dependent on query order.
return matches.Count == 0 ? Option<int>.None : matches[0].Id;
// Lowest id wins if two rows somehow share the seeded path, so the choice is stable across
// restarts rather than dependent on query order.
return matches.Count == 0 ? Option<int>.None : matches[0];
}
private static async Task UpgradeUnmodifiedTemplate(
@@ -19,6 +19,7 @@ public class JellyfinMusicVideoLibraryScanner :
{
private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IJellyfinMusicVideoRepository _jellyfinMusicVideoRepository;
private readonly ILogger<JellyfinMusicVideoLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMetadataRepository _metadataRepository;
private readonly IMusicVideoRepository _musicVideoRepository;
@@ -50,6 +51,7 @@ public class JellyfinMusicVideoLibraryScanner :
_mediaSourceRepository = mediaSourceRepository;
_musicVideoRepository = musicVideoRepository;
_metadataRepository = metadataRepository;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanLibrary(
@@ -97,6 +99,41 @@ public class JellyfinMusicVideoLibraryScanner :
library,
projectionFailures);
protected override async Task<Option<MediaVersion>> GetMediaServerStatistics(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
MediaItemScanResult<JellyfinMusicVideo> result,
JellyfinMusicVideo incoming)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Jellyfin Statistics", result.LocalPath);
Either<BaseError, MediaVersion> maybeVersion =
await _jellyfinApiClient.GetPlaybackInfo(
connectionParameters.Address,
connectionParameters.AuthorizationHeader,
library,
incoming.ItemId);
foreach (BaseError error in maybeVersion.LeftToSeq())
{
_logger.LogWarning(
"Failed to get music video statistics from Jellyfin for {Path}: {Error}",
result.LocalPath,
error.ToString());
}
// Chapters arrive with the library-items projection, not with playback info. They are taken from the
// INCOMING projection, not from result.Item as JellyfinMovieLibraryScanner does: the music-video
// repository's GetByItemId does not eager-load chapters, so on an existing row result.Item's head version
// carries none to write.
foreach (MediaVersion version in maybeVersion.RightToSeq())
{
version.Chapters = incoming.GetHeadVersion().Chapters ?? [];
}
return maybeVersion.ToOption();
}
protected override async Task<Either<BaseError, MediaItemScanResult<JellyfinMusicVideo>>> UpdateMetadata(
MediaItemScanResult<JellyfinMusicVideo> result,
JellyfinMusicVideo incoming,
@@ -131,6 +131,7 @@ public abstract class MediaServerMusicVideoLibraryScanner<TConnectionParameters,
Either<BaseError, MediaItemScanResult<TMusicVideo>> maybeMusicVideo =
await ProcessMusicVideo(
musicVideoRepository,
connectionParameters,
library,
libraryPath,
incoming,
@@ -250,6 +251,7 @@ public abstract class MediaServerMusicVideoLibraryScanner<TConnectionParameters,
private async Task<Either<BaseError, MediaItemScanResult<TMusicVideo>>> ProcessMusicVideo(
IMediaServerMusicVideoRepository<TLibrary, TMusicVideo, TEtag> musicVideoRepository,
TConnectionParameters connectionParameters,
TLibrary library,
LibraryPath libraryPath,
TMusicVideo incoming,
@@ -279,7 +281,7 @@ public abstract class MediaServerMusicVideoLibraryScanner<TConnectionParameters,
return result;
})
.BindT(result => UpdateMetadata(result, incoming, cancellationToken))
.BindT(result => UpdateStatistics(result, incoming));
.BindT(result => UpdateStatistics(connectionParameters, library, result, incoming, deepScan));
}
private async Task<Either<BaseError, Artist>> GetOrAddArtist(LibraryPath libraryPath, TMusicVideo musicVideo)
@@ -315,13 +317,40 @@ public abstract class MediaServerMusicVideoLibraryScanner<TConnectionParameters,
return result.Map(scanResult => scanResult.Item);
}
// ersatztv#924: the library-items projection carries NO streams and no width/height (the server only reports
// those through its per-item playback info), so the projection's version must never be written as the
// statistics — doing so stored a version FFmpegStreamSelector cannot play, and, because
// IMetadataRepository.UpdateStatistics reconciles streams against the version it is given, removed every
// stream an existing row already had. Statistics come from the media server, under the same condition
// MediaServerMovieLibraryScanner.UpdateStatistics uses; when the server cannot supply them nothing is written,
// so stored streams are kept rather than replaced by the projection's empty list. (`result.IsAdded` is implied
// by the empty-streams arm while the projection carries no streams; it is kept so the condition stays the
// movie scanner's.)
private async Task<Either<BaseError, MediaItemScanResult<TMusicVideo>>> UpdateStatistics(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TMusicVideo> result,
TMusicVideo incoming)
TMusicVideo incoming,
bool deepScan)
{
if (await _metadataRepository.UpdateStatistics(result.Item, incoming.GetHeadVersion()))
TMusicVideo existing = result.Item;
if (deepScan || result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) ||
existing.GetHeadVersion().Streams is not { Count: > 0 })
{
result.IsUpdated = true;
Option<MediaVersion> maybeMediaVersion = await GetMediaServerStatistics(
connectionParameters,
library,
result,
incoming);
foreach (MediaVersion mediaVersion in maybeMediaVersion)
{
if (await _metadataRepository.UpdateStatistics(result.Item, mediaVersion))
{
result.IsUpdated = true;
}
}
}
return result;
@@ -405,4 +434,13 @@ public abstract class MediaServerMusicVideoLibraryScanner<TConnectionParameters,
MediaItemScanResult<TMusicVideo> result,
TMusicVideo incoming,
CancellationToken cancellationToken);
// ersatztv#924: abstract rather than the movie scanner's virtual-returning-None, because a None default is
// exactly the defect — an implementation that forgot to override it would scan music videos with no streams.
// The returned version carries the chapters to store alongside the statistics.
protected abstract Task<Option<MediaVersion>> GetMediaServerStatistics(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TMusicVideo> result,
TMusicVideo incoming);
}
@@ -26,7 +26,7 @@ public class CreateChannelDefaultGraphicsElementTests : ChannelHandlerTestBase
await using TvContext context = Db.CreateContext();
var element = new GraphicsElement
{
Path = $"/templates/text/{GraphicsElementDefaults.OnNowNextFileName}",
Path = GraphicsElementDefaults.OnNowNextSeededPath,
Kind = GraphicsElementKind.Text
};
@@ -464,7 +464,7 @@ public class CreateChannelFromLineupHandlerTests
[Test]
public async Task Should_Reject_WeightedShuffle_For_A_Multi_Item_Lineup()
{
// regression (#70, found by adversarial review of PR #402): a 2+ entry lineup is persisted as a
// regression (#70, PR #402): a 2+ entry lineup is persisted as a
// Playlist, and PlaylistEnumerator has no default arm -- an order it doesn't know leaves the
// enumerator null and the items vanish from the playlist with nothing reported. This handler is the
// THIRD writer of PlaylistItem.PlaybackOrder and was missed when the other two were gated.
@@ -1020,7 +1020,7 @@ public class CreateChannelFromLineupHandlerTests
await using TvContext context = _db.CreateContext();
var element = new GraphicsElement
{
Path = $"/templates/text/{GraphicsElementDefaults.OnNowNextFileName}",
Path = GraphicsElementDefaults.OnNowNextSeededPath,
Kind = GraphicsElementKind.Text
};
@@ -1,6 +1,9 @@
using System.Globalization;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
@@ -15,6 +18,9 @@ public class UpdateChannelGraphicsElementsTests : ChannelHandlerTestBase
{
private UpdateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets, RemoteLogoCacher);
private static BaseError LeftOf(Either<BaseError, ChannelViewModel> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
private async Task<(int ElementAId, int ElementBId)> SeedGraphicsElements()
{
await using TvContext context = Db.CreateContext();
@@ -25,6 +31,37 @@ public class UpdateChannelGraphicsElementsTests : ChannelHandlerTestBase
return (elementA.Id, elementB.Id);
}
private async Task<int> SeedWatermark()
{
await using TvContext context = Db.CreateContext();
var watermark = new ChannelWatermark { Name = "W" };
context.ChannelWatermarks.Add(watermark);
await context.SaveChangesAsync();
return watermark.Id;
}
private async Task<List<int>> SeedGraphicsElements(int count)
{
await using TvContext context = Db.CreateContext();
List<GraphicsElement> elements = Enumerable.Range(0, count)
.Select(i => new GraphicsElement { Path = $"element-{i}.yml" })
.ToList();
context.GraphicsElements.AddRange(elements);
await context.SaveChangesAsync();
return elements.Select(e => e.Id).ToList();
}
// Replaces the harness the base class built with one whose SaveChangesAsync can be made to fail
// on demand. Foreign keys are off in InMemoryTvContext, so the FK violation a concurrent delete
// really produces cannot be raised by seeding alone.
private async Task<ArmedSaveFailureInterceptor> UseFailingSaveHarness()
{
await Db.DisposeAsync();
var interceptor = new ArmedSaveFailureInterceptor();
Db = await InMemoryTvContext.CreateAsync(interceptor);
return interceptor;
}
[Test]
public async Task Should_Reconcile_GraphicsElement_Join_Add_Then_Remove()
{
@@ -73,4 +110,199 @@ public class UpdateChannelGraphicsElementsTests : ChannelHandlerTestBase
reloaded.ChannelGraphicsElements.ShouldBeEmpty();
}
}
// #568: an unknown graphicsElementIds entry used to reach ApplyUpdateRequest unchecked, which
// blindly Adds a ChannelGraphicsElement and lets SaveChangesAsync hit
// FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId -> unhandled 500. Removing the
// GraphicsElementIdsMustExist validator alone from UpdateChannelHandler.Validate is row 35 of the
// mutation table in docs/graphics-elements.md, measured against the whole ErsatzTV.Tests project.
[Test]
public async Task Should_Reject_Unknown_GraphicsElementId_With_422_Not_500()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [999]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain("999");
// no partial write: the channel keeps no graphics element association
await using TvContext context = Db.CreateContext();
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == channel.Id);
reloaded.ChannelGraphicsElements.ShouldBeEmpty();
}
[Test]
public async Task Should_Reject_When_One_Of_Several_GraphicsElementIds_Is_Unknown()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
(int elementAId, _) = await SeedGraphicsElements();
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId, 12345]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain("12345");
}
// #568: the id list is client-supplied and was bounded only by the Kestrel body cap, which is a
// transport limit and not a collection limit. The cap is Validators.MaximumIdListCount, shared
// by all three id-list validators; these three tests pin its two edges and the shape that makes
// its placement matter. Removing the cap from Validators.IdsMustExist is row 41 of the mutation
// table in docs/graphics-elements.md.
[Test]
public async Task Should_Accept_Exactly_The_Maximum_Number_Of_GraphicsElementIds()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
List<int> ids = await SeedGraphicsElements(Validators.MaximumIdListCount);
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext context = Db.CreateContext();
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == channel.Id);
reloaded.ChannelGraphicsElements.Count.ShouldBe(Validators.MaximumIdListCount);
}
[Test]
public async Task Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
List<int> ids = Enumerable.Range(1, Validators.MaximumIdListCount + 1).ToList();
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain((Validators.MaximumIdListCount + 1).ToString(CultureInfo.InvariantCulture));
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
}
// The cap counts the RAW list, before Distinct: every one of these ids exists and they collapse
// to a single distinct id, so a cap applied after deduplication would accept this request and
// leave the parse and materialization it costs unbounded.
[Test]
public async Task Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
(int elementAId, _) = await SeedGraphicsElements();
List<int> ids = Enumerable.Repeat(elementAId, Validators.MaximumIdListCount + 1).ToList();
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain((Validators.MaximumIdListCount + 1).ToString(CultureInfo.InvariantCulture));
error.Value.ShouldNotContain("do not exist");
}
// A 422 that echoes every rejected id turns an oversized request into an oversized response.
// Removing the truncation from Validators.DescribeIds is row 42 of the mutation table.
[Test]
public async Task Should_Cap_The_Ids_Echoed_Back_In_The_Unknown_Id_422()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
List<int> ids = Enumerable.Range(1001, 30).ToList();
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("1001");
error.Value.ShouldContain("(and 20 more)");
error.Value.ShouldNotContain("1030");
}
// #568: validation and the write are two statements, so RefreshGraphicsElements can delete a
// validated element in between and hand the join insert the FK violation the validator exists to
// prevent -- the unhandled 500 again. Removing the DbUpdateException catch from
// ApplyUpdateRequestTranslatingLostRace is row 43 of the mutation table.
[Test]
public async Task Should_Translate_An_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422()
{
ArmedSaveFailureInterceptor interceptor = await UseFailingSaveHarness();
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
(int elementAId, _) = await SeedGraphicsElements();
interceptor.SqlBeforeFailing =
$"DELETE FROM GraphicsElement WHERE Id = {elementAId.ToString(CultureInfo.InvariantCulture)}";
interceptor.Armed = true;
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain(elementAId.ToString(CultureInfo.InvariantCulture));
}
// graphicsElementIds is not the only FK this DTO writes, and the recovery path re-asks the whole
// of Validate rather than the graphics-element half precisely so the other FKs are covered:
// WatermarkId is written by the same SaveChangesAsync and loses the same race. Reddens if the
// recheck is narrowed back to GraphicsElementIdsMustExist -- row 45 of the mutation table in
// docs/graphics-elements.md.
[Test]
public async Task Should_Translate_A_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422()
{
ArmedSaveFailureInterceptor interceptor = await UseFailingSaveHarness();
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
int watermarkId = await SeedWatermark();
interceptor.SqlBeforeFailing =
$"DELETE FROM ChannelWatermark WHERE Id = {watermarkId.ToString(CultureInfo.InvariantCulture)}";
interceptor.Armed = true;
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", watermarkId: watermarkId),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("Watermark");
error.Value.ShouldContain(watermarkId.ToString(CultureInfo.InvariantCulture));
}
// The other half of that catch: a DbUpdateException whose cause is NOT a missing graphics
// element is a real fault and must keep its own exception rather than be reported to the client
// as a validation error about ids that are all still present.
[Test]
public async Task Should_Not_Report_An_Unrelated_DbUpdateException_As_A_Graphics_Element_422()
{
ArmedSaveFailureInterceptor interceptor = await UseFailingSaveHarness();
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
(int elementAId, _) = await SeedGraphicsElements();
interceptor.Armed = true;
await Should.ThrowAsync<DbUpdateException>(
async () => await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId]),
CancellationToken.None));
}
}
@@ -1,6 +1,8 @@
using ErsatzTV.Application.Graphics;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Graphics;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NUnit.Framework;
@@ -62,6 +64,97 @@ public class GraphicsElementHandlerTests
result.ShouldBeEmpty();
}
// #568: the discriminator used to be Path.GetFileName(e.Path) == OnNowNextFileName, which is
// folder-agnostic -- a user element named exactly "on-now-next.yml" outside the seeded text
// template folder would also report builtIn:true. The row carries the SAME Kind as the real
// seeded element, deliberately: a wrong-kind row here would be rejected by the Kind conjunct
// whatever the path comparison is, so the composite revert (filename AND Kind == Text) would
// pass. Only the PATH half can reject a Text row in another folder, which is what this pins --
// it mirrors the seeder-site Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder.
[Test]
public async Task GetAllGraphicsElementsForApi_Should_Not_Mark_Same_Filename_Outside_Seeded_Folder_As_BuiltIn()
{
string userElementPath = System.IO.Path.Combine(
"/config/graphics-elements/text/some-subfolder",
GraphicsElementDefaults.OnNowNextFileName);
await SeedElement(1, userElementPath, GraphicsElementKind.Text, string.Empty);
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
List<GraphicsElementResponseModel> result =
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
result.Count.ShouldBe(1);
result[0].BuiltIn.ShouldBeFalse();
}
[Test]
public async Task GetAllGraphicsElementsForApi_Should_Mark_The_Seeded_Path_As_BuiltIn()
{
await SeedElement(
1,
GraphicsElementDefaults.OnNowNextSeededPath,
GraphicsElementKind.Text,
GraphicsElementDefaults.OnNowNextName);
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
List<GraphicsElementResponseModel> result =
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
result.Count.ShouldBe(1);
result[0].BuiltIn.ShouldBeTrue();
}
// #568: identity is GraphicsElementDefaults.IsOnNowNext, an ORDINAL comparison, so a row whose
// path differs from the seeded one only in case is a different element. Reddens if that
// comparison is loosened to OrdinalIgnoreCase. It does NOT pin provider independence: under
// SQLite's BINARY collation a `Where(e => e.Path == ...)` in SQL answers identically, which is
// why the comparison is kept in memory rather than pinned here (see IsOnNowNext's remarks).
[Test]
public async Task GetAllGraphicsElementsForApi_Should_Not_Mark_A_Case_Variant_Of_The_Seeded_Path_As_BuiltIn()
{
await SeedElement(1, CaseVariantOfSeededPath(), GraphicsElementKind.Text, string.Empty);
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
List<GraphicsElementResponseModel> result =
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
result.Count.ShouldBe(1);
result[0].BuiltIn.ShouldBeFalse();
}
// #568: Kind is part of the built-in element's identity, not a second test the seeder applies
// and the API skips. GetBuiltInElementId refuses a wrong-kind row at the seeded path -- it has
// to, since EnsureBuiltInElementRow asks it whether the Text row it is about to create already
// exists -- so an API that reported the same row as builtIn:true would have the two sites
// disagreeing about one row. Reddens if the Kind conjunct is dropped from
// GraphicsElementDefaults.IsOnNowNext (row 18 of the mutation table in docs/graphics-elements.md).
[Test]
public async Task GetAllGraphicsElementsForApi_Should_Not_Mark_A_Wrong_Kind_Row_At_The_Seeded_Path_As_BuiltIn()
{
await SeedElement(
1,
GraphicsElementDefaults.OnNowNextSeededPath,
GraphicsElementKind.Image,
string.Empty);
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
List<GraphicsElementResponseModel> result =
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
result.Count.ShouldBe(1);
result[0].BuiltIn.ShouldBeFalse();
}
// The seeded path with only the FILENAME's case changed -- same folder, same spelling.
private static string CaseVariantOfSeededPath() =>
System.IO.Path.Combine(
System.IO.Path.GetDirectoryName(GraphicsElementDefaults.OnNowNextSeededPath)!,
GraphicsElementDefaults.OnNowNextFileName.ToUpperInvariant());
private async Task SeedElement(int id, string path, GraphicsElementKind kind, string name)
{
await using TvContext context = _db.CreateContext();
@@ -4,6 +4,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
@@ -121,13 +122,200 @@ public class PlayoutHandlerTests
LeftOf(result).Value.ShouldContain("must not be empty");
}
// ---- #880 empty recurrence sets ----
[Test]
public async Task ReplaceAlternateSchedules_Should_Reject_A_Newly_Empty_Recurrence_On_A_Stored_Item()
{
await SeedPlayout(1, version: 1);
var handler = new ReplacePlayoutAlternateScheduleItemsHandler(
_db.Factory,
_worker,
NullLogger<ReplacePlayoutAlternateScheduleItemsHandler>.Instance);
// TWO items: index 1 is the catch-all (highest index), so index 0 is a real alternate whose
// recurrence IS stored. The empty set goes on THAT one.
ReplacePlayoutAlternateSchedule empty = AltItem(index: 0) with { DaysOfWeek = [] };
Either<BaseError, Unit> result = await handler.Handle(
new ReplacePlayoutAlternateScheduleItems(1, [empty, AltItem(index: 1)]),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("[DaysOfWeek]");
LeftOf(result).Value.ShouldContain("no day of the week");
// rejected BEFORE any mutation -- the version bump is the observable proof nothing was written
(await ReadPlayoutVersion(1)).ShouldBe(1);
}
// The catch-all's recurrence is discarded by the handler (only its ProgramScheduleId is used), so an
// empty set there cannot make anything "never apply". Rejecting it would state a reason that is FALSE
// for that item, which is why the check walks `incoming` rather than every submitted item.
[Test]
public async Task ReplaceAlternateSchedules_Should_Allow_An_Empty_Recurrence_On_The_CatchAll_Item()
{
await SeedPlayout(1, version: 1);
var handler = new ReplacePlayoutAlternateScheduleItemsHandler(
_db.Factory,
_worker,
NullLogger<ReplacePlayoutAlternateScheduleItemsHandler>.Instance);
// a single item IS the catch-all
ReplacePlayoutAlternateSchedule catchAll = AltItem(index: 0) with { DaysOfWeek = [], MonthsOfYear = [] };
Either<BaseError, Unit> result = await handler.Handle(
new ReplacePlayoutAlternateScheduleItems(1, [catchAll]),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await ReadPlayoutVersion(1)).ShouldBe(2);
}
[Test]
public async Task ReplaceTemplates_Should_Reject_A_Newly_Empty_Recurrence()
{
await SeedPlayout(1, version: 1);
var handler = new ReplacePlayoutTemplateItemsHandler(
_db.Factory,
NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);
ReplacePlayoutTemplate empty = TemplateItem() with { DaysOfMonth = [] };
Option<BaseError> result = await handler.Handle(
new ReplacePlayoutTemplateItems(1, [empty]),
CancellationToken.None);
result.IfNone(() => throw new AssertionException("Expected a Some(error)"))
.Value.ShouldContain("[DaysOfMonth]");
(await ReadPlayoutVersion(1)).ShouldBe(1);
}
// `api.ffmpeg-profile-numeric-bounds`: reject a NEWLY submitted bad value, not an UNCHANGED one the row
// already holds. Both PUT paths are whole-list replaces, so without this a single pre-existing empty row
// would make every OTHER item in the playout uneditable.
[Test]
public async Task ReplaceTemplates_Should_Allow_An_UNCHANGED_Empty_Recurrence_That_Is_Already_Stored()
{
int templateItemId = await SeedPlayoutWithEmptyTemplateRecurrence();
var handler = new ReplacePlayoutTemplateItemsHandler(
_db.Factory,
NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);
// same row, same empty DaysOfWeek -- an edit to some OTHER field on the same list
ReplacePlayoutTemplate unchanged = TemplateItem() with { Id = templateItemId, DaysOfWeek = [] };
Option<BaseError> result = await handler.Handle(
new ReplacePlayoutTemplateItems(1, [unchanged]),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
(await ReadPlayoutVersion(1)).ShouldBe(2);
}
// ... and the complement: the SAME stored row rejects a DIFFERENT field being newly emptied, so the
// exemption is per-field rather than "this row is grandfathered".
[Test]
public async Task ReplaceTemplates_Should_Still_Reject_A_Different_Field_Newly_Emptied_On_A_Stored_Row()
{
int templateItemId = await SeedPlayoutWithEmptyTemplateRecurrence();
var handler = new ReplacePlayoutTemplateItemsHandler(
_db.Factory,
NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);
// DaysOfWeek is the stored-empty one; MonthsOfYear is stored FULL, so emptying it is new
ReplacePlayoutTemplate item = TemplateItem() with
{
Id = templateItemId,
DaysOfWeek = [],
MonthsOfYear = []
};
Option<BaseError> result = await handler.Handle(
new ReplacePlayoutTemplateItems(1, [item]),
CancellationToken.None);
result.IfNone(() => throw new AssertionException("Expected a Some(error)"))
.Value.ShouldContain("[MonthsOfYear]");
(await ReadPlayoutVersion(1)).ShouldBe(1);
}
private async Task<int> SeedPlayoutWithEmptyTemplateRecurrence()
{
await using TvContext context = _db.CreateContext();
// The handler loads templates with `.Include(p => p.Templates).ThenInclude(t => t.Template)`, and
// that navigation is required -- so a PlayoutTemplate whose Template row does not exist is joined
// OUT and never reaches `existing`. Without seeding this, the stored row is invisible, the
// exemption cannot match, and the test fails for a reason that has nothing to do with the rule.
context.TemplateGroups.Add(new TemplateGroup { Id = 5, Name = "Group", Templates = [] });
context.Templates.Add(new Template { Id = 20, TemplateGroupId = 5, Name = "Template 20", Items = [] });
await context.SaveChangesAsync();
var template = new PlayoutTemplate
{
Index = 0,
TemplateId = 20,
DaysOfWeek = [],
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = false,
StartMonth = 1,
StartDay = 1,
EndMonth = 12,
EndDay = 31
};
context.Playouts.Add(
new Playout
{
Id = 1,
ChannelId = 1,
ProgramScheduleId = 10,
Version = 1,
Items = [],
ProgramScheduleAlternates = [],
Templates = [template]
});
await context.SaveChangesAsync();
return template.Id;
}
// ---- #253 optimistic concurrency (alternate schedules #7 + templates #8, shared Playout.Version) ----
private static ReplacePlayoutAlternateSchedule AltItem(int programScheduleId = 10) =>
new(0, 0, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
// Recurrence sets are UNRESTRICTED here, not empty (#880): an empty set now means "matches no date"
// and is rejected on any item whose recurrence is stored, so an empty fixture would make these
// concurrency tests measure the recurrence guard instead of the version check.
private static ReplacePlayoutAlternateSchedule AltItem(int programScheduleId = 10, int index = 0) =>
new(
0,
index,
programScheduleId,
AlternateScheduleSelector.AllDaysOfWeek(),
AlternateScheduleSelector.AllDaysOfMonth(),
AlternateScheduleSelector.AllMonthsOfYear(),
false,
1,
1,
null,
12,
31,
null);
private static ReplacePlayoutTemplate TemplateItem(int templateId = 20) =>
new(0, 0, templateId, null, [], [], [], false, 1, 1, null, 12, 31, null);
new(
0,
0,
templateId,
null,
AlternateScheduleSelector.AllDaysOfWeek(),
AlternateScheduleSelector.AllDaysOfMonth(),
AlternateScheduleSelector.AllMonthsOfYear(),
false,
1,
1,
null,
12,
31,
null);
private async Task SeedPlayout(int id, int version, int? programScheduleId = 10)
{
@@ -358,9 +358,9 @@ public class ScheduleItemResponseRoundTripTests
// that for this fixture's payload: it forwards every Id, takes the id-based reconcile, and
// updates rows in place. So Id compares equal, and the exemption was unnecessary.
//
// Two mutations of this fixture, both EXECUTED — recorded as results, with no account of why,
// because three earlier drafts of this comment each supplied a confident mechanism for a
// correct observation and two of them were contradicted by the code:
// Two mutations of this fixture, both EXECUTED — recorded as results, with no account of why:
// a confident mechanism for this observation is easy to get wrong, and two independent ones
// were each contradicted by the code:
//
// ToReplaceCommand passes `null` for EVERY id -> test stays GREEN
// ToReplaceCommand passes `null` for index 0 only -> test goes RED, "Id differs"
@@ -0,0 +1,416 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Scheduling;
/// <summary>
/// #568: the same full-replace-DTO FK hardening applied to UpdateChannelHandler's
/// graphicsElementIds also closes the identical twin defect in UpdateDecoHandler -- both
/// graphicsElementIds and watermarkIds are top-level ReplaceDecoRequest fields (not the "deep FK
/// ids nested inside item-list request bodies" carve-out in api-conventions.md), and the
/// reconcile in ApplyUpdateRequest blindly Adds a join row for every incoming id, so an unknown
/// id used to hit the FK constraint at SaveChangesAsync and surface as an unhandled 500.
/// </summary>
[TestFixture]
public class UpdateDecoGraphicsElementsTests
{
private InMemoryTvContext _db = null!;
private ChannelWriter<IBackgroundServiceRequest> _channel = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_channel = Substitute.For<ChannelWriter<IBackgroundServiceRequest>>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private static bool IsLeft<T>(Either<BaseError, T> result) => result.Match(Right: _ => false, Left: _ => true);
private async Task SeedDeco()
{
await using TvContext context = _db.CreateContext();
context.Decos.Add(
new Deco
{
Id = 1,
DecoGroupId = 1,
Name = "D",
BreakContent = [],
DecoWatermarks = [],
DecoGraphicsElements = []
});
await context.SaveChangesAsync();
}
private static UpdateDeco MakeUpdate(
List<int> graphicsElementIds = null,
List<int> watermarkIds = null,
DecoMode? graphicsElementsMode = null,
DecoMode? watermarkMode = null) =>
new(
1,
1,
"D",
watermarkMode ?? DecoMode.Inherit,
watermarkIds ?? [],
false,
graphicsElementsMode ?? (graphicsElementIds is null ? DecoMode.Inherit : DecoMode.Override),
graphicsElementIds ?? [],
false,
DecoMode.Inherit,
[],
DecoMode.Inherit,
CollectionType.Collection,
null,
null,
null,
null,
false,
DecoMode.Inherit,
CollectionType.Collection,
null,
null,
null,
null);
private async Task<int> SeedGraphicsElement()
{
await using TvContext context = _db.CreateContext();
var element = new GraphicsElement { Path = "element-a.yml" };
context.GraphicsElements.Add(element);
await context.SaveChangesAsync();
return element.Id;
}
private async Task<List<int>> SeedGraphicsElements(int count)
{
await using TvContext context = _db.CreateContext();
List<GraphicsElement> elements = Enumerable.Range(0, count)
.Select(i => new GraphicsElement { Path = $"element-{i}.yml" })
.ToList();
context.GraphicsElements.AddRange(elements);
await context.SaveChangesAsync();
return elements.Select(e => e.Id).ToList();
}
private async Task<List<int>> SeedWatermarks(int count)
{
await using TvContext context = _db.CreateContext();
List<ChannelWatermark> watermarks = Enumerable.Range(0, count)
.Select(i => new ChannelWatermark { Name = $"W{i}" })
.ToList();
context.ChannelWatermarks.AddRange(watermarks);
await context.SaveChangesAsync();
return watermarks.Select(w => w.Id).ToList();
}
private async Task<int> SeedWatermark()
{
await using TvContext context = _db.CreateContext();
var watermark = new ChannelWatermark { Name = "W" };
context.ChannelWatermarks.Add(watermark);
await context.SaveChangesAsync();
return watermark.Id;
}
private async Task AttachWatermark(int watermarkId)
{
await using TvContext context = _db.CreateContext();
Deco deco = await context.Decos.Include(d => d.DecoWatermarks).SingleAsync(d => d.Id == 1);
deco.WatermarkMode = DecoMode.Override;
deco.DecoWatermarks.Add(new DecoWatermark { DecoId = 1, WatermarkId = watermarkId });
await context.SaveChangesAsync();
}
private async Task AttachGraphicsElement(int elementId)
{
await using TvContext context = _db.CreateContext();
Deco deco = await context.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
deco.GraphicsElementsMode = DecoMode.Override;
deco.DecoGraphicsElements.Add(new DecoGraphicsElement { DecoId = 1, GraphicsElementId = elementId });
await context.SaveChangesAsync();
}
// Removing UpdateDecoHandler.GraphicsElementIdsMustExist alone from Validate is row 36 of the
// mutation table in docs/graphics-elements.md, measured against the whole ErsatzTV.Tests project.
[Test]
public async Task Should_Reject_Unknown_GraphicsElementId_With_A_Validation_Error_Not_A_Throw()
{
await SeedDeco();
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(graphicsElementIds: [999]),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain("999");
// no partial write: the deco keeps no graphics element association
await using TvContext context = _db.CreateContext();
Deco reloaded = await context.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
reloaded.DecoGraphicsElements.ShouldBeEmpty();
}
// Removing UpdateDecoHandler.WatermarkIdsMustExist alone from Validate is row 37 of the
// mutation table in docs/graphics-elements.md.
[Test]
public async Task Should_Reject_Unknown_WatermarkId_With_A_Validation_Error_Not_A_Throw()
{
await SeedDeco();
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
new UpdateDeco(
1,
1,
"D",
DecoMode.Override,
[999],
false,
DecoMode.Inherit,
[],
false,
DecoMode.Inherit,
[],
DecoMode.Inherit,
CollectionType.Collection,
null,
null,
null,
null,
false,
DecoMode.Inherit,
CollectionType.Collection,
null,
null,
null,
null),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[WatermarkIds]");
error.Value.ShouldContain("999");
}
// The mode, not the id list, decides whether an id is data. ApplyUpdateRequest reconciles the
// join table only under Override/Merge and Clear()s it otherwise, so validating unconditionally
// would reject a save the apply path was going to discard. Removing the ConsumesGraphicsElementIds
// guard alone from UpdateDecoHandler.GraphicsElementIdsMustExist is row 38 of the mutation table
// in docs/graphics-elements.md.
[Test]
public async Task Should_Ignore_An_Unknown_GraphicsElementId_When_The_Mode_Does_Not_Consume_It()
{
await SeedDeco();
int elementId = await SeedGraphicsElement();
await AttachGraphicsElement(elementId);
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(graphicsElementsMode: DecoMode.Inherit, graphicsElementIds: [999]),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
// the apply path discards the ids under Inherit, and the existing attachment with them
await using TvContext reload = _db.CreateContext();
Deco reloaded = await reload.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
reloaded.GraphicsElementsMode.ShouldBe(DecoMode.Inherit);
reloaded.DecoGraphicsElements.ShouldBeEmpty();
}
// Twin of the above for the watermark half; removing the ConsumesWatermarkIds guard alone from
// UpdateDecoHandler.WatermarkIdsMustExist is row 39 of the mutation table in
// docs/graphics-elements.md.
[Test]
public async Task Should_Ignore_An_Unknown_WatermarkId_When_The_Mode_Does_Not_Consume_It()
{
await SeedDeco();
int watermarkId = await SeedWatermark();
await AttachWatermark(watermarkId);
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(watermarkMode: DecoMode.Disable, watermarkIds: [999]),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext reload = _db.CreateContext();
Deco reloaded = await reload.Decos.Include(d => d.DecoWatermarks).SingleAsync(d => d.Id == 1);
reloaded.WatermarkMode.ShouldBe(DecoMode.Disable);
reloaded.DecoWatermarks.ShouldBeEmpty();
}
[Test]
public async Task Should_Accept_A_Known_GraphicsElementId()
{
await SeedDeco();
int elementId = await SeedGraphicsElement();
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(graphicsElementIds: [elementId]),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext reload = _db.CreateContext();
Deco reloaded = await reload.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
reloaded.DecoGraphicsElements.Select(x => x.GraphicsElementId).ShouldBe(new[] { elementId });
}
// Both deco id lists go through the same Validators.IdsMustExist as the channel's, so both
// inherit the same raw-count cap; the channel fixture pins its edges, these two pin that each
// deco field is actually behind it and names itself when it rejects.
[Test]
public async Task Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds()
{
await SeedDeco();
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(graphicsElementIds: Enumerable.Range(1, Validators.MaximumIdListCount + 1).ToList()),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
}
[Test]
public async Task Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds()
{
await SeedDeco();
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(
watermarkMode: DecoMode.Override,
watermarkIds: Enumerable.Range(1, Validators.MaximumIdListCount + 1).ToList()),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[WatermarkIds]");
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
}
// The mode gate is handed to Validators.IdsMustExist rather than short-circuiting the call,
// because only the EXISTENCE half is the apply path's business: a list the reconcile discards
// was still parsed and materialized out of the request body. These two pin that the cap holds
// under a mode that consumes nothing -- row 47 of the mutation table in
// docs/graphics-elements.md. Note the ids all EXIST here, so nothing but the cap can reject
// them: a rejection is the cap's, not a smuggled existence check.
[Test]
public async Task Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them()
{
await SeedDeco();
List<int> ids = await SeedGraphicsElements(Validators.MaximumIdListCount + 1);
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(graphicsElementsMode: DecoMode.Inherit, graphicsElementIds: ids),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
}
[Test]
public async Task Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them()
{
await SeedDeco();
List<int> ids = await SeedWatermarks(Validators.MaximumIdListCount + 1);
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(watermarkMode: DecoMode.Disable, watermarkIds: ids),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[WatermarkIds]");
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
}
// The deco twin of the channel handler's lost-race translation: an element deleted between
// Validate and SaveChangesAsync must come back as the validator's own 422, not the FK
// exception. Removing the DbUpdateException catch from
// UpdateDecoHandler.ApplyUpdateRequestTranslatingLostRace is row 44 of the mutation table in
// docs/graphics-elements.md.
[Test]
public async Task Should_Translate_A_Deco_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422()
{
var interceptor = new ArmedSaveFailureInterceptor();
await _db.DisposeAsync();
_db = await InMemoryTvContext.CreateAsync(interceptor);
await SeedDeco();
int elementId = await SeedGraphicsElement();
interceptor.SqlBeforeFailing =
$"DELETE FROM GraphicsElement WHERE Id = {elementId.ToString(CultureInfo.InvariantCulture)}";
interceptor.Armed = true;
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(graphicsElementIds: [elementId]),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain(elementId.ToString(CultureInfo.InvariantCulture));
}
// The watermark half of the same recovery. Without it, removing the watermark question from the
// recheck would redden nothing -- and the recheck re-asks the whole of Validate exactly so that
// neither id list is the only one covered. Row 46 of the mutation table.
[Test]
public async Task Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422()
{
var interceptor = new ArmedSaveFailureInterceptor();
await _db.DisposeAsync();
_db = await InMemoryTvContext.CreateAsync(interceptor);
await SeedDeco();
int watermarkId = await SeedWatermark();
interceptor.SqlBeforeFailing =
$"DELETE FROM ChannelWatermark WHERE Id = {watermarkId.ToString(CultureInfo.InvariantCulture)}";
interceptor.Armed = true;
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(watermarkMode: DecoMode.Override, watermarkIds: [watermarkId]),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[WatermarkIds]");
error.Value.ShouldContain(watermarkId.ToString(CultureInfo.InvariantCulture));
}
}
@@ -372,7 +372,7 @@ public class GetSearchFieldValuesHandlerTests
[Test]
public async Task List_Valued_Walk_Reads_Live_Rows_Regardless_Of_Id_Density()
{
// THE round-4 killer. That revision bounded the Id KEYSPACE, and keyspace is not rows: with 20,000
// Bounding the Id KEYSPACE is the killer here: keyspace is not rows — with 20,000
// historical rows deleted and one live song at Id 20001, the walk spent its whole allowance on empty
// ranges and returned [] for a table containing exactly one row. Capacity degraded linearly with
// deletion ratio, and no ratio was safe -- one placed gap hid the next match.
@@ -0,0 +1,52 @@
{
"description": "Committed stand-in for a user-authored scripted schedule: the ordered sequence of calls such a script makes against /api/v1/scripted/playout/build/{buildId}, replayed through the real ScriptedScheduleController by ScriptedScheduleControllerTests. Field names and casing match what the HTTP body binder accepts \u2014 the replay deserializes each body with ApiJsonSettings, the configuration Startup applies to AddNewtonsoftJson. Deliberately avoids wait_until / pad_until / pad_to_next (local day and time-of-day) and shuffle order, which would make the pinned snapshot machine-timezone- or seed-dependent. add_duration and pad_until_exact both end BETWEEN two content boundaries on purpose: an instruction that happens to end on one never reaches the engine's trim branch, and its trim flag is then witnessed by nothing.",
"script": [
{
"action": "add_collection",
"body": {
"key": "content",
"collection": "Test Collection",
"order": "chronological"
}
},
{
"action": "start_epg_group",
"body": {
"advance": true,
"customTitle": "Morning Block"
}
},
{
"action": "add_count",
"body": {
"content": "content",
"count": 2
}
},
{
"action": "stop_epg_group"
},
{
"action": "add_duration",
"body": {
"content": "content",
"duration": "2:00:00",
"trim": true
}
},
{
"action": "pad_until_exact",
"body": {
"content": "content",
"when": "2026-01-15T09:55:00+00:00",
"trim": true
}
},
{
"action": "add_all",
"body": {
"content": "content"
}
}
]
}
@@ -24,14 +24,13 @@ namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class OpenApiSerializerContractTests
{
// Mirrors Startup.ConfigureServices -> AddNewtonsoftJson exactly.
private static readonly JsonSerializerSettings RuntimeSettings = new()
{
NullValueHandling = NullValueHandling.Ignore,
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
ContractResolver = new CustomContractResolver(),
Converters = { new StringEnumConverter() }
};
// The configuration Startup.ConfigureServices -> AddNewtonsoftJson applies, from the same function
// rather than a hand-copy of it. This fixture is the WRITE-side witness for the naming strategy: the
// four cases below assert camelCase keys, which CustomContractResolver produces and a bare
// JsonSerializerSettings does not. It says nothing about NullValueHandling (every DTO member below is
// populated, so nothing is dropped either way) or the StringEnumConverter (it compares key NAMES, not
// values). docs/testing.md -> "Scripted playout coverage" tabulates which suite witnesses which half.
private static readonly JsonSerializerSettings RuntimeSettings = ApiJsonSettings.Create();
private static IEnumerable<TestCaseData> Cases()
{
@@ -978,7 +978,7 @@ public class PlayoutControllerTests
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
var request = new ReplacePlayoutAlternateSchedulesRequest(
[new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]);
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, true, startMonth, startDay, null, endMonth, endDay, null)]);
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
@@ -1002,7 +1002,7 @@ public class PlayoutControllerTests
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
var request = new ReplacePlayoutAlternateSchedulesRequest(
[new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]);
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, true, startMonth, startDay, null, endMonth, endDay, null)]);
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
@@ -1025,7 +1025,7 @@ public class PlayoutControllerTests
// LimitToDateRange is false, so the out-of-range month/day here must not block the save.
var request = new ReplacePlayoutAlternateSchedulesRequest(
[new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], false, 0, 0, null, 13, 32, null)]);
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, false, 0, 0, null, 13, 32, null)]);
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
@@ -1079,6 +1079,110 @@ public class PlayoutControllerTests
Arg.Any<CancellationToken>());
}
// ----- #880: absent recurrence means UNRESTRICTED, an explicit [] is rejected -----
// Reddens if ToReplaceItem's `?? All*()` is reverted to `?? []`: the counts drop to 0. That is the
// point of the test -- the normalization is the fix, so it is what must be pinned.
[Test]
public async Task ReplaceAlternateSchedules_Should_Normalize_Absent_Recurrence_To_Unrestricted()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
_mediator.Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>())
.Returns([MakeScheduleVm(7)]);
_mediator.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>())
.Returns([MakeAltVm(1, 0, 7)]);
// All three recurrence arrays omitted -- the shape an API client sends and the SPA never does.
var request = new ReplacePlayoutAlternateSchedulesRequest(
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, false, 1, 1, null, 12, 31, null)]);
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
await _mediator.Received(1).Send(
Arg.Is<ReplacePlayoutAlternateScheduleItems>(c =>
c.Items.Count == 1 &&
c.Items[0].DaysOfWeek.Count == 7 &&
c.Items[0].DaysOfMonth.Count == 31 &&
c.Items[0].MonthsOfYear.Count == 12),
Arg.Any<CancellationToken>());
}
// The complement of the test above: normalization must fill in ONLY what was absent. Without this a
// fix that substituted All*() unconditionally would still pass the normalization test.
[Test]
public async Task ReplaceAlternateSchedules_Should_Preserve_An_Explicit_Recurrence_Selection()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
_mediator.Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>())
.Returns([MakeScheduleVm(7)]);
_mediator.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>())
.Returns([MakeAltVm(1, 0, 7)]);
var request = new ReplacePlayoutAlternateSchedulesRequest(
[
new PlayoutAlternateScheduleItemRequest(
0,
7,
[DayOfWeek.Monday, DayOfWeek.Tuesday],
null,
[6],
false,
1,
1,
null,
12,
31,
null)
]);
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
await _mediator.Received(1).Send(
Arg.Is<ReplacePlayoutAlternateScheduleItems>(c =>
c.Items[0].DaysOfWeek.Count == 2 &&
c.Items[0].DaysOfWeek.Contains(DayOfWeek.Monday) &&
c.Items[0].DaysOfMonth.Count == 31 &&
c.Items[0].MonthsOfYear.Count == 1 &&
c.Items[0].MonthsOfYear.Contains(6)),
Arg.Any<CancellationToken>());
}
[Test]
public async Task ReplaceTemplates_Should_Normalize_Absent_Recurrence_To_Unrestricted()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(
Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
_mediator.Send(Arg.Any<GetAllTemplates>(), Arg.Any<CancellationToken>())
.Returns([MakeTemplateViewModel(7)]);
_mediator.Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.None);
_mediator.Send(Arg.Any<GetPlayoutTemplates>(), Arg.Any<CancellationToken>())
.Returns([MakeTemplateVm(1, 0, 7, null)]);
var request = new ReplacePlayoutTemplatesRequest(
[new PlayoutTemplateItemRequest(0, 7, null, null, null, null, false, 1, 1, null, 12, 31, null)]);
IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
await _mediator.Received(1).Send(
Arg.Is<ReplacePlayoutTemplateItems>(c =>
c.Items.Count == 1 &&
c.Items[0].DaysOfWeek.Count == 7 &&
c.Items[0].DaysOfMonth.Count == 31 &&
c.Items[0].MonthsOfYear.Count == 12),
Arg.Any<CancellationToken>());
}
// ----- Playout templates -----
[Test]
@@ -1187,7 +1291,7 @@ public class PlayoutControllerTests
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
var request = new ReplacePlayoutTemplatesRequest(
[new PlayoutTemplateItemRequest(0, 7, null, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]);
[new PlayoutTemplateItemRequest(0, 7, null, null, null, null, true, startMonth, startDay, null, endMonth, endDay, null)]);
IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None);
@@ -1212,7 +1316,7 @@ public class PlayoutControllerTests
// LimitToDateRange is false, so the out-of-range month/day here must not block the save.
var request = new ReplacePlayoutTemplatesRequest(
[new PlayoutTemplateItemRequest(0, 7, null, [], [], [], false, 0, 0, null, 13, 32, null)]);
[new PlayoutTemplateItemRequest(0, 7, null, null, null, null, false, 0, 0, null, 13, 32, null)]);
IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None);
@@ -1400,7 +1504,7 @@ public class PlayoutControllerTests
new(id, index, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
private static PlayoutAlternateScheduleItemRequest MakeAltRequest(int programScheduleId) =>
new(0, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
new(0, programScheduleId, null, null, null, false, 1, 1, null, 12, 31, null);
private static ProgramScheduleViewModel MakeScheduleVm(int id) =>
new(id, $"Schedule {id}", false, false, false, false, FixedStartTimeBehavior.Strict, null, 0);
@@ -1426,7 +1530,7 @@ public class PlayoutControllerTests
null);
private static PlayoutTemplateItemRequest MakeTemplateRequest(int templateId, int? decoTemplateId) =>
new(0, templateId, decoTemplateId, [], [], [], false, 1, 1, null, 12, 31, null);
new(0, templateId, decoTemplateId, null, null, null, false, 1, 1, null, 12, 31, null);
private static PlayoutNameViewModel MakePlayout(int id) =>
new(
@@ -0,0 +1,464 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.ScriptedPlayout;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling.Engine;
using ErsatzTV.Core.Scheduling.ScriptedScheduling;
using ErsatzTV.Serialization;
using LanguageExt;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
/// <summary>
/// In-process stand-in for a scripted playout build (ersatztv#563). A committed script fixture
/// (<c>Fixtures/scripted-build.json</c>) is replayed through the REAL
/// <see cref="ScriptedScheduleController" />, the REAL <see cref="ScriptedPlayoutBuilderService" /> and the
/// REAL <see cref="SchedulingEngine" />, and the resulting <c>PlayoutItem</c>s are compared to a pinned
/// snapshot in the same line format the playout goldens use. The engine is registered under a fixed build
/// id via <see cref="ScriptedPlayoutBuilderService.MockSession" />, the seam that exists for exactly this.
/// <para>
/// The hops this cannot host, named rather than implied: the <c>Cli.Wrap</c> launch of the user's
/// own program (exit code, timeout, stdout capture); the Kestrel/middleware/auth transport the
/// program calls back over; and MVC's binding <i>wrapper</i> — the input formatter's content-type
/// selection and malformed-body handling, model validation (a non-nullable reference type picks
/// up an implicit required check there), and the <c>[ApiController]</c> automatic 400 either
/// produces before an action runs, since every test here hands an action an already-bound object.
/// The serializer <i>inside</i> that wrapper is not residue: request bodies are deserialized with
/// <see cref="ApiJsonSettings" />, the same function <c>Startup</c> hands to <c>AddNewtonsoftJson</c>.
/// It is the production <i>configuration</i>, not the production <i>object</i> — MVC applies it to
/// settings it has already configured, and <c>ApiJsonSettings.Create()</c> starts from a bare one, so
/// MVC's stricter <c>MaxDepth</c> and its two <c>ProblemDetails</c> converters are missing here
/// (enumerated and pinned by <c>ApiJsonSettingsTests</c>; both inert for these DTOs, which nest two
/// levels and are never a <c>ProblemDetails</c>). Two tests hold the two ways that binder can be
/// replaced, since the fixture's own bodies parse identically under all of them:
/// <see cref="Production_Body_Binder_Ignores_Required_Members" /> against a System.Text.Json swap, and
/// <see cref="Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null" /> against a
/// plain Newtonsoft settings object. What no test here observes is <c>Startup</c>'s own
/// registration — <see cref="ApiJsonSettings" /> removes the duplicate rather than detecting
/// drift in one. The reasoning for the scope-out is in docs/testing.md → "Scripted playout
/// coverage" and the decision record <c>testing.scripted-engine-in-process-net</c>.
/// </para>
/// <para>
/// Deviation from the three golden-file nets (docs/contributing.md §10): the expected output is a
/// string constant in this file rather than a committed golden. The golden harness lives in
/// <c>ErsatzTV.Core.Tests</c>, which cannot reference a controller, and duplicating it here would
/// create a second action-to-engine mapping — the thing this design exists to avoid.
/// </para>
/// </summary>
[TestFixture]
public class ScriptedScheduleControllerTests
{
private const string ContentKey = "content";
private const string CollectionName = "Test Collection";
private static readonly Guid BuildId = Guid.Parse("00000000-0000-0000-0000-000000000563");
private static readonly DateTimeOffset Start = new(2026, 1, 15, 6, 0, 0, TimeSpan.Zero);
// What Startup hands to AddNewtonsoftJson, from the same function rather than a hand-copy of it.
// The two Production_Body_Binder_* tests below are what hold this to the production configuration.
private static readonly JsonSerializerSettings BodyBinderSettings = ApiJsonSettings.Create();
// A body that omits the `required` member "collection"; Newtonsoft and System.Text.Json disagree.
private const string BodyMissingRequiredMember = """{"key":"content","order":"chronological"}""";
// A body sending "order" as an explicit null. NullValueHandling.Ignore keeps the DTO's declared
// default; Newtonsoft's own default (Include) overwrites it with null.
private const string BodyWithExplicitNullOrder =
"""{"key":"content","collection":"Test Collection","order":null}""";
// Every action the fixture is expected to exercise. A fixture edit that drops one must fail loudly
// rather than quietly shrinking what the snapshot covers.
private static readonly string[] ExpectedActions =
[
"add_collection",
"start_epg_group",
"add_count",
"stop_epg_group",
"add_duration",
"pad_until_exact",
"add_all"
];
// Raw UTC Start/Finish (never the *Offset properties, which localize), FillerKind, and the item's
// MediaItemId rendered as "Movie NN" — a label built from the id, not a lookup of the seeded title.
//
// 004 and 006 are the two trimmed items, and they are the reason the fixture's numbers look arbitrary.
// Movie 05 (90m) starts at 08:35 inside a two-hour add_duration ending 09:15, and pad_until_exact
// targets 09:55, which Movie 01 (30m) would overshoot from 09:30. Land either target on a content
// boundary instead and the trim branch never runs, leaving that action's `trim` argument unwitnessed.
private const string ExpectedSnapshot =
"""
000 | 2026-01-15 06:00:00 - 2026-01-15 06:30:00 | None | Movie 01
001 | 2026-01-15 06:30:00 - 2026-01-15 07:15:00 | None | Movie 02
002 | 2026-01-15 07:15:00 - 2026-01-15 08:15:00 | None | Movie 03
003 | 2026-01-15 08:15:00 - 2026-01-15 08:35:00 | None | Movie 04
004 | 2026-01-15 08:35:00 - 2026-01-15 09:15:00 | None | Movie 05
005 | 2026-01-15 09:15:00 - 2026-01-15 09:30:00 | None | Movie 06
006 | 2026-01-15 09:30:00 - 2026-01-15 09:55:00 | None | Movie 01
007 | 2026-01-15 09:55:00 - 2026-01-15 10:40:00 | None | Movie 02
008 | 2026-01-15 10:40:00 - 2026-01-15 11:40:00 | None | Movie 03
009 | 2026-01-15 11:40:00 - 2026-01-15 12:00:00 | None | Movie 04
010 | 2026-01-15 12:00:00 - 2026-01-15 13:30:00 | None | Movie 05
011 | 2026-01-15 13:30:00 - 2026-01-15 13:45:00 | None | Movie 06
012 | 2026-01-15 13:45:00 - 2026-01-15 14:15:00 | None | Movie 01
""";
[Test]
public async Task Committed_Script_Fixture_Produces_The_Pinned_Snapshot()
{
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
List<string> replayed = await ReplayFixture(controller);
replayed.ShouldBe(ExpectedActions);
List<PlayoutItem> items = engine.GetState().AddedItems;
Snapshot(items).ShouldBe(Canonicalize(ExpectedSnapshot));
// invariants the snapshot alone does not state, so a regenerated snapshot cannot silently absorb them
items.Count.ShouldBe(13);
for (var i = 1; i < items.Count; i++)
{
items[i].Start.ShouldBe(items[i - 1].Finish);
}
// start_epg_group ... stop_epg_group holds one guide group across its items and titles them
items[0].GuideGroup.ShouldBe(items[1].GuideGroup);
items[2].GuideGroup.ShouldNotBe(items[0].GuideGroup);
items[0].CustomTitle.ShouldBe("Morning Block");
items[1].CustomTitle.ShouldBe("Morning Block");
items[2].CustomTitle.ShouldBeNull();
// after stop_epg_group every item opens its own guide group again
items.Skip(2).Select(i => i.GuideGroup).ShouldBe(Enumerable.Range(2, 11));
// pad_until_exact lands on the requested instant, whatever the machine offset is, and gets there by
// TRIMMING: Movie 01 is 30 minutes and starts at 09:30, so the 25-minute item below is the fixture's
// `"trim": true` reaching the engine. Passing `false` for that argument drops the item entirely.
items[6].Finish.ShouldBe(new DateTime(2026, 1, 15, 9, 55, 0, DateTimeKind.Utc));
(items[6].Finish - items[6].Start).ShouldBe(TimeSpan.FromMinutes(25));
items[6].OutPoint.ShouldBe(TimeSpan.FromMinutes(25));
}
[Test]
public void Production_Body_Binder_Ignores_Required_Members()
{
// The replay above is only faithful while Bind uses MVC's own serializer, and "the two agree" is
// not a safe assumption to leave unstated: Newtonsoft has no notion of the C# `required` keyword,
// so a body omitting one deserializes to a default, where System.Text.Json rejects it outright.
// The claim is about the SERIALIZER only — what MVC's validation layer then does with such a
// body (non-nullable reference types pick up an implicit required check) is the uncovered
// wrapper, not this. Binding through Bind itself is what gives this teeth: the fixture's own
// bodies parse identically under either serializer, so only a body like this one separates them.
// It separates THAT pair only — a Newtonsoft settings object that has merely lost the production
// configuration reads this body exactly as production does, which is the sibling test's subject.
using JsonDocument document = JsonDocument.Parse(BodyMissingRequiredMember);
ContentCollection bound = Bind<ContentCollection>(document.RootElement).ShouldNotBeNull();
bound.Key.ShouldBe(ContentKey);
bound.Collection.ShouldBeNull();
// the negative control: the same body under the serializer a swap would reach for
Should.Throw<System.Text.Json.JsonException>(
() => System.Text.Json.JsonSerializer.Deserialize<ContentCollection>(
BodyMissingRequiredMember,
new JsonSerializerOptions(JsonSerializerDefaults.Web)));
}
[Test]
public async Task Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null()
{
// The other half of the binder-fidelity claim, and the half that a NEWTONSOFT lookalike witnesses:
// production sets NullValueHandling.Ignore, so an explicit `"order": null` leaves ContentCollection
// at its declared "shuffle" rather than overwriting it. Newtonsoft's own default is Include, which
// writes the null through — and AddCollection's Enum.TryParse then rejects it as a 400. So this is
// a behaviour difference a script would see, not a settings-shape assertion — which is what makes
// the "shuffle" and the OkResult below assertions about the production configuration itself.
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
using JsonDocument document = JsonDocument.Parse(BodyWithExplicitNullOrder);
ContentCollection bound = Bind<ContentCollection>(document.RootElement).ShouldNotBeNull();
bound.Order.ShouldBe("shuffle");
IActionResult result = await controller.AddCollection(BuildId, bound, CancellationToken.None);
result.ShouldBeOfType<OkResult>();
// and the content is usable, i.e. the surviving default really was a valid playback order
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeTrue();
}
[Test]
public async Task Unknown_Build_Id_Returns_404()
{
(ScriptedScheduleController controller, SchedulingEngine _) = NewSession();
var other = Guid.Parse("00000000-0000-0000-0000-000000000999");
controller.GetContext(other).Result.ShouldBeOfType<NotFoundObjectResult>();
IActionResult collection = await controller.AddCollection(
other,
new ContentCollection { Key = ContentKey, Collection = CollectionName, Order = "chronological" },
CancellationToken.None);
collection.ShouldBeOfType<NotFoundObjectResult>();
ActionResult<PlayoutContext> count = controller.AddCount(
other,
new PlayoutCount { Content = ContentKey, Count = 1 });
count.Result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Invalid_Playback_Order_Returns_400()
{
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
IActionResult result = await controller.AddCollection(
BuildId,
new ContentCollection { Key = ContentKey, Collection = CollectionName, Order = "nonsense" },
CancellationToken.None);
result.ShouldBeOfType<BadRequestObjectResult>();
// the 400 short-circuits before the engine sees the content
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeFalse();
engine.GetState().AddedItems.ShouldBeEmpty();
}
[Test]
public async Task Unknown_Filler_Kind_Falls_Back_To_None()
{
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
(await controller.AddCollection(
BuildId,
new ContentCollection { Key = ContentKey, Collection = CollectionName, Order = "chronological" },
CancellationToken.None)).ShouldBeOfType<OkResult>();
// an unparseable filler_kind is NOT rejected the way an unparseable order is; it degrades to None
ActionResult<PlayoutContext> result = controller.AddCount(
BuildId,
new PlayoutCount { Content = ContentKey, Count = 1, FillerKind = "not-a-filler-kind" });
result.Result.ShouldBeOfType<OkObjectResult>();
engine.GetState().AddedItems.Single().FillerKind.ShouldBe(FillerKind.None);
}
[Test]
public async Task Known_Filler_Kind_Reaches_The_Item()
{
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
(await controller.AddCollection(
BuildId,
new ContentCollection { Key = ContentKey, Collection = CollectionName, Order = "chronological" },
CancellationToken.None)).ShouldBeOfType<OkResult>();
controller.AddCount(
BuildId,
new PlayoutCount { Content = ContentKey, Count = 1, FillerKind = "PreRoll" })
.Result.ShouldBeOfType<OkObjectResult>();
engine.GetState().AddedItems.Single().FillerKind.ShouldBe(FillerKind.PreRoll);
}
[Test]
public void No_Progress_Throw_Is_Mapped_To_400()
{
(ScriptedScheduleController controller, SchedulingEngine _) = NewSession();
// GetContext reads IsDone, which halts a script that stops advancing time. The engine throws;
// the controller must translate that into a 400 rather than letting it escape as a 500.
for (var i = 0; i < 20; i++)
{
controller.GetContext(BuildId).Result.ShouldBeOfType<OkObjectResult>();
}
controller.GetContext(BuildId).Result.ShouldBeOfType<BadRequestObjectResult>();
}
[Test]
public void Context_Reports_Start_Finish_And_Current_Time()
{
(ScriptedScheduleController controller, SchedulingEngine _) = NewSession();
var ok = controller.GetContext(BuildId).Result.ShouldBeOfType<OkObjectResult>();
var context = ok.Value.ShouldBeOfType<PlayoutContext>();
context.StartTime.ToUniversalTime().ShouldBe(Start);
context.FinishTime.ToUniversalTime().ShouldBe(Start.AddHours(12));
context.CurrentTime.ToUniversalTime().ShouldBe(Start);
context.IsDone.ShouldBeFalse();
}
private static (ScriptedScheduleController Controller, SchedulingEngine Engine) NewSession()
{
var repository = Substitute.For<IMediaCollectionRepository>();
repository.GetCollectionItemsByName(CollectionName, Arg.Any<CancellationToken>())
.Returns(_ => TestCollection());
var engine = new SchedulingEngine(
repository,
Substitute.For<IGraphicsElementRepository>(),
Substitute.For<IChannelRepository>(),
Substitute.For<ILogger<SchedulingEngine>>());
// same setup order ScriptedPlayoutBuilder uses; WithReferenceData must precede RestoreOrReset
engine.WithPlayoutId(1)
.WithMode(PlayoutBuildMode.Reset)
.WithSeed(0)
.BuildBetween(Start, Start.AddHours(12))
.WithReferenceData(new PlayoutReferenceData(null, Option<Deco>.None, [], [], null, [], [], TimeSpan.Zero))
.RestoreOrReset(Option<PlayoutAnchor>.None);
// a fresh service per test, so no session leaks between fixtures
var service = new ScriptedPlayoutBuilderService();
service.MockSession(engine, BuildId).ShouldBeTrue();
return (new ScriptedScheduleController(service), engine);
}
// Deserializes each fixture entry into the controller's own request DTO and invokes the matching action,
// asserting a 200 each time. Returns the actions actually replayed, so the caller can assert coverage.
private static async Task<List<string>> ReplayFixture(ScriptedScheduleController controller)
{
string json = await File.ReadAllTextAsync(Path.Combine(FixtureDir(), "scripted-build.json"));
using JsonDocument document = JsonDocument.Parse(json);
var replayed = new List<string>();
foreach (JsonElement entry in document.RootElement.GetProperty("script").EnumerateArray())
{
string action = entry.GetProperty("action").GetString().ShouldNotBeNull();
JsonElement body = entry.TryGetProperty("body", out JsonElement maybeBody) ? maybeBody : default;
switch (action)
{
case "add_collection":
ShouldBeOk(
await controller.AddCollection(
BuildId,
Bind<ContentCollection>(body),
CancellationToken.None));
break;
case "start_epg_group":
ShouldBeOk(controller.StartEpgGroup(BuildId, Bind<ControlStartEpgGroup>(body)));
break;
case "stop_epg_group":
ShouldBeOk(controller.StopEpgGroup(BuildId));
break;
case "add_count":
ShouldBeOkContext(controller.AddCount(BuildId, Bind<PlayoutCount>(body)));
break;
case "add_all":
ShouldBeOkContext(controller.AddAll(BuildId, Bind<ContentAll>(body)));
break;
case "add_duration":
ShouldBeOkContext(controller.AddDuration(BuildId, Bind<PlayoutDuration>(body)));
break;
case "pad_until_exact":
ShouldBeOkContext(controller.PadUntilExact(BuildId, Bind<PlayoutPadUntilExact>(body)));
break;
default:
Assert.Fail($"Fixture uses action '{action}', which the replayer does not implement.");
break;
}
replayed.Add(action);
}
return replayed;
}
// Deserializes a fixture body with the configuration the production body binder runs
// (Startup -> AddNewtonsoftJson -> ApiJsonSettings), so the fixture's field names and casing are
// asserted against that serializer rather than a convenient one. Not the identical settings OBJECT:
// MVC applies the configuration to its own pre-configured settings, so its stricter MaxDepth and its
// two ProblemDetails converters are absent from Create() — ApiJsonSettingsTests enumerates the gap and
// shows why neither reaches a request body. The two Production_Body_Binder_* tests, not this helper,
// are what make a replacement of the configuration visible.
private static T Bind<T>(JsonElement body) where T : class
{
body.ValueKind.ShouldBe(JsonValueKind.Object);
return JsonConvert.DeserializeObject<T>(body.GetRawText(), BodyBinderSettings).ShouldNotBeNull();
}
private static void ShouldBeOk(IActionResult result) => result.ShouldBeOfType<OkResult>();
private static void ShouldBeOkContext(ActionResult<PlayoutContext> result)
{
var ok = result.Result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBeOfType<PlayoutContext>();
}
// Same line format as PlayoutBuildGoldenTests.Snapshot: ordered by Start then MediaItemId, raw UTC.
private static string Snapshot(List<PlayoutItem> items)
{
List<PlayoutItem> ordered = items
.OrderBy(i => i.Start)
.ThenBy(i => i.MediaItemId)
.ToList();
var sb = new StringBuilder();
for (var index = 0; index < ordered.Count; index++)
{
PlayoutItem item = ordered[index];
sb.Append(index.ToString("D3", CultureInfo.InvariantCulture));
sb.Append(" | ");
sb.Append(item.Start.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
sb.Append(" - ");
sb.Append(item.Finish.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
sb.Append(" | ");
sb.Append(item.FillerKind.ToString());
sb.Append(" | ");
sb.Append($"Movie {item.MediaItemId:D2}");
sb.Append('\n');
}
return sb.ToString();
}
private static string Canonicalize(string text) => text.ReplaceLineEndings("\n").TrimEnd('\n') + "\n";
private static string FixtureDir([CallerFilePath] string thisFile = "") =>
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Fixtures");
// Distinct release dates make chronological order deterministic (id order); distinct durations make
// every boundary in the snapshot unambiguous.
private static List<MediaItem> TestCollection() =>
[
FakeMovie(1, 30),
FakeMovie(2, 45),
FakeMovie(3, 60),
FakeMovie(4, 20),
FakeMovie(5, 90),
FakeMovie(6, 15)
];
private static Movie FakeMovie(int id, int minutes) =>
new()
{
Id = id,
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(minutes) }],
MovieMetadata =
[
new MovieMetadata
{
Title = $"Movie {id:D2}",
ReleaseDate = new DateTime(2005, 1, 1).AddDays(id)
}
]
};
}
@@ -35,7 +35,7 @@ public class GraphicsElementDefaultAttachTests
{
var element = new GraphicsElement
{
Path = $"/templates/text/{GraphicsElementDefaults.OnNowNextFileName}",
Path = GraphicsElementDefaults.OnNowNextSeededPath,
Name = "On Now / Next",
Kind = GraphicsElementKind.Text
};
@@ -153,17 +153,36 @@ public class GraphicsElementDefaultAttachTests
(await AttachedElementIds(context, channel.Id)).Count.ShouldBe(1);
}
// Filename alone is ambiguous: the five template folders are separate namespaces, so an element
// of another kind may legitimately carry the same filename.
[Test]
public async Task Ignores_A_Same_Named_Element_Of_A_Different_Kind()
public async Task Ignores_A_Non_Built_In_Element_With_A_Different_Filename()
{
await using TvContext context = _db.CreateContext();
await context.GraphicsElements.AddAsync(
new GraphicsElement { Path = "/templates/text/something-else.yml", Kind = GraphicsElementKind.Text });
await context.SaveChangesAsync();
Channel channel = await SeedChannel(context, "1");
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
(await AttachedElementIds(context, channel.Id)).ShouldBeEmpty();
}
// #568: filename-only matching was folder-agnostic -- a user Text element named exactly
// "on-now-next.yml" outside the seeded text-template folder used to also count as built-in.
// Same Kind as the real seeded row, deliberately a different folder, so this only reddens if
// GetBuiltInElementId goes back to comparing Path.GetFileName(...) instead of the full path.
[Test]
public async Task Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder()
{
await using TvContext context = _db.CreateContext();
await context.GraphicsElements.AddAsync(
new GraphicsElement
{
Path = $"/templates/image/{GraphicsElementDefaults.OnNowNextFileName}",
Kind = GraphicsElementKind.Image
Path = System.IO.Path.Combine(
"/config/graphics-elements/text/some-subfolder",
GraphicsElementDefaults.OnNowNextFileName),
Kind = GraphicsElementKind.Text
});
await context.SaveChangesAsync();
@@ -174,12 +193,23 @@ public class GraphicsElementDefaultAttachTests
(await AttachedElementIds(context, channel.Id)).ShouldBeEmpty();
}
// #568: GetBuiltInElementId resolves through GraphicsElementDefaults.IsOnNowNext, an ORDINAL
// comparison, so a Text row in the seeded folder whose filename differs only in case is a
// different element. Reddens if that comparison is loosened to OrdinalIgnoreCase. It does NOT
// pin provider independence -- under SQLite's BINARY collation an equivalent SQL `Where` answers
// identically; that is held by keeping the comparison in memory (see IsOnNowNext's remarks).
[Test]
public async Task Ignores_A_Non_Built_In_Element_With_A_Different_Filename()
public async Task Ignores_A_Case_Variant_Of_The_Seeded_Path()
{
await using TvContext context = _db.CreateContext();
await context.GraphicsElements.AddAsync(
new GraphicsElement { Path = "/templates/text/something-else.yml", Kind = GraphicsElementKind.Text });
new GraphicsElement
{
Path = System.IO.Path.Combine(
System.IO.Path.GetDirectoryName(GraphicsElementDefaults.OnNowNextSeededPath)!,
GraphicsElementDefaults.OnNowNextFileName.ToUpperInvariant()),
Kind = GraphicsElementKind.Text
});
await context.SaveChangesAsync();
Channel channel = await SeedChannel(context, "1");
@@ -242,6 +272,54 @@ public class GraphicsElementDefaultAttachTests
(await GraphicsElementSeeder.GetBuiltInElementId(context, CancellationToken.None)).IsSome.ShouldBeTrue();
}
// #568: EnsureBuiltInElementRow's "does it exist already?" test and the lookup every consumer
// resolves through must be the same question. While that test was its own SQL comparison
// (`e.Path == target`), a row at the seeded path of ANY kind answered yes, so the Text row
// GetBuiltInElementId actually matches was never created and the built-in element stayed
// undiscoverable for the life of the install.
//
// This is the half of that divergence SQLite can observe. The other half -- a case-variant path,
// which MySQL's normally case-insensitive default collation matches in SQL while the ordinal
// lookup never does -- cannot be shown from a test here, because SQLite's BINARY collation and
// an ordinal comparison agree on every input; it is held by keeping the comparison out of SQL.
//
// RefreshGraphicsElements derives Kind from the template FOLDER, so it cannot itself produce the
// wrong-kind row seeded below. The point is not that this state is common but that the seeder
// must not answer a question differently from the code that consumes its answer.
[Test]
public async Task A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row()
{
var fs = new MockFileSystem();
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
string target = Path.Combine(
FileSystemLayout.GraphicsElementsTextTemplatesFolder,
GraphicsElementDefaults.OnNowNextFileName);
await fs.File.WriteAllTextAsync(target, "name: On Now / Next\n");
await using TvContext context = _db.CreateContext();
context.ConfigElements.Add(
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
await context.GraphicsElements.AddAsync(
new GraphicsElement { Path = target, Kind = GraphicsElementKind.Image });
await context.SaveChangesAsync();
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
List<GraphicsElement> rows = await context.GraphicsElements.OrderBy(e => e.Id).ToListAsync();
rows.Count.ShouldBe(2, "the built-in Text row must be created alongside the wrong-kind row");
rows[1].Path.ShouldBe(target);
rows[1].Kind.ShouldBe(GraphicsElementKind.Text);
rows[1].Name.ShouldBe(GraphicsElementDefaults.OnNowNextName);
// The lookup must land on the Text row, not on the lower-id wrong-kind row that shares its
// path. Both clauses this test covers are measured rows of the mutation table in
// docs/graphics-elements.md -- 18 (the Kind filter) and 34 (the existence check re-derived
// in SQL) -- rather than an outcome asserted only here.
int builtInId = (await GraphicsElementSeeder.GetBuiltInElementId(context, CancellationToken.None))
.IfNone(-1);
builtInId.ShouldBe(rows[1].Id);
}
// The armed path is a real, reachable state: an operator deletes the template, refresh reaps the
// row, and the backfill then has nothing to resolve. Pin what happens when the element comes
// back -- a single global marker cannot both avoid stranding and avoid re-adding, and this is
@@ -726,6 +726,285 @@ public class JellyfinMusicVideoLibraryScannerTests
metadata.Artists.Count.ShouldBe(1);
}
// ersatztv#924: Jellyfin's library-items projection carries no streams and no width/height — those come only
// from the per-item playback info. These run against the REAL MetadataRepository so the assertions read what
// was persisted. Non-vacuous (measured 2026-10-21): restoring the scanner's former body,
// `UpdateStatistics(result.Item, incoming.GetHeadVersion())`, reddens all five tests below.
[Test]
public async Task ScanLibrary_Should_Store_PlaybackInfo_Statistics_For_A_New_MusicVideo()
{
int libraryPathId = await SeedLibraryPath("/data/music");
const string VideoPath = "/data/music/artist1/song1.mkv";
JellyfinMusicVideo Incoming()
{
JellyfinMusicVideo musicVideo = BuildIncoming(VideoPath, "Artist 1", "Song 1");
musicVideo.MediaVersions[0].Chapters = [Chapter(0, "Intro")];
return musicVideo;
}
var playbackCalls = new List<string>();
IJellyfinApiClient api = FakeApiWithPlaybackInfo(
itemId =>
{
playbackCalls.Add(itemId);
return PlaybackInfo(1920, 1080);
},
Incoming);
(JellyfinMusicVideoLibraryScanner scanner, _) =
BuildScanner(api, metadataRepository: new MetadataRepository(_db.Factory));
(await scanner.ScanLibrary(
ConnectionParameters,
BuildLibrary(libraryPathId, "/data/music"),
deepScan: false,
CancellationToken.None)).IsRight.ShouldBeTrue();
playbackCalls.ShouldBe([ItemIdFor(VideoPath)]);
MediaVersion version = await HeadVersionOf(VideoPath);
version.Streams.Count(s => s.MediaStreamKind == MediaStreamKind.Video).ShouldBe(1);
version.Streams.Count(s => s.MediaStreamKind == MediaStreamKind.Audio).ShouldBe(1);
version.Width.ShouldBe(1920);
version.Height.ShouldBe(1080);
// the projection's chapters are stored alongside the statistics, not dropped by them
version.Chapters.Select(c => c.Title).ShouldBe(["Intro"]);
}
// The remediation path for rows already stored without streams: a regular scan skips an item whose etag is
// unchanged (so it cannot heal them), and a deep scan re-reads the statistics.
[Test]
public async Task ScanLibrary_Deep_Scan_Should_Read_Statistics_For_An_Unchanged_MusicVideo_Without_Streams()
{
int libraryPathId = await SeedLibraryPath("/data/music");
JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music");
const string VideoPath = "/data/music/artist1/song1.mkv";
var playbackCalls = 0;
var serverAnswers = false;
(int Width, int Height) size = (1280, 720);
IJellyfinApiClient api = FakeApiWithPlaybackInfo(
_ =>
{
playbackCalls++;
return serverAnswers
? PlaybackInfo(size.Width, size.Height)
: BaseError.New("playback info unavailable");
},
() => BuildIncoming(VideoPath, "Artist 1", "Song 1"));
(JellyfinMusicVideoLibraryScanner scanner, _) =
BuildScanner(api, metadataRepository: new MetadataRepository(_db.Factory));
// 1. the item is added while the server cannot supply statistics: stored with no streams
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
.IsRight.ShouldBeTrue();
(await HeadVersionOf(VideoPath)).Streams.ShouldBeEmpty();
playbackCalls.ShouldBe(1);
// 2. a regular scan of the unchanged item does not look at it again
serverAnswers = true;
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
.IsRight.ShouldBeTrue();
playbackCalls.ShouldBe(1);
(await HeadVersionOf(VideoPath)).Streams.ShouldBeEmpty();
// 3. a deep scan reads the statistics
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: true, CancellationToken.None))
.IsRight.ShouldBeTrue();
playbackCalls.ShouldBe(2);
MediaVersion version = await HeadVersionOf(VideoPath);
version.Streams.Count(s => s.MediaStreamKind == MediaStreamKind.Video).ShouldBe(1);
version.Width.ShouldBe(1280);
version.Height.ShouldBe(720);
// 4. a deep scan re-reads them even when streams are already stored and the etag is unchanged
size = (1920, 1080);
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: true, CancellationToken.None))
.IsRight.ShouldBeTrue();
playbackCalls.ShouldBe(3);
(await HeadVersionOf(VideoPath)).Width.ShouldBe(1920);
}
// The "no stored streams" arm of the refresh condition: an Unavailable item whose file is back is scanned with
// an UNCHANGED etag and no deep scan, so only the empty stream list says its statistics must be read.
[Test]
public async Task ScanLibrary_Should_Read_Statistics_For_A_Returning_Unavailable_MusicVideo_Without_Streams()
{
int libraryPathId = await SeedLibraryPath("/data/music");
JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music");
const string VideoPath = "/data/music/artist1/song1.mkv";
var serverAnswers = false;
IJellyfinApiClient api = FakeApiWithPlaybackInfo(
_ => serverAnswers ? PlaybackInfo(1280, 720) : BaseError.New("playback info unavailable"),
() => BuildIncoming(VideoPath, "Artist 1", "Song 1"));
(JellyfinMusicVideoLibraryScanner scanner, _) =
BuildScanner(api, metadataRepository: new MetadataRepository(_db.Factory));
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
.IsRight.ShouldBeTrue();
int musicVideoId = await MusicVideoId(VideoPath);
await using (TvContext context = _db.CreateContext())
{
MediaItem mediaItem = await context.MediaItems.SingleAsync(mi => mi.Id == musicVideoId);
mediaItem.State = MediaItemState.Unavailable;
await context.SaveChangesAsync();
}
serverAnswers = true;
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
.IsRight.ShouldBeTrue();
MediaVersion version = await HeadVersionOf(VideoPath);
version.Streams.Count(s => s.MediaStreamKind == MediaStreamKind.Video).ShouldBe(1);
version.Width.ShouldBe(1280);
(await MediaItemStateOf(musicVideoId)).ShouldBe(MediaItemState.Normal);
}
// When the server cannot supply statistics on a rescan, nothing is written: the projection's empty stream
// list must not replace the streams already stored (IMetadataRepository.UpdateStatistics removes every
// stored stream the version it is given does not carry).
[Test]
public async Task ScanLibrary_Should_Keep_Stored_Statistics_When_PlaybackInfo_Fails_On_Rescan()
{
int libraryPathId = await SeedLibraryPath("/data/music");
JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music");
const string VideoPath = "/data/music/artist1/song1.mkv";
var title = "Song 1";
IJellyfinApiClient api = FakeApiWithPlaybackInfo(
_ => BaseError.New("playback info unavailable"),
() => BuildIncoming(VideoPath, "Artist 1", title));
(JellyfinMusicVideoLibraryScanner scanner, _) =
BuildScanner(api, metadataRepository: new MetadataRepository(_db.Factory));
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
.IsRight.ShouldBeTrue();
// the row holds statistics from an earlier successful read
int versionId = (await HeadVersionOf(VideoPath)).Id;
await using (TvContext context = _db.CreateContext())
{
MediaVersion stored = await context.MediaVersions.SingleAsync(v => v.Id == versionId);
stored.Width = 1920;
stored.Height = 1080;
await context.MediaStreams.AddAsync(
new MediaStream
{
MediaVersionId = versionId,
Index = 0,
MediaStreamKind = MediaStreamKind.Video,
Codec = "h264"
});
await context.SaveChangesAsync();
}
// the server reports the item changed (BuildIncoming derives the etag from the title), but playback
// info fails
title = "Song 1 (Remastered)";
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
.IsRight.ShouldBeTrue();
MediaVersion version = await HeadVersionOf(VideoPath);
version.Streams.Select(s => (s.Index, s.MediaStreamKind, s.Codec))
.ShouldBe([(0, MediaStreamKind.Video, "h264")]);
version.Width.ShouldBe(1920);
version.Height.ShouldBe(1080);
}
// Refreshed statistics take their chapters from the INCOMING projection: the repository does not eager-load
// chapters on an existing row, so JellyfinMovieLibraryScanner's source (the stored item) would have none to
// write.
[Test]
public async Task ScanLibrary_Should_Refresh_Statistics_And_Projection_Chapters_When_The_Etag_Changes()
{
int libraryPathId = await SeedLibraryPath("/data/music");
JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music");
const string VideoPath = "/data/music/artist1/song1.mkv";
var title = "Song 1";
List<MediaChapter> chapters = [Chapter(0, "Intro")];
(int Width, int Height) size = (1280, 720);
JellyfinMusicVideo Incoming()
{
JellyfinMusicVideo musicVideo = BuildIncoming(VideoPath, "Artist 1", title);
musicVideo.MediaVersions[0].Chapters = chapters.Select(c => Chapter(c.ChapterId, c.Title)).ToList();
return musicVideo;
}
IJellyfinApiClient api = FakeApiWithPlaybackInfo(_ => PlaybackInfo(size.Width, size.Height), Incoming);
(JellyfinMusicVideoLibraryScanner scanner, _) =
BuildScanner(api, metadataRepository: new MetadataRepository(_db.Factory));
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
.IsRight.ShouldBeTrue();
title = "Song 1 (Remastered)";
chapters = [Chapter(0, "Intro"), Chapter(1, "Chorus")];
size = (1920, 1080);
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
.IsRight.ShouldBeTrue();
MediaVersion version = await HeadVersionOf(VideoPath);
version.Width.ShouldBe(1920);
version.Height.ShouldBe(1080);
version.Streams.Count(s => s.MediaStreamKind == MediaStreamKind.Video).ShouldBe(1);
version.Chapters.OrderBy(c => c.ChapterId).Select(c => c.Title).ShouldBe(["Intro", "Chorus"]);
}
private static MediaChapter Chapter(long chapterId, string title) =>
new()
{
ChapterId = chapterId,
Title = title,
StartTime = TimeSpan.FromSeconds(chapterId * 30),
EndTime = TimeSpan.FromSeconds((chapterId + 1) * 30)
};
// What JellyfinApiClient.GetPlaybackInfo returns for a playable file: a version carrying the streams and the
// dimensions. Built fresh per call, since UpdateStatistics attaches the stream objects to the stored version.
private static Either<BaseError, MediaVersion> PlaybackInfo(int width, int height) =>
new MediaVersion
{
Duration = TimeSpan.FromMinutes(4),
Width = width,
Height = height,
SampleAspectRatio = "1:1",
DisplayAspectRatio = "16:9",
RFrameRate = "23.976",
VideoScanKind = VideoScanKind.Progressive,
Streams =
[
new MediaStream { Index = 0, MediaStreamKind = MediaStreamKind.Video, Codec = "h264" },
new MediaStream { Index = 1, MediaStreamKind = MediaStreamKind.Audio, Codec = "aac", Channels = 2 }
],
Chapters = []
};
private async Task<MediaVersion> HeadVersionOf(string path)
{
await using TvContext context = _db.CreateContext();
List<MusicVideo> musicVideos = await context.MusicVideos
.Include(mv => mv.MediaVersions)
.ThenInclude(v => v.MediaFiles)
.Include(mv => mv.MediaVersions)
.ThenInclude(v => v.Streams)
.Include(mv => mv.MediaVersions)
.ThenInclude(v => v.Chapters)
.ToListAsync();
return musicVideos
.Single(mv => mv.MediaVersions.Single().MediaFiles.Single().Path == path)
.MediaVersions
.Single();
}
private static JellyfinConnectionParameters ConnectionParameters =>
new("http://jellyfin", "api-key", 1);
@@ -810,7 +1089,8 @@ public class JellyfinMusicVideoLibraryScannerTests
private (JellyfinMusicVideoLibraryScanner Scanner, IScannerProxy ScannerProxy) BuildScanner(
IJellyfinApiClient apiClient,
Func<string, string> replacePath = null)
Func<string, string> replacePath = null,
IMetadataRepository metadataRepository = null)
{
IScannerProxy scannerProxy = BuildScannerProxy();
@@ -823,7 +1103,7 @@ public class JellyfinMusicVideoLibraryScannerTests
new ArtistRepository(_db.Factory),
new MusicVideoRepository(_db.Factory),
new LibraryRepository(Substitute.For<IFileSystem>(), _db.Factory),
Substitute.For<IMetadataRepository>(),
metadataRepository ?? Substitute.For<IMetadataRepository>(),
BuildFileSystem(),
NullLogger<JellyfinMusicVideoLibraryScanner>.Instance);
@@ -912,7 +1192,14 @@ public class JellyfinMusicVideoLibraryScannerTests
// Each Func builds a fresh music video so the async stream can be re-enumerated across ScanLibrary calls
// (an IAsyncEnumerable iterator is single-use, and the scanner mutates the incoming item).
private static IJellyfinApiClient FakeApi(params Func<JellyfinMusicVideo>[] items)
// Playback info succeeds by default (ersatztv#924: the scanner reads statistics from it for every scanned item).
private static IJellyfinApiClient FakeApi(params Func<JellyfinMusicVideo>[] items) =>
FakeApiWithPlaybackInfo(_ => PlaybackInfo(1920, 1080), items);
// `playbackInfo` answers GetPlaybackInfo per item id, standing in for the server's per-item playback info.
private static IJellyfinApiClient FakeApiWithPlaybackInfo(
Func<string, Either<BaseError, MediaVersion>> playbackInfo,
params Func<JellyfinMusicVideo>[] items)
{
var apiClient = Substitute.For<IJellyfinApiClient>();
apiClient.GetMusicVideoLibraryItems(
@@ -921,9 +1208,16 @@ public class JellyfinMusicVideoLibraryScannerTests
Arg.Any<JellyfinLibrary>(),
Arg.Any<MediaServerProjectionFailureCounter>())
.Returns(_ => Items(items));
StubPlaybackInfo(apiClient, playbackInfo);
return apiClient;
}
private static void StubPlaybackInfo(
IJellyfinApiClient apiClient,
Func<string, Either<BaseError, MediaVersion>> playbackInfo) =>
apiClient.GetPlaybackInfo(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<JellyfinLibrary>(), Arg.Any<string>())
.Returns(ci => Task.FromResult(playbackInfo(ci.ArgAt<string>(3))));
// Same as FakeApi, but the enumeration also reports `failures` swallowed projection exceptions into the
// counter the scanner handed in — what the real JellyfinApiClient does from ProjectToMusicVideo's catch.
private static IJellyfinApiClient FakeApiWithProjectionFailures(
@@ -940,6 +1234,7 @@ public class JellyfinMusicVideoLibraryScannerTests
items,
ci.ArgAt<MediaServerProjectionFailureCounter>(3),
failures));
StubPlaybackInfo(apiClient, _ => PlaybackInfo(1920, 1080));
return apiClient;
}
@@ -33,7 +33,7 @@ namespace ErsatzTV.Tests.Integration;
/// the decision record <c>media.nullable-primitive-collection-mutation</c>.
/// Assigning one on a TRACKED entity flips it to <see cref="EntityState.Modified" />, and the next
/// <c>SaveChanges</c> writes <c>[]</c> over what the database held as <c>NULL</c> — the exact
/// mechanism an adversarial review demonstrated in ersatztv#691, which is why that issue's
/// mechanism demonstrated in ersatztv#691, which is why that issue's
/// entity-level guard was reverted in favour of guarding at the READ SITE.
/// </para>
/// <para>
@@ -0,0 +1,84 @@
using ErsatzTV.Serialization;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Serialization;
/// <summary>
/// Pins the gap between the two objects <see cref="ApiJsonSettings" /> produces:
/// <see cref="ApiJsonSettings.Apply" /> onto MVC's own settings, which is what the production body binder
/// runs, and <see cref="ApiJsonSettings.Create" />, which tests outside the MVC pipeline
/// (<c>ScriptedScheduleControllerTests</c>, <c>OpenApiSerializerContractTests</c>) bind and serialize with.
/// They are not the same object, and the difference belongs in a test rather than in a comment: a prose
/// claim of parity cannot notice the day it stops being true, and every doc describing the scripted
/// coverage rests on knowing which parts carry over and which do not.
/// </summary>
[TestFixture]
public class ApiJsonSettingsTests
{
// The object MVC's options pipeline constructs before Startup's configure delegate runs. Building it
// directly rather than hosting the app is the point of the caveat below: this measures the settings
// MvcNewtonsoftJsonOptions itself defines, not a fully composed host.
private static JsonSerializerSettings Production() =>
ApiJsonSettings.Apply(new MvcNewtonsoftJsonOptions().SerializerSettings);
[Test]
public void Apply_Sets_The_Same_Four_Things_On_Either_Object()
{
JsonSerializerSettings production = Production();
JsonSerializerSettings standalone = ApiJsonSettings.Create();
production.NullValueHandling.ShouldBe(NullValueHandling.Ignore);
standalone.NullValueHandling.ShouldBe(NullValueHandling.Ignore);
production.ReferenceLoopHandling.ShouldBe(ReferenceLoopHandling.Ignore);
standalone.ReferenceLoopHandling.ShouldBe(ReferenceLoopHandling.Ignore);
production.ContractResolver.ShouldBeOfType<CustomContractResolver>();
standalone.ContractResolver.ShouldBeOfType<CustomContractResolver>();
production.Converters.ShouldContain(c => c is Newtonsoft.Json.Converters.StringEnumConverter);
standalone.Converters.ShouldContain(c => c is Newtonsoft.Json.Converters.StringEnumConverter);
}
[Test]
public void Standalone_Matches_On_The_Mvc_Defaults_That_Change_How_A_Body_Parses()
{
JsonSerializerSettings production = Production();
JsonSerializerSettings standalone = ApiJsonSettings.Create();
standalone.MissingMemberHandling.ShouldBe(production.MissingMemberHandling);
standalone.TypeNameHandling.ShouldBe(production.TypeNameHandling);
standalone.DateParseHandling.ShouldBe(production.DateParseHandling);
}
[Test]
public void Standalone_Does_Not_Carry_Mvcs_Depth_Limit_Or_Its_Error_Converters()
{
JsonSerializerSettings production = Production();
JsonSerializerSettings standalone = ApiJsonSettings.Create();
// MVC clamps the object graph it will consume; a bare settings object keeps Newtonsoft's own limit.
// A body deeper than 32 therefore binds here and 400s in production — inert for the scripted request
// DTOs, which nest two levels, and the reason no test may generalize from Create() to "production".
production.MaxDepth.ShouldBe(32);
standalone.MaxDepth.ShouldBe(64);
// The rest of the delta is MVC's error-response shaping, which no request body reaches.
string[] missing = production.Converters
.Select(c => c.GetType().Name)
.Except(standalone.Converters.Select(c => c.GetType().Name))
.Order()
.ToArray();
missing.ShouldBe(["ProblemDetailsConverter", "ValidationProblemDetailsConverter"]);
// and nothing goes the other way: the standalone object adds nothing production lacks
standalone.Converters
.Select(c => c.GetType().Name)
.Except(production.Converters.Select(c => c.GetType().Name))
.ShouldBeEmpty();
}
}
@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace ErsatzTV.Tests.Support;
/// <summary>
/// Makes the next <c>SaveChangesAsync</c> fail the way a lost race against a concurrent delete
/// does: run <see cref="SqlBeforeFailing" /> first (the delete), then throw the
/// <see cref="DbUpdateException" /> the foreign-key violation would have produced.
/// </summary>
/// <remarks>
/// What is simulated is the TRIGGER, not the decision: <see cref="InMemoryTvContext" /> runs
/// with foreign keys OFF, so a real constraint cannot fire here. The handler's recovery — re-ask
/// the existence question against the post-delete state, translate a now-missing id into the
/// validator's own 422, re-throw anything else — is real code running against real data.
/// Disarms itself on the first fire so the recovery path gets a working context.
/// </remarks>
public sealed class ArmedSaveFailureInterceptor : SaveChangesInterceptor
{
public bool Armed { get; set; }
public string SqlBeforeFailing { get; set; } = string.Empty;
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (!Armed)
{
return await base.SavingChangesAsync(eventData, result, cancellationToken);
}
Armed = false;
if (!string.IsNullOrEmpty(SqlBeforeFailing))
{
await eventData.Context!.Database.ExecuteSqlRawAsync(SqlBeforeFailing, cancellationToken);
}
throw new DbUpdateException("simulated: a validated principal row was deleted concurrently");
}
}
@@ -128,7 +128,8 @@ public abstract class ChannelHandlerTestBase
string logoPath = "",
string name = "Test",
string group = "ErsatzTV",
List<int> graphicsElementIds = null) =>
List<int> graphicsElementIds = null,
int? watermarkId = null) =>
new(
channelId,
name,
@@ -147,7 +148,7 @@ public abstract class ChannelHandlerTestBase
null,
null,
StreamingMode.TransportStreamHybrid,
null,
watermarkId,
null,
string.Empty,
ChannelSubtitleMode.None,
+9 -1
View File
@@ -3,6 +3,7 @@ using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Sqlite.Data;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
namespace ErsatzTV.Tests.Support;
@@ -27,7 +28,13 @@ public sealed class InMemoryTvContext : IAsyncDisposable
public IDbContextFactory<TvContext> Factory => new TestDbContextFactory(_options);
public static async Task<InMemoryTvContext> CreateAsync()
/// <param name="interceptors">
/// Extra EF interceptors registered on every context this harness hands out. The only
/// current use is making <c>SaveChangesAsync</c> fail on demand: foreign keys are off here
/// (see the type summary), so a write-path failure a real FK constraint would raise cannot
/// be produced by seeding alone.
/// </param>
public static async Task<InMemoryTvContext> CreateAsync(params IInterceptor[] interceptors)
{
TvContext.IsSqlite = true;
TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation;
@@ -38,6 +45,7 @@ public sealed class InMemoryTvContext : IAsyncDisposable
DbContextOptions<TvContext> options = new DbContextOptionsBuilder<TvContext>()
.UseSqlite(connection)
.AddInterceptors(interceptors)
.Options;
await using (TvContext context = Create(options))
@@ -1,13 +1,15 @@
#nullable enable
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Controllers.Api.Requests;
public record PlayoutAlternateScheduleItemRequest(
int Id,
int ProgramScheduleId,
List<DayOfWeek> DaysOfWeek,
List<int> DaysOfMonth,
List<int> MonthsOfYear,
List<DayOfWeek>? DaysOfWeek,
List<int>? DaysOfMonth,
List<int>? MonthsOfYear,
bool LimitToDateRange,
int StartMonth,
int StartDay,
@@ -16,14 +18,26 @@ public record PlayoutAlternateScheduleItemRequest(
int EndDay,
int? EndYear)
{
// An ABSENT recurrence array means UNRESTRICTED, not "matches nothing" (#880). The three sets are
// conjunctive in AlternateScheduleSelector.GetScheduleForDate -- a miss on any one `continue`s -- so
// the previous `?? []` stored an item that could never apply on any date, and returned 200 while
// doing it. Absence now normalizes to the same All*() sets the READ side substitutes for a NULL
// column (AlternateScheduleSelector's read guard and both Mapper.ProjectToViewModel overloads), so
// the two halves of "this field is absent" finally agree -- residual (3) of
// `media.nullable-primitive-collection-mutation`.
//
// These are nullable so that ABSENT is distinguishable from an explicitly-sent `[]`, which is a
// different request and is REJECTED with a 422 in PlayoutController rather than normalized here.
// Newtonsoft maps both a missing property and an explicit `null` to null, so both read as absent;
// only a literal `[]` survives as empty.
public ReplacePlayoutAlternateSchedule ToReplaceItem(int index) =>
new(
Id,
index,
ProgramScheduleId,
DaysOfWeek ?? [],
DaysOfMonth ?? [],
MonthsOfYear ?? [],
DaysOfWeek ?? AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth ?? AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear ?? AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange,
StartMonth,
StartDay,
@@ -1,4 +1,6 @@
#nullable enable
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Controllers.Api.Requests;
@@ -6,9 +8,9 @@ public record PlayoutTemplateItemRequest(
int Id,
int TemplateId,
int? DecoTemplateId,
List<DayOfWeek> DaysOfWeek,
List<int> DaysOfMonth,
List<int> MonthsOfYear,
List<DayOfWeek>? DaysOfWeek,
List<int>? DaysOfMonth,
List<int>? MonthsOfYear,
bool LimitToDateRange,
int StartMonth,
int StartDay,
@@ -17,15 +19,18 @@ public record PlayoutTemplateItemRequest(
int EndDay,
int? EndYear)
{
// Same contract as PlayoutAlternateScheduleItemRequest: ABSENT means UNRESTRICTED (the All*() sets
// the read side substitutes for a NULL column), an explicit `[]` is rejected with a 422 in
// PlayoutController. See that record for the full rationale (#880).
public ReplacePlayoutTemplate ToReplaceItem(int index) =>
new(
Id,
index,
TemplateId,
DecoTemplateId,
DaysOfWeek ?? [],
DaysOfMonth ?? [],
MonthsOfYear ?? [],
DaysOfWeek ?? AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth ?? AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear ?? AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange,
StartMonth,
StartDay,
+43
View File
@@ -0,0 +1,43 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace ErsatzTV.Serialization;
/// <summary>
/// The Newtonsoft settings the MVC JSON formatters read and write `/api/*` bodies with. Defined once,
/// here, so a test can deserialize a request body exactly the way the production body binder does
/// instead of mirroring <see cref="Startup" />'s registration. This REMOVES the duplicate rather than
/// detecting drift in one: no test observes <c>Startup.ConfigureServices</c>, and a byte-equal hand-copy
/// of <see cref="Apply" /> is behaviourally indistinguishable from calling it. What the tests do
/// separate is a caller that binds with something else — a body omitting a C#
/// <c>required</c> member deserializes to a default here where System.Text.Json throws, and
/// <see cref="NullValueHandling" />.<c>Ignore</c> keeps a DTO's declared default over an explicit
/// null where plain Newtonsoft writes the null through. Both are pinned by
/// <c>ScriptedScheduleControllerTests</c>; the naming strategy is pinned on the write side by
/// <c>OpenApiSerializerContractTests</c>. See docs/testing.md → "Scripted playout coverage".
/// </summary>
public static class ApiJsonSettings
{
/// <summary>Applies the production configuration to an existing settings object (the MVC path).</summary>
public static JsonSerializerSettings Apply(JsonSerializerSettings settings)
{
settings.NullValueHandling = NullValueHandling.Ignore;
settings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
settings.ContractResolver = new CustomContractResolver();
settings.Converters.Add(new StringEnumConverter());
return settings;
}
/// <summary>
/// <see cref="Apply" /> onto a bare settings object, for a caller outside the MVC pipeline. It carries
/// everything <see cref="Apply" /> sets and nothing MVC supplies around it, so it is NOT the object the
/// body binder runs. Measured 2026-09-05 against <c>new MvcNewtonsoftJsonOptions().SerializerSettings</c>:
/// <c>MaxDepth</c> is Newtonsoft's 64 rather than MVC's stricter 32, and MVC's two error-shaping
/// converters — <c>ProblemDetailsConverter</c> and <c>ValidationProblemDetailsConverter</c> — are absent.
/// <c>MissingMemberHandling</c>, <c>TypeNameHandling</c> and <c>DateParseHandling</c> match. Both gaps are
/// inert for the request DTOs the tests bind (two levels deep, never a <c>ProblemDetails</c>), which is why
/// this is usable there at all; the delta is pinned by <c>ApiJsonSettingsTests</c> so it cannot decay into
/// a parity claim.
/// </summary>
public static JsonSerializerSettings Create() => Apply(new JsonSerializerSettings());
}
+3 -8
View File
@@ -104,7 +104,6 @@ using Microsoft.IdentityModel.Tokens;
using Microsoft.IO;
using Microsoft.OpenApi;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Refit;
using Scalar.AspNetCore;
using Serilog;
@@ -503,13 +502,9 @@ public class Startup
options.OutputFormatters.Insert(0, new HdhrJsonOutputFormatter());
options.Filters.AddService<ApiAuthorizationFilter>();
})
.AddNewtonsoftJson(opt =>
{
opt.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
opt.SerializerSettings.ContractResolver = new CustomContractResolver();
opt.SerializerSettings.Converters.Add(new StringEnumConverter());
});
// ApiJsonSettings rather than an inline lambda: the request-body binder's semantics are asserted
// by ScriptedScheduleControllerTests, which has to configure the SAME object, not a mirror of it.
.AddNewtonsoftJson(opt => ApiJsonSettings.Apply(opt.SerializerSettings));
services.AddScoped(_ => new ConditionalIptvAuthorizeFilter("JwtOnlyScheme"));
@@ -176,7 +176,7 @@ Tracked under epic **#62**; sub-issues #63–#68:
## 7. Out of scope & next steps
**Out of scope now:** the full #59 implementation spec, React app architecture (routing/state/auth/build/deploy), the media-libraries/collections browser (Pass 2), browser-based channel playback (tracked separately in #60), the actual rebuild. (Channel creation was originally deferred to Pass 2 but is now specified in §6.)
**Out of scope now:** the full #59 implementation spec, React app architecture (routing/state/auth/build/deploy), the media-libraries/collections browser (Pass 2), browser-based channel playback (tracked separately in #60), the actual rebuild. (Channel creation is specified in §6, not deferred to Pass 2.)
**Next steps (after a look is locked):**
1. Design the §6 Create Channel screen in Claude Design; file the §6 backend-gap issues.
+35 -31
View File
@@ -9,37 +9,41 @@ WORKDIR /source
COPY design-system/. ./design-system/
COPY web/. ./web/
WORKDIR /source/web
# The SPA suite runs here except for two files (ersatztv#819), excluded for OVERLAPPING reasons —
# one needs the git binary, the other needs the binary AND a checkout. Reading them as two separate
# reasons is what broke this stage once already:
# * `web/src/api/pageSizeCallSites.guard.test.ts` needs a git CHECKOUT — and, through it, the
# binary. It derives its file population from `git ls-files` rather than a directory walk, and
# refuses rather than falling back. This stage's context is `web/` + `design-system/` only, so
# there is no `.git`.
# * `web/vite-plugins/trackedSourceFiles.realgit.test.ts` needs the BINARY but no checkout: it
# builds its own temp repository to prove the derivation by executing it.
# `node:22-bookworm-slim` ships no git (`command -v git` -> not found), so it dies with
# `spawnSync git ENOENT`.
# So this is NOT checkout-versus-binary, and adding a `COPY .git` would not let either run here —
# the binary would still be missing. Excluding only the first is not enough either, and a replica
# that merely deletes `.git` cannot show that: verify any change here with the git binary off
# `PATH`, not just with the directory absent.
# Everything else — all but those two files — runs fine gitless and is kept, rather than dropping
# the whole suite for one file as an earlier fix here did.
# The excluded pair is not skipped overall: `docker-build.yml`'s `Build & test (.NET)` job runs the
# whole suite on a real checkout, and `build` (the job that invokes this Dockerfile) carries
# `needs: [test, migrations, scan]`. State that chain precisely, because the `needs:` edge is not
# all of it: `Test SPA` is also gated on `docs_only` and on the #420 revalidate skip, and `build` is
# not gated on `revalidate`. On a push whose tree is byte-identical to an already-green head the
# suite is skipped and the image still builds — carried there by #420's byte-identical-tree
# argument. The `docs_only` arm cannot ship an image at all (`Build and push` is gated on it too),
# and `ci-detect-docs-only.sh` classifies by PATH SHAPE (`docs/` or `*.md`), not by directory, so a
# `web/*.md` would count as docs — there are none today, but do not restate it as "any `web/**`".
RUN npm run lint && npm run typecheck && \
npm test -- --run \
--exclude 'src/api/pageSizeCallSites.guard.test.ts' \
--exclude 'vite-plugins/trackedSourceFiles.realgit.test.ts' && \
npm run build
# THE VITEST SUITE IS NOT RUN IN THIS STAGE, AND MUST NOT BE RE-ADDED (ersatztv#887).
# This stage is gitless in both senses at once. It copies only `web/` and `design-system/`, so it
# holds no `.git` — a property of the STAGE, not of the context, which is the repository root
# (`context: .`) and whose `.dockerignore` does not exclude `.git`: the directory is there to be
# copied and simply is not. And `node:22-bookworm-slim` ships no git binary (`command -v git` -> not
# found), which is why a `COPY .git` would not help either. Members of the SPA suite need one or the
# other: `pageSizeCallSites.guard.test.ts` and `completeAnnotations.guard.test.ts` derive their file
# population from `git ls-files` and refuse to fall back to a directory walk
# (`testing.guard-derives-population-from-source`), and `trackedSourceFiles.realgit.test.ts` builds
# its own temp repository. (Not every `*.guard.test.ts` — `completeRequest.guard.test.ts` needs no
# git at all.)
#
# Running the suite here anyway therefore costs a hand-maintained list of the members that cannot
# run — and that list is a population nothing derives. It went stale the first time a guard was
# added without updating it, and because `Build & push image (amd64)` is `if: github.event_name !=
# 'pull_request'`, the resulting red is unreachable on a PR and lands on `main` and on the `v*` tag
# path: every image build failed and `:latest` stopped being republished. Excluding one more file
# re-arms that; the list is removed instead.
#
# WHAT STILL VALIDATES THE SUITE. `docker-build.yml`'s `test` job runs it UNFILTERED on a real
# checkout, and `build` — the job that invokes this Dockerfile — carries `needs: [test, migrations,
# scan]`, so an image cannot be published past a red suite. Two skips exist inside `test` and
# neither leaves an image unvalidated: `docs_only` also gates `Build and push`, so that arm ships
# nothing — and that arm IS asserted, by `test_the_DOCS_ONLY_arm_cannot_publish_an_image`. The #420
# revalidate skip fires only on a tree byte-identical to a head that already carried a green combined
# status; that arm is NOT asserted here — it rests on `scripts/ci-detect-already-validated.sh`, which
# carries no mutation proof of its own, so it is a dependency rather than a check. Held by
# `scripts/tests/test_image_build_delegates_the_spa_suite.py`. That guard PINS the command line
# below as text: any edit to it reddens, whatever the edit says. Update the pin in the same commit,
# and do not use it to re-add a suite run.
#
# `lint` and `typecheck` stay. They are gitless-safe with no member that is not, so they carry no
# list and no trap; whether the image build should run them at all is a separate question this
# does not answer.
RUN npm run lint && npm run typecheck && npm run build
FROM --platform=linux/amd64 192.168.1.95:3000/timothy/ersatztv-ffmpeg:8.1.2 AS runtime-base
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
+5 -1
View File
@@ -17,6 +17,7 @@ doc below, or that changes which sections a task signal points to.**
| Signal | Read |
| --- | --- |
| Session startup / "what's next" (no issue named) | `docs/handoffs/chicorytv-issue-queue.md` (standing kickoff — two concurrent tracks: orientation ‖ `scripts/select-queue.sh 5`) |
| Running SEVERAL issues in parallel under one orchestrator (the `orchestrator-prompt.md` kickoff) | `docs/handoffs/orchestration.md` — roles and sizing, one worktree per issue under `~/orca/workspaces/ersatztv/`, the landing order (gate + review before the single push; verdict via `scripts/post-review-verdict.sh`; merge through the consent hook), resuming a paused branch; rule: `process.orchestrated-session`. The queue rules and HARD CONSTRAINTS stay in `chicorytv-issue-queue.md` |
| Named-issue pickup | Skip queue selection; go straight to focused retrieval — see "Knowledge retrieval" below, then the issue body |
| Adding/changing a `/api/*` endpoint | `docs/api-conventions.md` checklist + `docs/endpoint-index.md` |
| Adding a ChicoryTV SPA screen | `docs/spa-conventions.md` |
@@ -27,8 +28,9 @@ doc below, or that changes which sections a task signal points to.**
| Concurrency / optimistic-locking work | `docs/api-conventions.md` §7a/b/c + `docs/decisions/optimistic-concurrency.md` |
| Auth / security-surface work | `docs/decisions/api-auth-security.md` |
| CI / release pipeline work | `docs/ci-cd.md` + `docs/decisions/release-ci-governance.md` |
| Proposing a new guard / CI check / regression test convention | `docs/defect-shapes-773.md` §4 (detector menu + the classes where no detector is plausible), then the rules every guard must satisfy: `docs/decisions/records/testing/guard-derives-population-from-source.md`, `…/guard-ships-with-mutation-proof.md` and `…/mutation-claims-are-executed.md` (a `MUTATION` grade carries a DECLARED clause mutation that is re-run every suite) — plus `…/verification-code-needs-its-own-proof.md`, which extends the same obligation BEYOND guards to the harness, wrapper or checker doing the checking, and says where its proof lives when the checker holds no row |
| Proposing a new guard / CI check / regression test convention | `docs/defect-shapes-773.md` §4 (detector menu + the classes where no detector is plausible), then the rules every guard must satisfy: `docs/decisions/records/testing/guard-derives-population-from-source.md`, `…/guard-ships-with-mutation-proof.md` and `…/mutation-claims-are-executed.md` (a `MUTATION` grade carries a DECLARED clause mutation that is re-run every suite — and so does a PROSE claim that some mutation reddens, or does not redden, a named test, wherever it is written: it is a `CLAIMS` entry in the same manifest, bound to its site and verbatim quote, or it is not written) — plus `…/verification-code-needs-its-own-proof.md`, which extends the same obligation BEYOND guards to the harness, wrapper or checker doing the checking, and says where its proof lives when the checker holds no row. Then `…/guard-pins-the-artifact-not-a-shape.md` for the SHAPE of the predicate itself: over an artifact whose GRAMMAR the predicate does not implement, pin the artifact WHOLE by default — matching a shape inside it is the exception and must carry that record's four-part argument; that record's `rule:` is the one place the qualifying grammars are enumerated |
| Adding or bounding a consequential numeric config field (an FFmpeg profile tunable, a pipeline knob) | `docs/api-conventions.md` §3d — reject out of range with a 422 naming the bound and its consequence, never accept-then-rewrite; validate against the constants the renderer reads, keep the render-time clamp for pre-existing rows, and let an UNCHANGED legacy value through on update. Then `api.ffmpeg-profile-numeric-bounds` |
| Adding or changing a write path that takes a top-level LIST of FK ids (`graphicsElementIds`, `watermarkIds`) | `docs/api-conventions.md` §3b — bound the RAW list through `Validators.IdsMustExist` before `Distinct`, reject an unknown id with a 422 that names the request field, gate only the EXISTENCE half on whatever makes the apply path discard the list, and translate a lost race by re-running the handler's whole `Validate` on a fresh context. Then `api.top-level-id-list-validation`; the repo-wide rollout is #917 |
| Testing a surface gated by config / an env var / a credential | `docs/decisions/records/testing/deny-path-at-production-config-value.md` — cover the setting absent, at its production value, and each opt-out, and assert the DENY branch |
| Touching a full-replace write path or a hand-built request object | `docs/decisions/records/testing/full-replace-asserts-field-list.md` — derive the field list from the DTO and assert set equality; reconcile by id where child state exists. In the SPA the same rule is enforced by the type system: `docs/spa-conventions.md` §4b — build the body as `Complete<T>`, annotating BOTH the wrapper parameter and every construction site |
| Writing or editing any doc, or answering a review finding in prose | `docs/decisions/records/docs/no-session-narrative.md` — the doc records the END STATE; the path to it goes in the commit message. Apply the who-benefits test, and read the carve-out before you cut (dated measurements, stated snapshot boundaries and tested-and-rejected results stay) |
@@ -120,6 +122,8 @@ bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "K
that file's standing kickoff for the two concurrent tracks (orientation ‖ selection). ersatztv#237
is a closed, archival historical tracker (superseded by `startup.parallel-orientation` in
`docs/decisions.md`) — not a live pointer.
- **`docs/handoffs/orchestration.md`** — mechanics of an orchestrated session: roles, isolation, landing a branch through the gate (`process.orchestrated-session`), resume, incidents. Workflow scripts: `.claude/workflows/ersatztv-{pick-next,issue-build,resume-branch}.js`.
- **`docs/handoffs/orchestrator-prompt.md`** — the standing prompt that starts an orchestrated session; the single-issue kickoff stays in `chicorytv-issue-queue.md`.
- **`docs/defect-shapes-773.md`** — root-cause analysis of the recurring defect shapes across the
whole closed-issue corpus (#773): the measured class ranking, the four families they consolidate
into, the cheapest mechanical detector per class, the classes where **no** detector is plausible,
+97 -6
View File
@@ -302,6 +302,32 @@ handler's validation when a lookup fails, so the controller-side mapping falls o
caller) → surface as 422 instead of silently returning a shorter list.
- Unbounded `int`/`TimeSpan` inputs from the request → clamp or validate, per the Logs pagination
pattern above.
- **A top-level LIST of FK ids is an unbounded input too** — the body-size cap is a transport limit,
not a collection limit, and millions of compact integers fit under it. Bound the list with
`Validators.IdsMustExist` (#568), which counts the **raw** list before `Distinct` and before any
database work — deduplication is not what the request costs to parse and materialize — and caps how
many rejected ids the 422 echoes back, so an oversized request does not earn an oversized response.
The count is the REQUEST's bound, not the apply path's: where another field of the same body (a
deco's `DecoMode`) makes the reconcile discard the list, the mode gates the EXISTENCE half only —
a discarded list was still parsed and materialized out of the body. Pass that predicate to
`IdsMustExist` as `idsAreConsumed` rather than short-circuiting the call, which takes the cap with
it. The rollout to the other FK-id validators on these DTOs is #917; the rule and its residuals are
`api.top-level-id-list-validation`.
- **Name the request field in the validation message**, `[GraphicsElementIds] …`, using the same
`[{GetMemberName(expression)}]` prefix `Validators.NotEmpty`/`NotLongerThan`/`AtLeast` already
produce. A full-replace DTO carries several id lists, and `Graphics element(s) do not exist: 42`
does not say which one to fix.
- **Validation and the write are two statements, so an FK the validator confirmed can be gone by
`SaveChangesAsync`** — a background job deleting the principal row (e.g. `RefreshGraphicsElements`
removing elements whose template file is gone) restores the very 500 the validator was added to
prevent. A transaction does not close that window: neither provider locks the rows the validator
merely READ. Catch `DbUpdateException` around the save, re-ask the question on a **fresh** context
(the failed one still tracks the changes it could not write), and return the validator's own 422 if
an id has since gone — re-throwing anything else, so a real fault is not reported as a client error
(#568). **Re-run the handler's whole `Validate`, never a named subset of its fields**: every FK the
save writes loses the same race, and a recovery path that enumerates the fields it knows about
silently omits the next one the DTO gains. Re-running the validator set covers a check added later
by construction.
- **Dereferencing a request `string` (e.g. `request.Name.Length`) is a latent 500** — request DTOs
carry no `#nullable` context (§2), so a `string Name` binds `null` from `name: null`/an omitted field
and there is no implicit `[Required]`; a raw `.Length`/`.Trim()` throws `NullReferenceException` → an
@@ -383,6 +409,46 @@ configurable — never materialized into a stored number on save, so an untouche
identically. Document the range in the schema with `[property: Description("…")]` on the request
record's positional parameter (`System.ComponentModel`); it renders into `v1.json`.
### 3e. An ABSENT collection means unrestricted; an explicitly EMPTY one is rejected
A nullable collection on a write path carries two different requests that are easy to collapse into
one, and collapsing them is how ersatztv#880 shipped a 200 that stored a row which could never apply.
Decide both, separately:
- **Absent** (the property is missing, or explicitly `null` — Newtonsoft maps both to `null`) means
*the client is not expressing a restriction*. Normalize it to the **permissive** value, which is
whatever the READ side already substitutes for the same absence. It must not become the empty set:
for a filter, empty is the maximally *restrictive* value, so `?? []` silently inverts the request.
- **Explicitly empty** (`[]`) is a different, well-formed request. If the empty set has no meaningful
outcome — a conjunctive filter that then matches nothing — **reject it with a 422 naming the
consequence**, per §3d. Do not accept-then-rewrite it into the permissive value: that would make
`[]` and absence indistinguishable again, in the other direction.
Distinguishing them requires the request record's property to be **nullable** (`List<T>?`, with a
per-file `#nullable enable` where the project has annotations off), because `?? …` on a
non-nullable-annotated `List<T>` cannot tell absence from empty. Normalization belongs in the request
record's `ToReplaceItem`; **rejection does not belong beside it.** Three rules that come with it:
- **Say "send `null`", not "omit the property".** Making a C# property nullable does NOT make it
optional in the generated schema: all three recurrence properties are still listed in `required` in
`v1.json`, with type `["null","array"]`. A client generated from the published contract therefore
*cannot* omit them, and an error message telling it to would be instructing a schema violation.
Omission still works at runtime; `null` is the form that is also contract-legal.
- **Validate where the STORED value is in hand — the handler, not the controller.** §3d's rule that an
*unchanged* bad value must still be accepted applies here in its sharpest form: these are whole-list
replace PUTs, so rejecting a pre-existing empty set would make every *other* item in the list
uneditable over a row the operator never touched. That comparison needs the stored row, which the
controller does not have and the handler already loaded. Exemplar: `RecurrenceSetBounds`
(`ErsatzTV.Application/Scheduling`), one validator called from both replace handlers — the same shape
as `FFmpegProfileBounds`. The exemption is **per field**, not per row: a row grandfathered on
`DaysOfWeek` still cannot newly empty `MonthsOfYear`.
- **Derive the validated set from the list the handler actually writes.** In the alternate-schedule
path that is `incoming`, which *excludes* the highest-`Index` catch-all — the handler discards that
item's recurrence along with its date range, so an empty set there cannot make anything "never
apply" and rejecting it would state a reason that is false for that item. Walking the same list the
writes iterate is what keeps the check and its subject from drifting; do not re-derive "which item is
the catch-all" in a second place (`api.put-replace-index-order`).
## 4. Artwork contract
API response DTOs return **rooted, directly-usable artwork URLs** — e.g. `/artwork/posters/...`,
@@ -601,7 +667,11 @@ Runtime `/api/*` JSON is serialized by **Newtonsoft** (`AddNewtonsoftJson` in `S
`ErsatzTV/Serialization/CustomContractResolver.cs` → `CustomNamingStrategy` (camelCase **plus** a
special case mapping any `FFmpegProfileId` member to `"ffmpegProfileId"`, and honoring any
`[JsonProperty("...")]` attribute, e.g. `ChannelResponseModel.FFmpegProfile` →
`[JsonProperty("ffmpegProfile")]`). The OpenAPI document, however, is generated from
`[JsonProperty("ffmpegProfile")]`). The settings themselves live in
`ErsatzTV/Serialization/ApiJsonSettings.cs`, which `Startup` applies — call it from a test that needs to
serialize or bind the way the runtime does rather than restating the settings, which drifts silently
(`OpenApiSerializerContractTests`, `ScriptedScheduleControllerTests`). The OpenAPI document, however, is
generated from
**System.Text.Json** metadata, whose camelCase can differ (it emitted `fFmpegProfileId` /
`fFmpegProfile`). That drift silently gave the SPA the wrong key to read (issue #198).
@@ -891,11 +961,32 @@ for items that have no group, so the SPA can render an "ungrouped" bucket
concept elsewhere, this is the established pattern to follow — but be aware it means `Id` is not a
reliable real-entity id for those synthetic rows.
**Channel graphics (issue #74)**: `ChannelDetailResponseModel`/`UpdateChannelRequest` carry
`graphicsElementIds` (the channel's attached `GraphicsElement` ids, reconciled add/remove on PUT via
`Channel.ChannelGraphicsElements`), and `GraphicsElementResponseModel` exposes a server-derived
`builtIn` (`Path.GetFileName(element.Path) == GraphicsElementDefaults.OnNowNextFileName`) — never
client-settable.
**Channel graphics (issue #74, hardened #568)**: `ChannelDetailResponseModel`/`UpdateChannelRequest`
carry `graphicsElementIds` (the channel's attached `GraphicsElement` ids, reconciled add/remove on PUT
via `Channel.ChannelGraphicsElements`); `UpdateChannelHandler.Validate` rejects any id not present in
`GraphicsElements` with 422 (previously an unhandled 500 from the FK constraint — #568), matching
every other field on this full-replace DTO. The identical shape existed on `PUT /api/v1/decos/{id}`
(`ReplaceDecoRequest.graphicsElementIds`/`watermarkIds`, also top-level fields, not the
`deep-FK-in-a-nested-list` carve-out of §3b above) and is hardened the same way by
`UpdateDecoHandler.Validate` (`GraphicsElementIdsMustExist`/`WatermarkIdsMustExist`, #568). Each
deco validator passes the same `Override`/`Merge` mode predicate that makes the apply path consume
its id list — under `Inherit`/`Disable` the reconcile clears the join and ignores the ids, so
validating them there would 422 a request over ids it was about to discard. The SPA sends both id
lists whatever the mode selector says, so that shape arrives from the real editor. The predicate is
an argument (`idsAreConsumed`) rather than a short-circuit around the call, because it gates the
existence half alone and the raw-count cap applies under every mode.
All three id lists go through one shared validator, `Validators.IdsMustExist`, which is where the
count cap (`MaximumIdListCount`), the request field named in the message and the cap on echoed ids
are written once rather than three times. Both handlers translate a lost race the same way, by
re-running their own `Validate` on a fresh context after a `DbUpdateException` — so every FK on the
DTO is covered, not just the id lists this issue was about.
`GraphicsElementResponseModel` exposes a server-derived `builtIn`, computed by
`GetAllGraphicsElementsForApiHandler` as
`GraphicsElementDefaults.IsOnNowNext(element.Path, element.Kind)` — ordinal equality against the full
seeded path AND `Kind == Text`, not the bare filename, which was folder-agnostic (#568) — never
client-settable. `Kind` is inside that predicate rather than a filter each caller adds: the seeder
requires it, and an API that did not would report a wrong-kind row at the seeded path as
`builtIn:true` while the seeder refused to treat it as the built-in element (#568).
## 9. Authentication — session-or-key posture (fail-closed)
+312 -68
View File
@@ -41,6 +41,7 @@ Upstream's final release was **`v26.3.0`** (archived). Our line continues from t
| `v26.12.0` | **`ErsatzTV.Mcp` MCP server** — read + cautious-write over `/api/v1`, `ERSATZTV_ALLOW_WRITES`-gated (#58). **External channel-logo URLs download + cache at save time** (#525), with the on-screen bug now rendered for external-URL logos (#502). HLS cold-start hardening: burst-read the first segments so start isn't `-readrate`-bound (#350) and floor QSV extra hardware frames so an unthrottled read can't exhaust the pool (#529); remote graphics-engine image fetches bounded — timeout, size cap, decode cap, redirects, pooling (#511). Decision-lifecycle tooling + parallel-orientation startup rewrite (#520/#521); CI `docker build` lane rebalance (#508). |
| `v26.13.0` | **RuleBuilder maturation** — arbitrary-depth group nesting (#436), inline smart-query authoring in Channel Builder (#437), DB-sourced facet typeahead + relative-date operators + validation (#434/#435/#438), and an artist typeahead covering music-video/song credits with `album_artist` no longer 404ing (#578). **Per-channel On Now/Next transient overlay** (#74/#570) and **per-schedule clock-boundary padding** (#392); in-browser channel preview (#60); Auto-Tune per-source weight steppers + exclude/add-untagged (#440). Library-browse pickers now resolve by search instead of a 100-row window, closing several silent at-cap truncations (#644/#650/#651/#634). Correctness: one watermark resolver for all four attachment points, incl. `MiddleCenter` (#503/#510); QSV HDR tonemaps through OpenCL because `vpp_qsv=tonemap` is a silent no-op (#505); `LibraryFolder` unique index + concurrent-insert tolerance (#491); per-library music-video identity with soft trash (#496); Jellyfin Album/Track music-video projection (#177); metadata-collection dedup (#500); accented facet values via a registered Unicode fold on SQLite (#668); `WorkAheadSlots` atomic slot claim, never a negative count (#536/#539); on-demand guide rebuild on thaw (#68). Process/CI: the H10 review-verdict gate became a sha-bound **required** commit status and was hardened through its false-open chain (#622/#629/#632/#648/#649/#672/#698), the decision corpus split to one YAML-frontmatter record per file (#610/#620), and headless Playwright UI-E2E flows landed (#445/#533). Five dual-provider migrations. |
| `v26.14.0` | **Live TV no longer starves on embedded bitmap subtitles** — `-readrate` paces an input off its *furthest-behind* stream, and a PGS/DVD subtitle read through the video's own `-i` is sparse enough to drag the whole process to **0.53x realtime** against the 1.0x a client consumes, draining the buffer until the channel stalls. Fixed with a capability-gated `-readrate_catchup` (ffmpeg 8.0+) on realtime inputs, keeping `-readrate` on the frame-producing path so the `ffmpeg.qsv-extra-hw-frames-floor` bound is untouched; measured 0.533x → 1.067x on QSV and software, with a 240s QSV soak clean of allocation errors (#726). Affects items carrying an embedded bitmap subtitle matching the channel's subtitle mode — 3,182 of 24,646 media versions on prod, and a property of the *item*, not the channel, which is why the stall presented as random. Process/CI: the H10 review-verdict gate's repair sentinel became a fixed point and its write is now fenced on the timeline retarget count, closing a raced-sentinel false-open (#706/#707/#711). **The decisions validator now cross-checks its dependency-free frontmatter parse against PyYAML** and reports both the truncating unquoted `` #`` and the scalar-closing bare apostrophe as errors, so a record whose `rule:` silently halves under PyYAML fails the local gate instead of CI (#674/#688) — the ceiling-calibration claim was also split so the suite pins what the derivation MEANS rather than live-corpus order statistics. Dependencies: CliWrap 3.10.4, JetBrains.ReSharper.GlobalTools 2025.3.5. |
| `v26.15.0` | **`main` became PR-only, which is what makes `review-verdict/h10` load-bearing** — branch protection now carries `enable_push: false` *and* `block_admin_merge_override: true`, so a direct `git push origin HEAD:main` is refused at pre-receive for every account including a site admin, and an admin cannot force-merge past a missing or red required context (#743/#749). Gitea only evaluates `status_check_contexts` on the PR merge path, so before this the whole gate was skippable with no forgery needed. The gate was then hardened through the rest of its false-open chain: CI's registry credential scoped so a head-resolved workflow cannot forge the status (#697), a stray expression delimiter **in a comment** that killed the gate outright and is now banned on the release path itself by a `scan` job `build` depends on (#751/#767), a positive verdict inherited only from an allow-listed reviewer (#742), the PR-timeline `HEAD` alias fenced on the `pull_push` count (#803/#664), and both `/statuses/{sha}` reads paged to a validated terminator (#763). App-facing: **FFmpeg profile numeric fields are bounded with a 422 that names the bound and its consequence** instead of being silently rewritten, and readrate pacing is exposed as a profile field (#735 — one dual-provider migration, `Add_FFmpegProfile_ReadRatePacing`). **On Now / Next gets a boxed background and is on by default** (#732). Field-level progressive disclosure lands as a shared `FieldHelp` trigger + portalled panel, adopted on FFmpeg Profiles and documented as `docs/spa-conventions.md` §15 (#734). Auto-Tune proposal rows align on a grid (#721); `AddItemsDialog` resolves by search instead of windowing a whole media-library type, with stale-response and is-mounted guards (#685/#740). Correctness: a paged handler now counts **the same query it pages**, so a filtered page stops reporting the unfiltered total across playouts, rerun collections and six sibling handlers (#690/#758); nullable primitive collections are guarded at the read site rather than mutated on a tracked entity — `SongMetadata.Artists` in both search indexers (#701/#824) and the scheduling `DaysOfMonth`/`MonthsOfYear`/`DaysOfWeek` columns, where a runtime NULL reads as **unrestricted** (#823); SPA full-replace bodies are built as `Complete<T>` so an omitted optional DTO field is a type error (#807); and every `ErsatzTV.Mcp` tool is pinned to its OpenAPI contract, with `graphicsElementIds` and `padToNearestMinute` declared so an MCP channel update stops silently stripping the overlay (#754/#757). Process/CI: one rule for how a guard derives its population and one for the proof it ships with, both enforced (#774/#775) — populations come from the git index, never a filesystem walk (#806/#819), every `MUTATION` grade is executed each suite rather than asserted in prose (#790), and verification code is itself code under test (#796). Workflows declare their own per-job metadata and guard populations derive from it (#786/#789); all six declare `permissions:` (#748); `ci-image.yml` publishes from `main` only and the persisted checkout credential is gone (#744/#746/#835). Hooks report their own execution and the report is measured (#776), and `docs.no-session-narrative` was generalized to every durable artifact and the sites its sweep IDENTIFIED were remediated as judgements rather than by regex — no closure is claimed over a phrasing space (#784/#812). A ruff config is committed and enforced in CI (#780). Dependencies: Scriban.Signed 7.2.6. **Cut at `736649b3b`, not at `main`'s head**: `completeAnnotations.guard.test.ts` (merged in the very next commit) needs a git index the Dockerfile's `web-build` stage does not have, so every image build after `736649b3b` fails — including a tag build (#887). `736649b3b` is the newest commit whose `Build & push image (amd64)` actually ran green. The scheduling-recurrence fix #880 therefore slips to the next release. |
**Before cutting a release — sweep `docs/decisions.md` + `docs/decisions/`** (ersatztv#521, supersedes
the ersatztv#303 H9 append-only ritual). Supersession/retirement is now a same-PR act (add the new
@@ -100,6 +101,19 @@ commit in the range (see the `decisions.md` header) — routine lifecycle metada
1. Confirm `main` CI is green; run the full local gate plus `dotnet list package --vulnerable
--include-transitive`; then push a `vYY.N.P` tag on that exact `main` commit.
> **`v*` tags are protected — push as `timothy`.** Since ersatztv#885 the repo carries one
> tag-protection rule (`name_pattern: v*`, `whitelist_usernames: ["timothy"]`,
> `release.tag-protection-v-star`), because a `v*` push builds and publishes `:prod`. A push from
> any other account is expected to be refused — configured, not exercised, since only the
> `timothy` credential is available here. **The positive half is UNVERIFIED too**: the rule was
> applied 2026-09-04 and only its negative half was measured (a non-`v*` tag still pushes), because
> pushing a `v*` tag outside a real cut would publish `:prod`. This cut is its first real
> exercise. If the tag push is refused, unblock with
> `DELETE /api/v1/repos/timothy/ersatztv/tag_protections/1`, push, then re-`POST` the rule
> (prefer a `PATCH` adding the account if the cut has moved to a different operator, so the
> protection is never left off) — and record the outcome on `release.tag-protection-v-star`,
> which is waiting for it.
2. Wait for tag CI to build `:prod` + the immutable `:<version>` + `:<sha>` images. Run
`scripts/security-scan.sh` on jazz against **the immutable `:<version>` image**, not a
moving tag, and triage every ZAP/semgrep finding.
@@ -169,9 +183,15 @@ grants `releases: write`, which nothing here uses. The two read units are not sp
`/issues/{n}/timeline` (the ersatztv#706 retarget fence) and, through
`scripts/pr-changed-files.sh`, `/pulls/{n}` and `/pulls/{n}/files` (the ersatztv#698 exemption path).
No workflow needs a `packages:` unit. All five `container:` blocks in `docker-build.yml` carry
explicit `credentials:` using the scoped `REGISTRY_*` PAT, so no job pulls the toolchain image with
the injected token.
No workflow needs a `packages:` unit. Since ersatztv#885 the five `container:` blocks in
`docker-build.yml` carry no `credentials:` at all: they run on the `pull_request` route, so they may
hold no stored secret, and the runner pulls the toolchain image ANONYMOUSLY — this registry issues a
pull token for a public package with no credential (measured at the HTTP level 2026-09-04, and at the
daemon level 2026-09-05 — a `docker pull` of the pinned tag with an empty docker config succeeds on
the runner host, which is the same daemon that creates the job containers; see
`ci.pr-route-carries-no-stored-credential`, whose `mechanics:` names both measurements and the two
things neither exercises). So no job pulls that image with the injected
token, and none pulls it with the `REGISTRY_*` PAT either.
### The binding was measured, not assumed (2026-08-27, Gitea 1.27.1)
@@ -313,7 +333,9 @@ further back than N. Measured 2026-08-26: a 150-commit repo branched 130 back, `
returns the correct file list. Under the old `|| true` that surfaced as a silent skip; unmasked it
would have reddened `api-docs` and `Formatting` on any branch not rebased recently, sent
`docs-reminder`'s parity nudge advisory-red, and left `decisions lifecycle` green with its
diff-dependent checks silently skipped — so the depth came off with the mask. The same graft is why `:latest` images ship `InformationalVersion 0.0.0-<sha>` (ersatztv#836).
diff-dependent checks silently skipped — so the depth came off with the mask. The same graft,
reached through the detector script rather than a workflow step, is what stamped `:latest` images
`InformationalVersion 0.0.0-<sha>` until ersatztv#836 — see below.
**Two masked fetches that stay masked, deliberately.** `scripts/ci-detect-docs-only.sh` and
`scripts/ci-detect-already-validated.sh` both swallow a failed fetch — and should. In both, a failed
@@ -323,14 +345,38 @@ runner time and nothing else, which is the opposite direction from the workflow
the same masking bought a silently skipped check. Judge these by which way the failure falls, not by
the `|| true`.
That clears their **masking** and nothing else. Their `--depth` is a separate question with a
separate answer: `ci-detect-docs-only.sh`'s push-path `--depth=2` runs inside the `build` job's
`fetch-depth: 0` checkout and grafts it shallow, which is why the `git describe` in the immediately
following step returns nothing and `:latest` ships `InformationalVersion 0.0.0-<sha>` — measured on
the live test container as `0.0.0-5a7a4ed7` (ersatztv#836). That depth is correct for the script's
other three consumers — `test`, `migrations` and `functional-e2e` — whose checkouts really are
shallow (`fetch-depth: 2`). `build` is the only one that checks out complete, which is why it is the
only one that grafts.
That clears their **masking** and nothing else. Their `--depth` was a separate question with a
separate answer, and it is the one that shipped a defect (ersatztv#836).
**A depth is passed only to a checkout that is already shallow.** `ci-detect-docs-only.sh` routes
both its fetches through `fetch_ref`, which asks `git rev-parse --is-shallow-repository` and drops
the `--depth` on any answer but `true`. The script has **exactly four consumers** — `test`,
`migrations` and `functional-e2e` at `fetch-depth: 2`, and `build` at `fetch-depth: 0`. Enumerate
them with `grep -rn ci-detect-docs-only .gitea/`, not by looking for a step with `id: detect`:
`api-docs` and `format` have one of those too, and it runs their own inline diff.
So `build` is the only complete-clone consumer, and the push arm is the only arm it takes. That is
where the cost landed: its `--depth=2` grafted `build`'s complete clone, the `git describe --tags`
in the immediately following step found no reachable tag, and a `|| echo v0.0.0` fallback that
cannot fail turned that into a version. Every `:latest` image published from `main` carried
`InformationalVersion 0.0.0-<sha>` instead of `26.x.y-<sha>` — read off the live test container as
`0.0.0-5a7a4ed7` on 2026-08-26, six weeks after the depth landed with #416 on 2026-07-17, because
nothing went red.
The PR arm's `--depth=200` had **no complete-clone consumer at all** (on `pull_request` only the
three shallow jobs run this script), so it was inert rather than latently firing. It goes through
the same helper anyway, because the clause is shared and the next `fetch-depth: 0` consumer of that
arm would inherit the graft silently. Keeping the depth for the shallow three is likewise
conservative rather than required: a depth-less fetch into a `--depth=2` clone leaves `.git/shallow`
in place, still writes `FETCH_HEAD`, and still reports the parents the push arm reads.
Both halves are now held by something that executes. `scripts/tests/test_docs_only_detector_clone_depth.py`
drives the real script over real `file://` clones and asserts the clone is still complete and
`git describe` still resolves afterwards, with a negative control that requires the fixture to be
able to graft at all — a plain path clone silently ignores `--depth`, which would make the whole
file vacuous. And `Compute version and tags` no longer defaults: a failed `git describe` on the
non-tag path fails the job rather than stamping `0.0.0`, so no `:latest` is published instead of a
mislabelled one. The tag path never calls `describe`, so a release cut is unaffected either way.
### The last exemption, and how it closed
@@ -709,7 +755,11 @@ the image build.
for `192.168.1.95:3000` — **BuildKit does not inherit the host daemon's
`insecure-registries`**, so without this, cache/base-image/push over the HTTP
registry fails (`http: server gave HTTP response to HTTPS client`).
3. `docker/login-action` with repo secrets `REGISTRY_USER` / `REGISTRY_PASSWORD`.
3. `docker/login-action` with repo secrets `REGISTRY_USER` / `REGISTRY_PASSWORD`. **`build` is the
ONLY job in this workflow that names them** (ersatztv#885,
`ci.pr-route-carries-no-stored-credential`): it is gated `if: github.event_name != 'pull_request'`,
and every job that IS on the PR route now reads the registry and the status API anonymously,
because a `pull_request` run executes head-supplied YAML.
**`REGISTRY_PASSWORD` is a scoped PAT (`write:package` + `read:repository`), not an account
password** — deliberately, so head-resolved PR code cannot use it to forge a commit status
(`ci.actions-credential-scoping`, ersatztv#697). If a job ever fails with `token does not have at
@@ -874,8 +924,11 @@ the docs-only detect), and every heavy step gains an added `&& steps.revalidate.
squash, rebase, fast-forward, or a direct push have no `HEAD^2`, so they run);
- `git rev-parse HEAD^{tree}` equals `HEAD^2^{tree}` — main did not advance since the PR's last run,
a byte-identical tree;
- `HEAD^2` has a **green Gitea combined commit status**, queried via the API with
`ETV_STATUS_AUTH`. Trusting the aggregate `.state` is sound: a `skipped` context does **not** drag
- `HEAD^2` has a **green Gitea combined commit status**, queried via the API **anonymously**
(ersatztv#885: the three jobs that call `scripts/ci-detect-already-validated.sh` also run on the
`pull_request` route, so they hold no stored secret; `timothy/ersatztv` is public and answers the
combined-status GET without a credential — measured 2026-09-04). Trusting the aggregate `.state`
is sound: a `skipped` context does **not** drag
the combined state below `success` (verified live against this instance — a real merge commit with
four `skipped` PR-only contexts still reported `.state == success`), and the two required jobs
never report `skipped` (they always run and report a real `success`/`failure`), so
@@ -1121,8 +1174,10 @@ one case that's easy to forget and easy to detect: a PR that touches a SPA scree
`::warning::` annotation.
**2. The session-narrative reminder** (ersatztv#784) runs `scripts/check-doc-narrative.py --diff`
over the lines this PR **adds** to `docs/**/*.md` (minus `docs/decisions/**`, exempt wholesale) and
root-level `*.md`, flagging text that narrates the document's own revision history —
over the lines this PR **adds** to `docs/**/*.md` (minus `docs/decisions/**`, exempt wholesale),
root-level `*.md`, and — since ersatztv#876 — every tracked file under `.claude/`, `.gitea/`, `.husky/`
and `scripts/` regardless of extension (minus the detector and its own test, whose fixtures are the
phrasings), flagging text that narrates the artifact's own revision history —
`docs.no-session-narrative`. It is advisory **by design and permanently**: a narrative detector is a
string predicate over prose, the class `docs/defect-shapes-773.md` §4 argues must never be
load-bearing, so the script exits 0 on every path including a bad argument or an unresolvable base
@@ -1228,11 +1283,15 @@ genuine pytest regression surface as precisely the red everyone is told to wave
name keeps a real failure unambiguous.
**Why it runs unconditionally** rather than behind a `scripts/**` path filter: the suite's true
input set spans more than one directory — `test_post_review_verdict.py` and
input set spans AT LEAST three top-level directories. `test_post_review_verdict.py` and
`test_merge_consent_exemption.py` execute the real `scripts/post-review-verdict.sh` and
`.claude/hooks/pretooluse-merge-consent.sh` — so a `scripts/**` filter would silently miss a
`.claude/hooks/**` edit. The reason is the input set, not the cost: the suite was ~10s when that was
decided and is now ~4.5 min, and it would still be wrong to filter on `scripts/**`.
`.claude/hooks/pretooluse-merge-consent.sh`, and since ersatztv#845 the former also READS
`.gitea/workflows/review-verdict.yml`, because the writer derives the H10 allow-list from it and
editing that literal changes the suite's outcome. So a `scripts/**` filter would silently miss a
`.claude/hooks/**` or `.gitea/workflows/**` edit. The hedge is deliberate: the corpus tests read
`docs/` too, so the real figure is higher, and an exact count here is the kind that goes stale.
The reason is the input set, not the cost: the suite was ~10s when that was decided and is now
minutes, and it would still be wrong to filter on `scripts/**`.
**It also lints (ersatztv#780).** Early in the job it installs a **pinned** `ruff==0.12.11` and runs
`ruff check` and `ruff format --check` against the repo-root `ruff.toml`. Five things are deliberate:
@@ -1399,8 +1458,14 @@ re-opens the hole this section exists to close.
Practical consequences: **every** change to `main` goes through a PR, including a one-line docs fix;
and the client-side Husky guards (H6/H11/H13) remain useful friction but were never the control —
they are fail-open and `--no-verify` bypasses them. Tag pushes are unaffected (separate mechanism;
`tag_protections` is empty), so the release cut in "Cutting a release" still works unchanged.
they are fail-open and `--no-verify` bypasses them. Tag pushes go through a separate mechanism and
are meant to stay unaffected for the release operator: since ersatztv#885 `tag_protections` carries
one rule, `v*` whitelisted to `timothy` (`release.tag-protection-v-star`), which is intended to leave
the release cut in "Cutting a release" working unchanged while refusing a `v*` tag from the `renovate`
bot — a push that would otherwise have published a `:prod` image. Both halves of that are EXPECTED,
UNVERIFIED: only the `timothy` credential exists here, so neither a real release cut nor a refused bot
push has been exercised. `release.tag-protection-v-star` carries the same caveat and waits on the
next cut.
**A fourth arm: guard-scope freshness (ersatztv#787).** `scripts/tests/test_ci_dropped_step_guard.py`
decides which CI jobs must carry per-step execution markers by DERIVING that scope from
@@ -1563,8 +1628,9 @@ membership).
falls through to re-derivation.
- The **last-moment re-read** reads `ex_attributable`, because it asks the opposite question — "did a
reviewer post a verdict while we were classifying". Narrowing it makes the job stop abstaining and
post its exemption over the row, and the post-write repair does not cover that: it is skipped
whenever the high-water mark could not be established. It additionally requires the
post its exemption over the row, and the post-write repair would not cover that — it is skipped
whenever the high-water mark could not be established, which since ersatztv#849 is also the case in
which no exemption is posted at all. It additionally requires the
state/creator/description triple to have *changed* since the first read, because the two calls no
longer compute an identical predicate and "changed" can no longer be inferred from "fired".
- The **post-write raced check** stays broad on `creator != null`. Not because narrowing it would let
@@ -1586,12 +1652,45 @@ account to `H10_REVIEWERS` in `.gitea/workflows/review-verdict.yml`. (A separate
annotation carrying the same three values was tried and withdrawn: its only delta was a severity whose
rendering could not be probed, and it needed a guard of its own to stop contradicting the line above
it.) `post-review-verdict.sh` posts
with whatever account owns `ETV_GITEA_TOKEN`/`ETV_GITEA_BASICAUTH` and does **not** check that it is an
allow-listed one (ersatztv#845). A changed writer credential does not fail loudly — it reports success,
and each *subsequent* PR event re-derives the status: to `pending` on a PR that needs a verdict, or to
an exemption **`success`** on a docs-only or Renovate-manifest one, which is the quieter and worse
half. A verdict posted after the last event stands until the next one, so the symptom is a PR that
keeps losing its verdict rather than an immediate refusal.
with whatever account owns `ETV_GITEA_TOKEN`/`ETV_GITEA_BASICAUTH`, and since ersatztv#845 it
**checks that it is an allow-listed one** — at the terminal that made the mistake, not one PR event
later. It reads the status back after posting, identifies its own write by state and description, and
refuses **before writing the comment** unless `.creator.login` is a member; a `creator: null`
(Actions-token) status gets its own message, since there is no account to name. The allow-list is
DERIVED from the `H10_REVIEWERS` literal in `review-verdict.yml` by `scripts/lib/h10-reviewers.sh`,
not restated beside it, so there is one declaration rather than two plus a parity test.
**The membership requirement is `success`-only, mirroring the gate's own asymmetry.** An existing
`failure` is inherited on ATTRIBUTABILITY alone, whichever account recorded it, so requiring
membership for a `BLOCKED` verdict would refuse one the gate honours perfectly well — and would
leave an off-list reviewer no supported way to record a rejection, since the comment would be
withheld too. A negative verdict from any attributable account is written and commented normally.
What that changes is the *diagnostic*, not the safety direction: a non-allow-listed POSITIVE verdict
was always a stall, never a fail-open. Before, a changed writer credential reported success and each
*subsequent* PR event re-derived the status — to `pending` on a PR that needs a verdict, or to an
exemption **`success`** on a docs-only or Renovate-manifest one, which is the quieter and worse
half — so the symptom was a PR that kept losing its verdict rather than an immediate refusal. Now it
is an immediate refusal naming the account and the allow-list.
**The residual, stated precisely, because "the `ask` half-state" describes only half of it.** The
check runs after the POST, so a refused verdict leaves a `review-verdict/h10` **standing on the
head** with no comment beside it. On the *hook* path that is the safe half-state
`release.verdict-writes-status-before-comment` designates — nothing for condition (c) to classify,
so it asks rather than granting. On the *server* path it is weaker: branch protection binds the
context NAME and not its issuer, so a `success` posted by any account satisfies the required check
until the gate re-derives it on the next PR event. **A refusal from this tool is not a reason to
trust the green it leaves behind.** That is not a regression — it was equally true before #845, when
the tool reported success and said nothing — and it is deliberately not repaired here, because a
second corrective write is the sticky-sentinel mechanism ersatztv#849 is separately designing.
**One false ACCEPT, named because the direction matters.** The writer reads the workflow in the local
checkout while the gate runs the base-resolved one, so on a branch that ADDS a reviewer the writer
accepts an account the live gate will not yet inherit from — the #845 stall, reached through the
check meant to catch it, on the one branch where someone is editing the allow-list. Accepted:
closing it means a live remote read on the merge-consent write path, whose own read failure would
have to resolve either to refusing every verdict or to passing. The window is one PR long and the
residual is a stall, never an unreviewed merge.
This remains a *provenance* check, not an authentication one — an **allow-listed** collaborator's own
write-scoped token can still POST a verdict they did not form, branch protection binds the context
@@ -1626,20 +1725,34 @@ payload, which a retarget cannot rewrite, and `edited` is in `types:` so a retar
`edited` gives **detection, not atomicity**: runs are not serialized, so a stale run could still post
`success` after the reclassifying run posted `pending`.
**That residual is now fenced — NARROWED, not resolved (ersatztv#706; correction ersatztv#849).** The fence never re-counts *after* the POST, so a retarget landing between its final pre-write count and the write still yields a **permanent** forged green: the successor run consumes the `edited` event and exits on the existing status, and the stale run then posts last with nothing left to correct it. Runs are still not serialized — instead a run that was
overtaken *declines to write*. The job counts `change_target_branch` events on the PR's issue timeline
**That residual is now fenced — NARROWED, not resolved (ersatztv#706; correction ersatztv#849).** The pre-write fence does not re-count *after* the POST, so a retarget landing between its final pre-write count and the write used to yield a **permanent** forged green: the successor run consumes the `edited` event and exits on the existing status, and the stale run then posts last with nothing left to correct it. Since ersatztv#849 a SEPARATE post-POST re-count (below) makes that green transient rather than permanent. Runs are still not serialized — instead a run that was
overtaken *declines to write its classification*. The job counts `change_target_branch` events on the PR's issue timeline
at start and again immediately before its POST, and posts **nothing** if the count moved. The count is
the key precisely because the branch *name* is ABA-vulnerable: `main → scratch → main` reads `main` at
both ends, which is how the forged exemption was obtained in the first place. Abstaining never strands
a PR, because every retarget fires `edited` — the event that makes one run abstain has already queued
its successor.
If the count can't be established (unreadable timeline, paging that never reached a validated empty
page), only the exemption `success` is withheld; `pending` still posts, since it blocks the merge
If the count can't be established (unreadable timeline, an empty FIRST page, or paging that reached
the 20-page cap on a NON-empty one — since #870 an empty page before the cap no longer ends the walk),
only the exemption `success` is withheld; `pending` still posts, since it blocks the merge
immediately and withholding it would strand ordinary PRs whenever the timeline is unreadable. That is
the right trade but not a free one — a generic `pending` can mask a rejection landing in its own write
window and be re-derived into an exemption `success` later (ersatztv#849). **If an exempt PR is
unexpectedly missing its status after a retarget, this is why** — the job log names the counts.
the right trade but not a free one — a generic `pending` still masks a rejection landing in its own
write window. What made that durable is gone: since ersatztv#849 the post-write check runs after
**every** write, so such a write is repaired to the sticky sentinel instead of being re-derived into
an exemption by a later run. **If an exempt PR is unexpectedly missing its status after a retarget,
this is why** — the job log names the counts.
**The count is also re-taken AFTER the POST, on the exemption path only** (ersatztv#849,
`ci.verdict-unverified-write-sentinel`). The pre-write fence covers *writing while overtaken*; it never
covered *being overtaken after writing*, which was the worse of the two — a retarget landing after the
final pre-write count left a stale `success` with the `edited` event already consumed by a successor
that short-circuited, so nothing remained to reclassify. It makes that green **transient rather than
permanent**, not absent: the `success` is live between its POST and the repair, so branch protection
or a scheduled auto-merge can still observe it. Closing that window needs a compare-and-set the API
does not offer. The retarget axis only: a push after the POST
moves the head, so the status no longer gates that PR, while a retarget changes the effective diff with
the sha unchanged.
Worth knowing before reaching for the obvious alternative: **a concurrency group does not work here**,
measured rather than assumed. Gitea 1.25.4 auto-cancels superseded `push` runs on a branch, but *not*
@@ -1668,16 +1781,70 @@ arranging two real merges, or adding a throwaway trigger; both cost more than th
because nothing branches on it. The `pull_request_target` half the fence actually relies on was **not**
re-measured either and is 1.25.4-dated too.
Separately, after posting an exemption `success` the job re-reads the per-POST status history and, if
a human `Review-verdict:` row appeared during the write window, overwrites its own status with
`pending` and logs an error. **That check runs only for a `success` write**, so it does not extend to
a generic `pending` write: that write is not verified, and a rejection it masks is re-derived green
by a later run (ersatztv#849). The repair is
`pending`, never a copy of the human's verdict, which would attribute a human decision to the job.
The two facts this paragraph rests on — that no workflow triggers on a push to a non-`main` branch,
and that `main` refuses direct pushes — were **re-checked on 2026-09-02 (ersatztv#869)** and both still
hold. Note what that does and does not cover: it re-checks the REASON, not the claims. The
`push`-supersession and `pull_request_target` measurements above remain 1.25.4-dated and were not
re-run by #869 either. Re-checking the reason matters on its own, because a reason decays exactly like
the claim it excuses. One limit is worth stating so this is not over-read: the enumeration covers
`push:` triggers only. Four of the six workflows carry `workflow_dispatch:`, so a disposable run can
still be raised on demand — what is gone is the cheap scratch-BRANCH push probe, not every route to a
throwaway run.
**Both `/statuses/{sha}` reads are PAGED** (ersatztv#763). The history is read twice — before the write
for the high-water mark, after it for the race check — and `limit` clamps to `MAX_RESPONSE_ITEMS`
(measured 50), so a single read of a busy head returned a partial list.
Separately, after posting **any** status the job re-reads the per-POST status history and, if a human
`Review-verdict:` row appeared during the write window, overwrites its own status with `pending` and
logs an error. It ran only for a `success` write until ersatztv#849, on the claim that a `pending`
cannot turn a rejection green — false, since a generic `pending` masks a rejection just as well and,
carrying no marker, is re-derived into an exemption by the next run with the human's row now below
THAT run's high-water mark. The repair is `pending`, never a copy of the human's verdict, which would
attribute a human decision to the job.
**Which sentinel the repair writes is part of the answer.** There are two, and they are not
interchangeable (`ci.verdict-unverified-write-sentinel`):
- `Human verdict raced this exemption write — re-post the verdict` **asserts** that a verdict existed
and a write buried it. Only the arm that actually *counted* such a row may claim it, and only a
human re-posting clears it.
- `Status write could not be verified — re-post the verdict` states only what was established —
that the write could not be checked. Every "could not check" arm writes this one: an unreadable or
over-cap history, an impossible empty history, an unusable count, no high-water mark at all, a
post-POST retarget, and an unreadable combined-status read. It is equally sticky, and additionally
**reconcilable**: a later run pages `/statuses/{sha}` in full and either finds a verdict underneath
it — upgrading to the repair sentinel — or finds none and clears it, so a transient API failure does
not cost a head its exemption permanently.
**No high-water mark means the write becomes the sentinel** — every re-derivable state, not only the
exemption — withheld before the POST rather than posted and repaired: the defect is known in advance,
and publishing a green to take it back opens a window branch protection, and an already-scheduled
auto-merge, can see. A generic `pending` is included because it is exactly what a later run
re-derives; `$REPAIR_DESC` is not, being the stronger fact.
**Every path that cannot establish what the head carries REPLACES the unknown state** rather than
merely declining to write: the combined read (retried once first), all four page-2 completeness
refusals, a row or consumed field whose type the schema does not allow, the path-predicate failure,
the fence branch that cannot trust its retarget count while holding a derived `success`, and the two
OBSERVED-mutation arms when the head carries a row the run did not inherit. Declining protects a real verdict and leaves a *forged* one standing, which is what an
off-list `success` is; the job went red on a status branch protection does not read, and the fence
branch was reached only after the classification had DECLINED to inherit the very row it then left
current. Nothing is destroyed — `/statuses/{sha}` keeps one row per POST, so the next run's
reconciliation finds a masked verdict and tells the reviewer to re-post. Writing the sentinel and
failing the job are separate decisions: the read refusals were already non-zero exits and stay red,
the fence branch exited 0 and still does.
**Reconciliation needs a witness.** It may clear the sentinel only over a complete history that
CONTAINS the sentinel's own row, matched by the `id` the combined read reported — degrading to a
DESCRIPTION match, which identifies a row with the same text rather than that row, only where the
server omits `id`. `ex_unverified` means the combined endpoint just returned that row
and `/statuses/{sha}` keeps one row per POST, so a complete-but-empty history contradicts a write
that demonstrably happened — and `page_statuses` accepts an empty page 1 as complete, which is what
made the shape reachable. The cost lands where the replacement actually cost something: on an
APPROVED PR, reconciliation finds the masked verdict and upgrades to the repair sentinel, so the
recovery is "you are asked to re-post", not "it clears itself".
**Every `/statuses/{sha}` read is PAGED** (ersatztv#763). The history is read twice on an ordinary
run — before the write for the high-water mark, after it for the race check — and a THIRD time on a
head carrying the unverified sentinel, for the reconciliation (ersatztv#849). `limit` clamps to
`MAX_RESPONSE_ITEMS` (measured 50), so a single read of a busy head returned a partial list.
**Be precise about what that cost** — ersatztv#763's framing of it is too strong. Under the server
default (`created_unix DESC`) page 1 holds the *newest* rows and ids are monotonic with `created_at`,
@@ -1695,6 +1862,18 @@ validate at most 950 rows, since the twentieth must be the empty terminator. The
per endpoint and they differ: `/statuses/{sha}` returns `[]`, `/issues/{n}/timeline` a bare `null`,
`/commits/{sha}/status` an object with `statuses: null`.
**The two walks terminate by OPPOSITE rules, and that asymmetry is deliberate** (ersatztv#870, settled
by ersatztv#893 on 2026-09-02). `count_pr_mutations` reads the timeline to its cap and trusts the
counts only when the LAST page came back empty; `page_statuses` still returns on its FIRST empty page.
They differ because the ENDPOINTS differ, established from the v1.27.1 source: `getCommitStatuses`
serializes every row the paged query returns — an unconditional `append` loop, the only filter being a
SQL `WHERE` the database evaluates in the same query as the `LIMIT`/`OFFSET` — so a serialized page is
exactly as long as its database page, and an empty page really is the end. `ListIssueCommentsAndTimeline` instead drops
`CommentTypeCode` rows AFTER paging, which is what makes a fully filtered page byte-identical to the
end of the list. The same source explains the header: `/statuses/{sha}` sets `X-Total-Count` from a SQL
COUNT, so it is a true total (page 1 of 50 on a head reporting 63, measured 2026-09-02), where the
timeline reports its post-filter page length. **Do not "tidy" the two walks into agreement.**
**The page-2 probe is gone.** ersatztv#751 treated "there are rows I did not read" as "assume raced".
That fired on Renovate PR #761: a head that grew past one page over ordinary CI re-runs had its
exemption repaired away, with an `::error::` asserting a human verdict was overwritten when the head
@@ -1772,8 +1951,10 @@ as establishing that the gate cannot be forged (see the residual below, and ersa
`docs/decisions/records/ci/gate-trigger-base-resolved.md`. **This closes the rewrite route through
this workflow, not the class:** `docker-build.yml` is also head-resolved and must stay on
`pull_request` because it builds the PR's code, so it got the read-only status identity instead —
its `ETV_STATUS_AUTH` is now a PAT scoped `write:package` + `read:repository`, which the status
endpoint refuses (`ci.actions-credential-scoping`, ersatztv#697). The inventory was never that one
the `ETV_STATUS_AUTH` it passed was a PAT scoped `write:package` + `read:repository`, which the
status endpoint refuses (`ci.actions-credential-scoping`, ersatztv#697). Since ersatztv#885 it
passes no credential there at all: the status read is anonymous, so the PR route materialises
nothing to refuse. The inventory was never that one
workflow, though: Gitea injects a write-capable `GITEA_TOKEN` into every job and branch protection
binds the *context*, not its issuer. Gitea >=1.26 with the Actions default set to **Restricted**
(server-management#714) binds the injected token, but does not close the class either — not against
@@ -1898,14 +2079,28 @@ Two things kept it invisible, and both are worth generalising:
fidelity, that claim is a test assertion and needs re-measuring like any other.
The type is now read as a value (`case` over `jq -r 'type'`) rather than through `jq -e`, whose
exit-status semantics already bit this workflow once at jq 1.6, and both `null` and `[]` terminate the
walk. The regression test is parameterised over both shapes because both are live on this server.
An empty page — `null` OR `[]`, which take the SAME rule since ersatztv#803 — is accepted as
exhaustion only from **page 2 on** — every real PR's first page carries events
(spot-checked non-empty across #752/#753/#749/#739/#717; the counts are deliberately not recorded here
because timelines grow and such figures go stale within days), so an empty first
page is ANOMALOUS rather than EXHAUSTED, and the walk should not certify "no retarget happened" from a
response it cannot explain.
exit-status semantics already bit this workflow once at jq 1.6, and both `null` and `[]` are read as
the same EMPTY shape. The regression test is parameterised over both because both are live on this
server. An empty page — `null` OR `[]`, which take the SAME rule since ersatztv#803 — is **no longer
accepted as exhaustion before the page cap** (ersatztv#870, 2026-08-30). Gitea pages this endpoint at the database level and
filters afterwards, so a page whose 50 rows are all inline-code comments serializes as exactly the
value the walk used to trust as the end of the list, while later pages still hold events — 50 comments,
which a PR author can create on their own PR, bought silence on both axes. The walk now SKIPS an empty
page before the cap, reads every page up to its 20-page cap, and trusts the counts only when the LAST
page it read came back empty — the same rule the status walk above states for the CAP, though not for termination: that walk still returns on its first empty page, and whether it needs the same change is tracked at ersatztv#893 rather than assumed (measured 2026-08-30, `/statuses/{sha}` returns a TRUE `X-Total-Count`, which this endpoint does not). **That
moves the inference to the cap rather than removing it, and the residual is real**: pages 1-19 cover
offsets 0..949, so a timeline of more than 1000 rows whose offsets 950..999 are all filtered still
returns an empty page 20, and the walk publishes a too-low count as trusted. Both re-reads share the
blind spot, so this is the same defeat at about 10x the price — the 50-row filtered block is the
same, but the timeline it must sit in grows from ~100 rows to over 1000, and on their own PR the
padding is the attacker's to supply — not a closed hole. It is NOT the status walk's over-cap residual: `/statuses/{sha}` does not
filter after paging, so only here can a page-20 response be empty while later pages hold rows. An empty
FIRST page still ends the walk untrusted: every real PR's first page carries events (spot-checked
non-empty across #752/#753/#749/#739/#717; the counts are deliberately not recorded here because
timelines grow and such figures go stale within days), so an empty first page is ANOMALOUS rather than
EXHAUSTED, and the walk should not certify "no retarget happened" from a response it cannot explain.
`X-Total-Count` cannot shortcut this: on this endpoint it is the post-filter length of the PAGE, not a
total (`?limit=1` returns 1 on a 14-row timeline), unlike `/activities/feeds`, which does return one.
**The same nil-slice shape bites `/commits/{sha}/status`** — a third instance. A head with no statuses yet returns
`{"state":"pending","total_count":0,"statuses":null}` (measured on PR #739's head). `read_existing_verdict`
@@ -2090,7 +2285,8 @@ like every other line in the file, so a branch that *deletes* it re-enables the
What is closed is the **drive-by** case: an ordinary push of a legitimate `docker/ci` change
publishing an image nobody asked for, with no deliberate act anywhere in the loop. Against someone
who intends to run their own YAML this is not a boundary at all, and citing it as one is the mistake
to avoid — see #853.
to avoid — see #853, which settled its DISPATCH row; the `v*` tag-push and `pull_request:` rows are
live in #885.
**What this does not close — beginning with this file's own filter, then sorted by REF CLASS
rather than by which keyword a trigger carries.**
@@ -2101,7 +2297,37 @@ that commit's `docker-build.yml` and can publish `ersatztv:prod` — no dispatch
`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml` and `renovate.yml` all carry an
unrestricted `workflow_dispatch`, which loads its definition from the selected ref. The filter here
removes the *drive-by* route, where merely pushing a branch was enough; it is not a boundary against
a malicious writer. The whole remaining class is tracked in #853.
a malicious writer.
**That remaining class was probed and ACCEPTED in #853 — read this before proposing a fix for it.**
Gitea 1.27.1 has no way to restrict `workflow_dispatch` by ref (the API takes `ref` as a required
free-form string) and no protected-environment concept to gate a secret behind — zero of its 308
documented API paths mention "environment", and the config file the running server names in its own
`--config` sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`. More to the point,
restricting dispatch would close nothing: the `pull_request:` row above is cheaper than any
dispatch, because it runs **attacker-authored YAML**, which can name any secret in the repo store —
not only the ones the committed workflows reference. That is what makes it cover `renovate.yml`'s
`RENOVATE_TOKEN` without dispatching `renovate.yml` at all. (`dependency-scan.yml` references no
secrets at all.) Full reasoning and the probe: `ci.workflow-dispatch-ref-unrestricted`.
Six jobs in `docker-build.yml` used to hold `REGISTRY_PASSWORD` on that route, two of them required
contexts. Since ersatztv#885 **no job on the `pull_request` route names any stored secret**
(`ci.pr-route-carries-no-stored-credential`): the toolchain image is pulled through the registry's
anonymous bearer-token flow and the commit-status API is read unauthenticated, leaving the PAT to
`build`, which the route cannot reach. The invariant is held by
`scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`,
which derives the population from the git index by the predicate *every job of a
`pull_request`-triggered workflow that names a `secrets.*`* — never a list, and never "every
`container:` job" either, which names five of the six because `toolchain-preflight` is
container-free. The `v*` tag-push row is closed against the bot by `release.tag-protection-v-star`.
What that does **not** close, and must not be read as closing: `REGISTRY_PASSWORD` is still in the
repo's Actions store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or
`SERVERMGMT_DEPLOY_KEY` in a job of its own. "Push a branch, open a PR" still reaches every secret in
the store with **no act outside the ordinary contribution flow**. Bounding the store needs
per-environment secret scoping, which Gitea 1.27.1 does not have. What is removed is the routine
materialisation of a write-capable credential into six PR-run environments — blast radius, not the
route.
**A `v*` tag push does not fire `ci-image.yml`.** There is no `tags:` key, and a `branches:` filter
is compared against a branch ref — so a release cut no longer republishes the toolchain image as a
@@ -2150,7 +2376,14 @@ through a rebase.
> recovery — are #854, deferred on exactly these grounds.
**A change that lives only in `ci-image.yml` publishes nothing.** That file is no longer one of its
own trigger paths, nor one of `ci-image-pin`'s `expected` paths. That pairing is a **decided
own trigger paths, nor one of `ci-image-pin`'s `expected` paths. Those two lists must otherwise name
the same sources, and `scripts/tests/test_ci_image_paths_pin_agreement.py` holds them to it — it
derives both from the workflow documents and compares them for set equality, so editing one alone
reddens `script-tests`. That job is not a required context, so the red does not block server-side; it
denies the merge-consent auto-grant through the combined status. **Read that guard before taking the
reversal described next**: it models a `<dir>/**`-against-`<dir>` pair only, so putting
`ci-image.yml` back into both lists is a file-against-file pair it refuses, and would need a file arm
added there first (ersatztv#855). That pairing is a **decided
tradeoff, not a necessity** — keeping the file in `expected` still works, since the branch dispatch
can publish the ci-image.yml commit itself and the pin then matches. What it costs is the reason it
went: that route charges a full ~2 GB publish and a five-pin bump for *every* edit to the file, a
@@ -2179,7 +2412,7 @@ sha tag once 15 newer versions of the package exist. `ci-image.yml` publishes a
and on every push to `main` touching `docker/ci/**` (plus every branch dispatch), while the pin only
moves when a human bumps it — so a pin ages toward eviction on its own. That is what happened between 2026-08-11 and
2026-08-13 (ersatztv#772): the tag vanished, and every `container:` job — **both required contexts
included** — died after 1–2s with
included** — died at image pull, before running a step, with
```
Error response from daemon: failed to resolve reference ".../ersatztv-ci:<pin>": not found
@@ -2225,16 +2458,27 @@ because they send you to different places:
| HTTP 200 with a manifest body | green | resolves |
| HTTP 404 | **red** | `IS GONE` — rebuild the tag (recovery above) |
| HTTP 200, body is not a manifest | **red** | something is answering for the registry (proxy, login page) |
| 401 / 403 | **red** | the credentials were rejected — fix the secrets |
| 401 / 403 that survives a bearer the run actually obtained | **red** | the registry `refused an ANONYMOUS read ... even after a Bearer token was obtained` — about the PACKAGE: check that the `ersatztv-ci` package is still PUBLIC. It is linked to no repository (measured 2026-09-05: every version reports `"repository": null`), so this is the package's own visibility and NOT `timothy/ersatztv`'s |
| 401 whose token leg ANSWERED and yielded no bearer — no `Www-Authenticate` at all, a challenge naming no realm, or a token endpoint answering with no token | **red**, asked once | `could NOT OBTAIN an anonymous pull token ... after 1 token-leg attempt(s)` — about the registry's TOKEN ENDPOINT, not the package's visibility. The endpoint said something, so asking again cannot change it |
| 401 whose token endpoint could not be REACHED, or answered 5xx | **red** after `ETV_CI_ATTEMPTS` token-leg tries | the same message, naming the attempts it actually made. An endpoint that said nothing is the same transport blip a flaky manifest read gets to survive — the two legs of one read must not have opposite flake tolerances when a red here denies a merge |
| 403 on the FIRST read, before any token leg — `probe` enters it on a `401` only | **red** | `refused an ANONYMOUS read ... and NO TOKEN WAS EVER REQUESTED` — it reports what the RUN did (never followed this answer as a challenge), not what the registry sent. About ACCESS to the registry: visibility, or something answering for it |
| anything else (5xx, unreachable, no `curl`) | **red** after `ETV_CI_ATTEMPTS` tries | `could NOT VERIFY` — check the registry's health, NOT the pin |
| `ETV_REGISTRY_AUTH` unset, malformed, or either half empty | **red**, before any query | an absent secret interpolates to `":"`, which is not a credential |
The last two rows are the ones worth defending, because warning on them and exiting 0 is the natural
way to write this check and it is wrong: a missing `curl`, a moved registry and a DNS change all land
there, and a green-with-a-warning job is indistinguishable from a healthy pin forever after. The
**The refusal rows say only what actually ran**, and that is the point of wording them apart
rather than a nicety: a message naming a step the run skipped is evidence for a diagnosis nobody
performed. So the arm branches on `token` first — a bearer was obtained — then on whether the token
leg was attempted at all, and only then reports the never-asked case; and the token-leg message
carries its own attempt count rather than leaving the reader to infer one from the retry policy.
Those refusal rows and the `could NOT VERIFY` row are the ones worth defending, because warning
on them and exiting 0 is the natural way to write this check and it is wrong: a missing `curl`, a
moved registry and a DNS change all land in the last of them, and a green-with-a-warning job is
indistinguishable from a healthy pin forever after. The
unknown arm retries first (`ETV_CI_ATTEMPTS`, default 3, `ETV_CI_RETRY_SECONDS` apart) so an ordinary
registry blip does not redden a PR — that pause is what makes failing on unknown affordable, and
shortening it silently trades this guard for flake.
shortening it silently trades this guard for flake. **An unreachable token endpoint is in that
class, not in the refusal class**, and is retried on the same budget: the split is by what the
endpoint SAID, never by which leg of the read it happened on.
**It is not a `needs:` of anything, but it is not consequence-free either.** The merge-consent hook
reads the PR's **combined** status and denies on a non-`success` combined state (a `skipped` context
@@ -2276,7 +2520,7 @@ check and takes no arguments. Run it from the CURRENT checkout, not the pin work
`$repo` is kept above:
```bash
ETV_REGISTRY_AUTH=user:pass scripts/ci-toolchain-image-resolves.sh
scripts/ci-toolchain-image-resolves.sh
```
**What this rebuild does and does not restore.** It restores a *working* toolchain at that tag, built
+2 -2
View File
@@ -99,7 +99,6 @@ leaving the shared tree dirty is the one outcome that would make this script a n
**Negative control (inherited from #231/#250).** A dedicated test hammers unbalanced releases on an empty pool while reader threads sample the count; none may ever observe a value below zero. Reinstating the pre-#539 decrement-first body makes it fail (`sawNegative > 0` — the readers catch the transient `−1`); verified. As with the #536 tests, break the primitive by reverting the real body, **not** `if (true)` (CS0219 under warnings-as-errors leaves `--no-build` running a stale, still-fixed dll).
**SPA is id-fields-first; the API is complete ahead of the UI.** The Channel Builder + Auto-Tune DetailPanel re-add a real "None" option to the five id selects (watermark + fillers) — the pickers #89 had degraded to "Inherit"-only — routed through a `CLEAR` overrides sentinel folded into `advanced.clear` at request-build time (`applyOverridesToRequest`, so the sentinel never leaks as a field value). The three string clear-fields are covered by the backend enum for machine clients (MCP) but the SPA text inputs keep "empty = inherit"; adding a tri-state to those inputs is deferred, not blocked. This is the deliberate "REST API is a real audience" posture (`rest-api-purpose-mcp-and-new-ui`).
`security.iptv-browser-token`.
external-process pipeline remains #563's.
picker; TZ-independence holds only for divisors of 60. See #77 (prior art) and #392.
@@ -202,7 +201,7 @@ another doc or an old issue comment should land here and then follow the link.
- 2026-07-21 — `WorkAheadSlots.Release()` clamps before decrementing and reports unbalance in-band (#539) — [`ffmpeg.work-ahead-slot-release-never-negative`](decisions/records/ffmpeg/work-ahead-slot-release-never-negative.md)
- 2026-07-21 — `from-lineup` advanced overrides express "clear to none" via a typed `clear` enum list (#135) — [`api.from-lineup-clear-to-none`](decisions/records/api/from-lineup-clear-to-none.md)
- 2026-07-22 — Channel-level graphics-element attachment + seeded On Now/Next text element (#74) — [`graphics.channel-level-attachment`](decisions/records/graphics/channel-level-attachment.md)
- 2026-07-22 — Sequential (YAML) playout gets a golden; Scripted is excluded from the golden net by construction (#381) — [`testing.scripted-playout-golden-deferred`](decisions/records/testing/scripted-playout-golden-deferred.md)
- 2026-07-22 — Sequential (YAML) playout gets a golden; Scripted is excluded from the golden net by construction (#381) — [`testing.scripted-playout-golden-deferred`](decisions/archive/testing/scripted-playout-golden-deferred.md) (superseded by `testing.scripted-engine-in-process-net`)
- 2026-07-22 — per-schedule clock-boundary padding is a synthetic content-less Pad over the existing per-episode machinery (#392) — [`sched.clock-padding-schedule-toggle`](decisions/records/sched/clock-padding-schedule-toggle.md)
- 2026-07-23 — Channel health = a server-derived `health` object on the channel DTOs, built-timeline detection (#415) — [`api.channel-health-object`](decisions/records/api/channel-health-object.md)
- 2026-07-23 — Channel origin is immutable creation-provenance, stamped at insert, not a health signal (#414) — [`channel.origin-marker`](decisions/records/channel/origin-marker.md)
@@ -216,3 +215,4 @@ another doc or an old issue comment should land here and then follow the link.
- 2026-07-25 — The rationale-edit marker is a git trailer, not a substring anywhere in the commit range (#609) — [`ci.decisions-edit-trailer`](decisions/records/ci/decisions-edit-trailer.md)
- 2026-07-25 — UI-E2E: headless Playwright flows in the existing `functional-e2e` job, browser baked into the CI image (#445) — [`ci.ui-e2e-harness`](decisions/records/ci/ui-e2e-harness.md)
- 2026-07-26 — Facet-value typeahead restated: every artist source covered; the JSON-column source is paged by row position with no residual SQL predicate (#578) — [`api.search-field-values-sources`](decisions/records/api/search-field-values-sources.md)
- 2026-09-05 — Scripted playout is covered in-process, engine plus controller adapter; the Cli.Wrap process and the HTTP transport are permanently out of the automated suite (#563) — [`testing.scripted-engine-in-process-net`](decisions/records/testing/scripted-engine-in-process-net.md)
File diff suppressed because one or more lines are too long
@@ -1,13 +1,13 @@
---
key: testing.scripted-playout-golden-deferred
title: 2026-07-22 — Sequential (YAML) playout gets a golden; Scripted is excluded from the golden net by construction (#381)
status: active
status: superseded
since: '2026-07-22'
supersedes: none
superseded-by: none
rule: 'The `PlayoutBuildGoldenTests` in-memory golden net covers Sequential (YAML) as of #381. Scripted''s *end-to-end pipeline* is excluded — `ScriptedPlayoutBuilder` runs a user-authored external program that drives the engine over HTTP loopback, which the in-memory harness can''t pin — so that full-pipeline (integration) harness is deferred to #563. But the scheduling *behavior* those scripts drive lives entirely in the in-process `SchedulingEngine` (the `ScriptedScheduleController` is a 1:1 pass-through to it), which IS directly unit/golden-testable; the earlier "Scripted is un-golden-able by construction" framing overstated the constraint by conflating transport with engine. #395 extracts that shared switch to `ContentEnumeratorBuilder` and adds a direct regression net (`ContentEnumeratorBuilderTests`) over it.'
superseded-by: testing.scripted-engine-in-process-net@2026-09-05
rule: '(superseded) The `PlayoutBuildGoldenTests` in-memory golden net covers Sequential (YAML) as of #381. Scripted''s *end-to-end pipeline* is excluded — `ScriptedPlayoutBuilder` runs a user-authored external program that drives the engine over HTTP loopback, which the in-memory harness can''t pin — so that full-pipeline (integration) harness is deferred to #563. But the scheduling *behavior* those scripts drive lives entirely in the in-process `SchedulingEngine` (the `ScriptedScheduleController` is a 1:1 pass-through to it), which IS directly unit/golden-testable; the earlier "Scripted is un-golden-able by construction" framing overstated the constraint by conflating transport with engine. #395 extracts that shared switch to `ContentEnumeratorBuilder` and adds a direct regression net (`ContentEnumeratorBuilderTests`) over it.'
signals: 'why is there no scripted golden; scripted transport vs engine; SchedulingEngine is in-process testable; Cli.Wrap external process is transport only; ScriptedScheduleController 1:1 pass-through; engine-level scripted regression net; EnumeratorForContent · paths: `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs`, `ErsatzTV.Core.Tests/Scheduling/Engine/SchedulingEngineTests.cs`, `ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs`, `ErsatzTV/Controllers/Api/ScriptedScheduleController.cs`, `ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs` · issues: #381, #163, #395, #563'
mechanics: docs/testing.md → Golden-file nets
mechanics: superseded by `testing.scripted-engine-in-process-net` (ersatztv#563), which stands up the deferred coverage in-process and corrects the "1:1 pass-through" description of `ScriptedScheduleController`
---
**Sequential (YAML) is golden-able and TZ-independent.** `SequentialPlayoutBuilder` reads a YAML schedule
@@ -0,0 +1,47 @@
---
key: api.absent-collection-means-unrestricted
title: '2026-08-30 — An ABSENT request collection means unrestricted; an explicitly EMPTY one is rejected with a 422 (#880)'
status: active
since: '2026-08-30'
supersedes: none
superseded-by: none
rule: 'A nullable collection on a write path carries TWO distinct requests and they get DIFFERENT answers. ABSENT (property missing, or explicit `null` — Newtonsoft maps both to null, so they are indistinguishable and are treated as ONE case) means the client expresses NO restriction and NORMALIZES to the PERMISSIVE value; EXPLICITLY EMPTY (`[]`) is a well-formed request whose outcome is meaningless for a conjunctive filter, and is REJECTED with a 422 naming the consequence rather than rewritten. Collapsing the two is what ersatztv#880 records: `?? []` on the three recurrence arrays of `PlayoutAlternateScheduleItemRequest` / `PlayoutTemplateItemRequest` returned HTTP 200 and stored a row that could NEVER apply on any date, because `AlternateScheduleSelector.GetScheduleForDate` treats DaysOfWeek/DaysOfMonth/MonthsOfYear CONJUNCTIVELY (a miss on any one `continue`s), so empty is the maximally RESTRICTIVE value, not a neutral one. The permissive value is not chosen freshly: it must be the SAME SYMBOL the read side substitutes for the same absence — here `AlternateScheduleSelector.All*()`, already used by the selector''s null guard and both `Mapper.ProjectToViewModel` overloads (`media.nullable-primitive-collection-mutation`), whose residual (3) this closes. Distinguishing absence from empty REQUIRES the property to be nullable (`List<T>?`), which in the `ErsatzTV` web project needs a per-file `#nullable enable` — the project has annotations off, and sibling request records already use that form. THE TWO HALVES LIVE IN DIFFERENT LAYERS AND THAT SPLIT IS THE DECISION: normalization sits in the request record''s `ToReplaceItem`, but REJECTION SITS IN THE HANDLER, in `RecurrenceSetBounds` (`ErsatzTV.Application/Scheduling`) called from BOTH replace handlers — the `FFmpegProfileBounds` shape. It CANNOT sit beside the normalization in the controller, because `api.ffmpeg-profile-numeric-bounds`'' rule that an UNCHANGED bad value must still be accepted binds here in its sharpest form: both PUT paths are WHOLE-LIST replaces, so rejecting a pre-existing empty set would make every OTHER item in the list uneditable over a row the operator never touched. That comparison needs the STORED row, which the controller does not have and the handler already loaded. The exemption is PER FIELD, not per row — a row grandfathered on `DaysOfWeek` still cannot newly empty `MonthsOfYear` — and a STORED NULL is NOT exempt, because null means unrestricted, so submitting `[]` against it is a new emptying. The validated set is DERIVED from the list the handler actually writes (`incoming`), which EXCLUDES the highest-`Index` catch-all: the handler discards that item''s recurrence along with its date range, so an empty set there cannot make anything "never apply" and rejecting it would state a reason that is FALSE for that item — do not re-derive "which item is the catch-all" in a second place (`api.put-replace-index-order`). Finally, the error message says SEND NULL, not "omit the property": making a C# property nullable does NOT make it optional in the generated schema — all three are still listed in `required` in `v1.json` with type `["null","array"]` — so a client generated from the published contract cannot omit them, and telling it to would instruct a schema violation. Omission still works at runtime; `null` is the form that is also contract-legal.'
signals: 'omitted array means never applies · absent versus explicitly empty on a write path · `?? []` inverts a conjunctive filter · empty set is the most restrictive value not the neutral one · normalize absence to the same symbol the read side substitutes · `List<T>?` plus per-file `#nullable enable` in a project with annotations off · reject an empty recurrence with 422 naming the consequence · rejection needs the STORED row so it lives in the handler not the controller · unchanged stored empty is exempt per FIELD not per row · a stored NULL is not exempt because null means unrestricted · validate the list the handler writes so the catch-all is excluded by construction · nullable does not mean optional in the generated schema so say send null not omit · paths: `ErsatzTV.Application/Scheduling/RecurrenceSetBounds.cs`, `ErsatzTV/Controllers/Api/Requests/PlayoutAlternateScheduleItemRequest.cs`, `ErsatzTV/Controllers/Api/Requests/PlayoutTemplateItemRequest.cs`, `ErsatzTV.Application/Playouts/Commands/ReplacePlayoutAlternateScheduleItemsHandler.cs`, `ErsatzTV.Application/Scheduling/Commands/ReplacePlayoutTemplateItemsHandler.cs` · issues: #880, #823, #701'
mechanics: 'Two halves, pinned in two files and mutation-proved SEPARATELY by EXECUTION against the real predecessor clause. NORMALIZATION is pinned in `ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs` by `Replace{AlternateSchedules,Templates}_Should_Normalize_Absent_Recurrence_To_Unrestricted` plus `ReplaceAlternateSchedules_Should_Preserve_An_Explicit_Recurrence_Selection` — the last is the complement, without which a fix that substituted `All*()` UNCONDITIONALLY would still pass. Proof: reverting the three `?? All*()` clauses to `?? []` in both request records reddens EXACTLY those three. REJECTION is pinned in `ErsatzTV.Tests/Application/Playouts/PlayoutHandlerTests.cs` against a real `InMemoryTvContext`, and the four cases are chosen to be mutually distinguishing: a newly-empty field on a NON-catch-all item is rejected; the same empty on the CATCH-ALL is ACCEPTED; an UNCHANGED stored empty is accepted; and the SAME stored row still REJECTS a different field newly emptied. Each rejection case also asserts the version did NOT bump, which is the anti-vacuity control — a 422 that had already mutated would be a different bug. One fixture trap is load-bearing and was measured, not reasoned about: the template handler loads `.Include(p => p.Templates).ThenInclude(t => t.Template)` and that navigation is REQUIRED, so a `PlayoutTemplate` seeded WITHOUT its `Template` row is joined OUT and never reaches `existing` — the stored row is then invisible, the exemption cannot match, and the test fails claiming the rule is broken when the fixture is. Seed the `TemplateGroup` and `Template` too.'
---
**The empty set is not the neutral value, and that is the whole defect.** For a conjunctive filter,
absence and emptiness sit at OPPOSITE ends: absence restricts nothing, empty admits nothing. `?? []`
reads as a harmless "default to no filter" and is in fact "default to matching nothing" — the row is
stored, the write returns 200, and the item silently never applies. Nothing logs it, because from the
scheduler's point of view a non-matching item is ordinary. The SPA never triggered it (it sends full
arrays and its generated type marks the field `null | Array<T>`, so the key is always present) and the
MCP server exposes no such write tool, which is why this survived: it was reachable only by a direct
API client, and only by OMITTING a field.
**Why the two cases must not be merged in either direction.** Normalizing `[]` to the permissive value
too would make it impossible to say "no days" at all, and would re-collapse the distinction the
nullable annotation was added to express. Rejecting absence instead would break every client that
legitimately omits an optional field. The pair — normalize absence, reject empty — is the only
assignment that leaves both requests expressible and neither one silent.
**The first implementation put the rejection in the controller, and an independent review was right to
block it.** Validating the request there is cheaper and matches the sibling `ValidateDateRanges`, but
it has no access to the stored row, so it rejected an unchanged legacy empty and — because these are
whole-list replaces — would have made every sibling item unsaveable. It also validated EVERY item
uniformly, including the catch-all whose recurrence is discarded, producing a 422 whose stated reason
("the item would never apply") is false for exactly that item. Both disappear once the check moves to
the handler and derives its population from `incoming`. The uniformity of `ValidateDateRanges` is
precedent for a STYLE, not evidence that the behaviour is correct; that was the reasoning error.
**Prod was measured before choosing to reject rather than grandfather** (2026-08-30, jazz
`/config/ersatztv.sqlite3`): `ProgramScheduleAlternate` and `PlayoutTemplate` both hold ZERO rows. That
measurement is why the exemption could not be justified by pointing at live data — it had to be
justified by the shape of a whole-list replace instead, which holds for any deployment. Do not cite
the zero-row count as a reason to drop the exemption.
**The SPA can still produce an empty set** (`PlayoutScheduleEditors.tsx` toggles days individually,
with no floor at one), so a user who unchecks every day now receives the 422 instead of silently
saving a dead row. That is the intended outcome and it is a visible behaviour change; a client-side
floor mirroring the server message would be the §3d "mirror the exemption in the client" follow-up if
the error proves awkward in the form.
@@ -0,0 +1,45 @@
---
key: api.top-level-id-list-validation
title: '2026-09-05 — A top-level list of FK ids on a full-replace write DTO is bounded, existence-checked, named in its own 422, and re-asked after a lost race (#568)'
status: active
since: '2026-09-05'
supersedes: none
superseded-by: none
rule: 'A list of foreign-key ids that is a TOP-LEVEL field of a full-replace write DTO (`UpdateChannelRequest.graphicsElementIds`, `ReplaceDecoRequest.graphicsElementIds`/`watermarkIds`) is validated like every other FK field on that DTO, through the one shared primitive `Validators.IdsMustExist`. Four properties, in this order: (1) the RAW list length is capped at `Validators.MaximumIdListCount` before `Distinct` and before any database work, because deduplication is not what the request cost to parse and materialize; (2) an id with no matching row is a 422, never the FK constraint reaching `SaveChangesAsync` as an unhandled 500; (3) the message carries the request field name in brackets, `[GraphicsElementIds] ...`, the prefix `Validators.NotEmpty`/`NotLongerThan`/`AtLeast` already produce, and echoes at most ten missing ids plus a count; (4) where a SIBLING field of the same request makes the apply path discard the list — a deco `DecoMode` outside `Override`/`Merge` — that predicate is passed in as `idsAreConsumed` and gates the EXISTENCE half ONLY. It never gates the cap: a discarded list was parsed and materialized out of the request body all the same. Because validation and the write are two statements, a handler that writes any FK also catches `DbUpdateException` around the save and re-runs ITS OWN `Validate` on a FRESH context, returning the validator''s own 422 when a principal row has since gone and re-throwing otherwise. The whole validator set is re-asked, never a named subset of fields. This is NOT the deep-FK carve-out of api-conventions.md 3b, which stays: ids nested inside item-list request bodies are still not existence-checked at that depth.'
signals: 'unknown graphicsElementIds returns 500 · unbounded client-supplied id array · which id list does this 422 mean · FK deleted between validation and SaveChangesAsync · deco mode gate skips the count cap · IdsMustExist · MaximumIdListCount · idsAreConsumed · ApplyUpdateRequestTranslatingLostRace · paths: `ErsatzTV.Application/Validators/IdListValidation.cs`, `ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs`, `ErsatzTV.Application/Scheduling/Commands/UpdateDecoHandler.cs`, `docs/api-conventions.md` 3b · issues: #568, #917, #74'
mechanics: '`Validators.IdsMustExist<T>(input, expression, noun, idsAreConsumed, findExisting, ct)` in `ErsatzTV.Application/Validators/IdListValidation.cs`; the field name comes from `GetMemberName(expression)`, so it cannot drift from the DTO. Pinned by `UpdateChannelGraphicsElementsTests` and `UpdateDecoGraphicsElementsTests`; the clause mutations and their measured red sets are rows 35-47 of the table in `docs/graphics-elements.md`.'
---
- **The cap belongs to the request, the existence check belongs to the apply path.** These are two
different questions and the first attempt at the mode gate conflated them: the deco validators
short-circuited the whole `IdsMustExist` call when `DecoMode` was `Inherit`/`Disable`, which was
right about existence and wrong about the count. A validator may not reject an id the reconcile
was going to throw away — the SPA sends both id lists whatever the mode selector says, so a draft
holding a since-deleted element must still be able to save the deco back to `Inherit` — but the
array was still bound, allocated and materialized before any of that was known. Hence
`idsAreConsumed` as a parameter of the shared primitive rather than an `if` around the call site:
the primitive decides what the flag may switch off, and no call site can widen it.
- **A recovery path that names its own fields is the defect it is recovering from.** Re-asking only
the graphics-element question after a `DbUpdateException` left `WatermarkId`, `FFmpegProfileId`,
`FallbackFillerId` and `MirrorSourceChannelId` — every one of them written by the same
`SaveChangesAsync`, every one of them a real FK — surfacing the same unhandled 500 the catch exists
to remove. Re-running the handler's own `Validate` is what makes the coverage derive from the
validator set instead of from whoever last edited the catch. A transaction is not an alternative:
neither provider locks rows the validator merely READ, so the concurrent delete commits either way.
The context must be a fresh one — the failed context still tracks the changes it could not write,
and would answer the existence query out of them.
- **Bounded, but only here — and deliberately.** Three validators on two DTOs carry this cap; the
other FK validators on the same DTOs, and the `O(existing x desired)` reconcile loops the ids then
feed, do not. That is not an oversight and it is not a finished rule: capping three sites and not
the rest is a per-field constant, and #917 owns the repo-wide question (where the bound lives, how
the limit is derived, and bounding the apply paths as well as the validators). Cite this record for
the SHAPE a bounded id list takes; expect #917 to replace the mechanism.
- **`MaximumIdListCount` is a ceiling on abuse, not a product limit.** 512, against tables an
operator curates by hand where a few dozen rows is a large install. Nothing reachable from the
editor can approach it, which is what lets it be a hard rejection rather than a clamp.
- **A 422 that echoes every rejected id turns an oversized request into an oversized response.** Ten
ids and a count: enough to fix the payload by hand, bounded by construction.
@@ -5,9 +5,9 @@ status: active
since: '2026-08-05'
supersedes: none
superseded-by: none
rule: 'Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`''s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo''s checkouts now set it — 15 in #746 and `ci-image.yml`''s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation.'
rule: 'Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`''s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo''s checkouts now set it — 15 in #746 and `ci-image.yml`''s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The `workflow_dispatch` half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because `docker-build.yml`''s head-resolved `pull_request:` runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the `v*` tag push, not dispatch, were the live residuals; both were addressed in #885 — the tag push is whitelisted to the release operator (`release.tag-protection-v-star`) and NOTHING on the `pull_request` route names a stored secret any more — no job and no workflow scope (`ci.pr-route-carries-no-stored-credential`), so the `container:` pull, `ETV_REGISTRY_AUTH` and `ETV_STATUS_AUTH` are all gone from that route and `REGISTRY_PASSWORD` reaches only `build`. That bounds the blast radius; it does not close the route, because head-supplied YAML can still name any secret in the store.'
signals: 'admin password in CI secrets, registry credential scope, ETV_STATUS_AUTH can write statuses, forge review-verdict/h10, head-resolved workflow holds credentials, persist-credentials on actions/checkout, credential left in .git/config, masked git fetch yields empty changed set, Gitea token scopes, no status scope, write:package vs write:repository, permissions key no-op, GITEA_TOKEN default read/write, Restricted default token permissions, orphan secret, deploy key in secret store, toolchain image overwrite, prod floating tag write · paths: `.gitea/workflows/docker-build.yml`, `.gitea/workflows/pr-checks.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/ci-image.yml`, `.gitea/workflows/renovate.yml`, `scripts/ci-detect-already-validated.sh` · issues: #697, #672, #698, #742, #743, #746, #748, #835, #420, server-management#714'
mechanics: 'PAT `ci-registry-scoped-697`, scopes `write:package,read:repository`, stored as repo Actions secret `REGISTRY_PASSWORD`; `REGISTRY_USER` remains `timothy`. Verified 2026-08-05 on Gitea 1.25.4: registry push of a probe tag SUCCEEDED; `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` REFUSED HTTP 403 `token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository`. Probe artifacts deleted, confirmed 404. NOT measured with this token: the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea''s scope model (write implies read per category, read at tag `v1.25.4`) — INFERRED. Note WHICH run proves which: only the `container:` pull is exercised by a PR. `cache-to`/`cache-from` and the base-image pull are confined to the `build` job, which carries `if: github.event_name != ''pull_request''`, so they are first exercised on the post-merge push to `main` — AFTER the merge gate has passed. A wrong inference there reddens main, not the PR.'
mechanics: 'PAT `ci-registry-scoped-697`, scopes `write:package,read:repository`, stored as repo Actions secret `REGISTRY_PASSWORD`; `REGISTRY_USER` remains `timothy`. Verified 2026-08-05 on Gitea 1.25.4: registry push of a probe tag SUCCEEDED; `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` REFUSED HTTP 403 `token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository`. Probe artifacts deleted, confirmed 404. RE-PROBED 2026-09-02 on Gitea 1.27.1 (#869) in a throwaway repo with a freshly minted PAT of the same two scopes: same 403 with the same message, plus a read control (same token GET -> 200) and a write control (admin POST -> 201); repo and token deleted, deletion confirmed. NOT COVERED BY EITHER PROBE (2026-08-05 or the #869 re-probe): the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea''s scope model (write implies read per category, re-read at tag `v1.27.1` 2026-09-02) — INFERRED. Note WHICH run exercises which, and that this CHANGED on 2026-09-04 (#885). The buildcache write and the base-image pull are confined to the `build` job, which carries `if: github.event_name != ''pull_request''`. The `container:` pull is not in `build` at all — `build` declares no `container:` — and it is the one a PR used to exercise with this credential; the five `container:` blocks now declare no `credentials:` and pull ANONYMOUSLY, so there is no credential left there to infer about (`ci.pr-route-carries-no-stored-credential`). No PR run therefore exercises this PAT at all, and the two credentialed uses are first exercised on the post-merge push to `main`, AFTER the merge gate has passed, so a wrong inference reddens main, not the PR.'
---
**What was wrong.** `REGISTRY_USER`/`REGISTRY_PASSWORD` were the **admin account's** basic auth, and
@@ -102,16 +102,50 @@ count is now **16 of 16 with no exemption**. That is what let #835 ship the guar
exemption list at all: a one-entry list justified by a temporary condition elsewhere keeps passing
after that condition expires, leaving the guard permanently blind to the file it was written for.
**Measured vs inferred.** Measured here: the `v1.25.4` scope enum (`access_token_scope.go`) has no
**Measured vs inferred.** Measured here: the scope enum (`access_token_scope.go`) has no
`status` entry; the `reqRepoWriter` gate (`routers/api/v1/api.go`); the probes in `mechanics`. Read from
docs, NOT verified (2026-08-05): `permissions:` landed in 1.26.0 (Gitea PR #36173); no `app.ini` lever
at any version; Gitea rejects GitHub's `statuses`/`checks` scopes.
**MEASURED 2026-08-27 on 1.27.1 (#748) — this block is NOT covered by the 1.25.4 caveat below.** It
re-probes exactly ONE of that caveat's items, the `permissions:` question, and supersedes the
"whether `permissions:` is honored here is UNPROBED" claim. It does NOT discharge the caveat: the
scope enum, the `reqRepoWriter` gate and the `write:package` 403 are still 1.25.4-dated and
un-re-run. Method: a matched pair of scratch-base probe PRs, identical except for one permission
**RE-ESTABLISHED ON 1.27.1, 2026-09-02 (#869) — the three items the version caveat below NAMES.**
It discharges those three and only those three. It does NOT discharge every dated statement in this
record: "no container tag immutability in Gitea 1.25" (above) is still INFERRED and untouched, and
the caveat is kept rather than deleted because it names what each item rests on.
- **Scope enum — no `status` scope.** Read at tag `v1.27.1`, `models/auth/access_token_scope.go` —
the same path it occupies at `v1.25.4`, checked in both trees rather than assumed, because this
record had only ever named the file and not its directory.
Nine categories — ActivityPub, Admin, Misc, Notification, Organization, Package, Issue,
Repository, User — and the string `status` does not occur in the file at all. `write:package`,
`read:repository` and `write:repository` remain distinct scopes, which is what keeps the registry
credential scopable off status-write at no cost.
- **The `reqRepoWriter` gate.** Read at tag `v1.27.1`, `routers/api/v1/api.go`: the `/statuses`
group registers `m.Combo("/{sha}").Get(repo.GetCommitStatuses).Post(reqToken(),
reqRepoWriter(unit.TypeCode), bind(api.CreateStatusOption{}), repo.NewCommitStatus)`. Unchanged in
substance from the 1.25.4 read: writing a commit status is still gated on repository-code WRITE,
so any credential that can write the repository can still forge `review-verdict/h10`.
- **The `write:package` 403 — re-probed live, with both controls.** A PAT scoped exactly
`write:package,read:repository`, minted against the live 1.27.1 instance, `POST
/repos/{o}/{r}/statuses/{sha}` -> **403**, message byte-for-byte the 1.25.4 one: `token does not
have at least one of required scope(s), required=[write:repository], token
scope=write:package,read:repository`. Two controls, because a bare 403 is equally consistent with a
dead token or a dead route: the SAME token `GET`ting the same path -> **200** (the token is live and
the read scope works), and an admin credential `POST`ing the same path -> **201** (the route accepts
a POST). **Be precise about what the second control licenses** — it changes the credential, so scope
and identity vary together and the PAIR alone cannot separate a SCOPE refusal from a
unit-permission refusal. What actually discriminates is the 403 BODY, which names the scope it
wanted and the scope it got; the admin 201 only rules out "the route rejects every POST". Cite the
message as the evidence and the control as its backstop, not the other way round. Run in a
throwaway private repo created for the probe; repo and token both deleted afterwards and the
deletion confirmed (repo `GET` -> 404, token `DELETE` -> 204), so nothing was written to a real
head.
**MEASURED 2026-08-27 on 1.27.1 (#748).** It re-probed exactly ONE of the old 1.25.4 caveat's items,
the `permissions:` question, and supersedes the "whether `permissions:` is honored here is UNPROBED"
claim. It did **not** by itself discharge that caveat — the scope enum, the `reqRepoWriter` gate and
the `write:package` 403 were still 1.25.4-dated and un-re-run when this paragraph was written; #869
re-established all three on 2026-09-02, which is what finally discharged it (see the block above and
"Version history" below). Method: a matched pair of scratch-base probe PRs, identical except for one permission
unit, the block carried **at job level on `set-verdict-status`** — the same level and job as the
shipped `review-verdict.yml`, so the gate's own shape is what was measured.
@@ -137,24 +171,32 @@ controls ran under `permissive`); and the TOP-LEVEL declaration form the other f
which is inferred rather than measured. That inference fails safe (a top-level key Gitea ignored is a
security no-op, never a brick). Probe branches and PRs (#851, #852) deleted after reading.
**Version caveat — this record's measurements are pinned to 1.25.4, the instance is now 1.27.1.**
The instance was upgraded mid-session on 2026-08-05 (#743). Everything above measured on 1.25.4 is
therefore a *dated* claim, not a current one: the scope enum, the `reqRepoWriter` gate and the 403
probe were all taken pre-upgrade and have NOT been re-run. They are recorded honestly as of their
date and are the best evidence available, but do not cite them as current behaviour without
re-probing. #747 re-probed SOME of the 1.25.4-pinned CI claims on 2026-08-28 —
`ci.docs-only-skip-steps`, `ci.shared-pr-file-enumeration`, the REST-route half of
`ci.batch-pushes-no-cancel-route`, the `compare` half of `ci.exemption-provenance` and the
`branches: [main]` item in `ci.gate-trigger-base-resolved` — and it was **not** a sweep of the
corpus. Other 1.25.4-dated CI claims remain un-re-run, the `creator`-attribution measurement the H10
allow-list rests on (`review-verdict.yml`) among them. It **did not** discharge this record's own
remaining items either: the `v1.25.4` scope enum, the `reqRepoWriter` gate and the `write:package` 403
all stay 1.25.4-dated. Only the 403 needs a scoped PAT minted against the live instance; the scope enum
and the `reqRepoWriter` gate were established by reading Gitea's source at the version tag (see
"Measured vs inferred" above), so re-running those needs only a source read at `v1.27.1`. #747 did
neither. What #747 did settle elsewhere is the merge-path half — see `release.main-direct-push-disabled`
for the measured
`block_admin_merge_override` semantics and for why `enable_bypass_allowlist` is not a substitute.
**Version history — the 1.25.4 caveat, and what discharged it.** The instance was upgraded mid-session
on 2026-08-05 (#743), which left this record's measurements dated against a version it no longer ran.
#747 re-probed SOME of the 1.25.4-pinned CI claims on 2026-08-28 — `ci.docs-only-skip-steps`,
`ci.shared-pr-file-enumeration`, the REST-route half of `ci.batch-pushes-no-cancel-route`, the
`compare` half of `ci.exemption-provenance` and the `branches: [main]` item in
`ci.gate-trigger-base-resolved` — and it was **not** a sweep of the corpus. #869 then swept the corpus
by deriving the population with `git ls-files` rather than from an issue's item list, and re-established
most of it — but "finished" would be too strong. Several findings remain 1.25.4-dated by decision, and
**nothing here tracks which ones, deliberately.** Two attempts at that tracking both failed inside the
commit that wrote them: an enumerated list named three residuals and omitted three the same commit
created, and its replacement — a `git grep` pointer — missed the residual that same commit created,
because the commit had introduced a second spelling the grep did not match. A second copy of a set,
whether written out or derived by a pattern, is one more thing to keep in step with the set. Each
residual states its own reason where it lives, which is the copy that cannot drift from its subject.
This record's own
three items are re-established on 1.27.1 above (2026-09-02, #869), so they may now be cited as current. What #747 settled elsewhere is the merge-path half — see
`release.main-direct-push-disabled` for the measured `block_admin_merge_override` semantics and for why
`enable_bypass_allowlist` is not a substitute.
**Read the two source items as source-attested, not instance-attested, and know the difference.** The
scope enum and the `reqRepoWriter` gate are established by reading Gitea's source at tag `v1.27.1`,
which is the version this instance reports (`GET /api/v1/version` -> `1.27.1`, 2026-09-02). That is a
strong claim about the code and a weaker one about the deployment: a patched or vendored binary
reporting `1.27.1` would satisfy the version read and not the source read. Nothing suggests one here,
and the third item — the live 403, whose error string enumerates the required scope — is what ties the
source to this instance. Cite the trio together rather than the source pair alone.
**Surviving routes — this record is not a mitigation for any of them.** `RENOVATE_TOKEN` is a
`write:repository` bot PAT in the same store and still cannot be scoped down, because Renovate needs
@@ -172,8 +214,17 @@ still holds repo-write, so the property is "only a workflow visibly opting into
verdict", not "no workflow can". Scoping still bounds what a job may DO, never whether attacker YAML
RUNS. A
collaborator's own token always can — and after #742 an ALLOW-LISTED collaborator's still is
inherited, which is the residual the allow-list explicitly does not touch. `docker-build.yml` publishes `:prod` from a `v*` tag push and a tag
may point at ANY commit — a prod image with no PR, review or status (tag protections are empty).
inherited, which is the residual the allow-list explicitly does not touch. **Which route is cheapest
was settled in #853** (`ci.workflow-dispatch-ref-unrestricted`): not the four unrestricted
`workflow_dispatch` triggers but `docker-build.yml`'s head-resolved `pull_request:`, which runs
attacker-authored YAML and therefore reaches EVERY secret in the store, not only the ones the
committed workflows name. Six of its jobs held `REGISTRY_PASSWORD` on that route, two of them required
contexts; #885 removed all six (`ci.pr-route-carries-no-stored-credential`), which narrows the blast
radius and leaves the ROUTE exactly as cheap as before — the store, not the committed references, is
what head YAML reaches. `docker-build.yml` publishes `:prod` from a `v*` tag push and a tag may point
at ANY commit — a prod image with no PR, review or status. Since #885 that push is whitelisted to
`timothy` (`release.tag-protection-v-star`), which closes it against the bot and not against the
admin.
**And none of it was necessary: direct pushes to `main` were server-side permitted, so the gate was
bypassable with no forgery at all (#743).** That route is now closed — `main` carries
`enable_push: false` (`release.main-direct-push-disabled`), which removes `main` as a destination for
@@ -1,12 +1,12 @@
---
key: ci.batch-pushes-no-cancel-route
title: '2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4; REST route re-probed on 1.27.1 (#542)'
title: '2026-07-21 — Batch your pushes: no agent-side cancel route; REST absence confirmed in the v1.27.1 router source, web UI still 1.25.4-dated (#542, #869)'
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes.
signals: 'cancel run 404 · Gitea 1.25.4 · Gitea 1.27.1 · `POST /api/v1/.../actions/runs/{id}/cancel` · MCP `actions_run_write` `cancel_run` · session+CSRF · `dispatch_workflow` · orphaned run · 4-slot runner · paths: n/a · issues: #542'
signals: 'cancel run 404 · Gitea 1.25.4 · Gitea 1.27.1 · `POST /api/v1/.../actions/runs/{id}/cancel` · MCP `actions_run_write` `cancel_run` · session+CSRF · `routers/api/v1/api.go` · `dispatch_workflow` · orphaned run · 4-slot runner · paths: n/a · issues: #542, #869'
mechanics: Gitea Actions REST + MCP `actions_run_write`; operator-only cancel in the browser UI.
---
@@ -18,7 +18,9 @@ session cookie).
**Re-probed on 1.27.1, 2026-08-28 (#747) — the REST route only.** `POST .../cancel` still returns
**404**, and the live swagger shows the absence is structural rather than one 404: the only POST routes
under `actions/runs/{run}` are `rerun`, `rerun-failed-jobs` and `jobs/{job_id}/rerun`. The MCP
`cancel_run` and web-UI/CSRF findings above were **not** re-run and stay 1.25.4-dated. Swagger also
`cancel_run` and web-UI findings above were **not** re-run and stay 1.25.4-dated. What is said about
each at the end of this record differs, deliberately: the MCP one has a stated but SESSION-LOCAL
reason (#869), and the web-UI one has none beyond being unchanged in kind. Swagger also
lists `DELETE /actions/runs/{run}` (delete a run record); it was not probed — do not assume it cancels
a run or frees a runner slot. **Only the operator can cancel, in the browser** — so if you must supersede a live
run, say so explicitly instead of leaving it burning. (`dispatch_workflow` is a different route and
@@ -26,3 +28,34 @@ still works for re-triggering a **main** run.)
This corrects the older "superseded runs drain on their own" framing: they do finish, but they hold
one of the 4 runner slots while doing it, which is a real cost with several parallel sessions.
**Confirmed from SOURCE at tag `v1.27.1`, 2026-09-02 (#869), which upgrades what the claim rests on.**
`routers/api/v1/api.go` registers the entire `/repos/{o}/{r}/actions/runs/{run}` group and it holds no
cancel route in any method: `GET ""`, `GET /attempts/{attempt}`, `GET /attempts/{attempt}/jobs`,
`DELETE ""` (`repo.DeleteActionRun`), `POST /rerun`, `POST /rerun-failed-jobs`, `GET /jobs`,
`POST /jobs/{job_id}/rerun`, `GET /artifacts`. The only occurrence of `cancel` in that entire router is
`repo.CancelScheduledAutoMerge`, an auto-merge route and unrelated. Reading the source is what draws a
distinction a status code cannot: a 404 is equally consistent with an absent route, a mistyped path and
a permission refusal, and only the first justifies "there is no route".
**Scope that read honestly: it covers the REST API surface and nothing else.** `routers/api/v1/api.go`
cannot speak for the web UI's own routes, for the runner protocol, or for anything an agent driving an
authenticated browser session could reach. "Operator-only in the browser" therefore still rests on the
1.25.4 web-UI finding, not on this source read.
**Why the other two stay dated (#869, 2026-09-02) — and the honest reason, which is not the tidy one.**
The MCP `cancel_run` tool was NOT re-invoked this session because the agent harness refused the call.
That is a session-local constraint, not a property of this repo, and it is recorded as such rather
than dressed up as an impossibility.
An earlier draft of this paragraph did dress it up — it claimed there was "no disposable run to spend"
because nothing triggers on a non-`main` push. **That reason is false and cold review caught it.**
Four of the six workflows carry `workflow_dispatch:`, and `dependency-scan.yml` is read-only while
`renovate.yml` defaults its dispatch to `dryRun: full` (log only, no PRs), so either raises a
disposable run on demand. A scratch PR raises runs too, which is how #747 and #748 probed. And the
premise does not even need a disposable run: invoking `cancel_run` against an already-FINISHED run id
costs nothing, and that is the right re-probe whenever a session can make the call.
What IS established: the REST surface carries no cancel route at v1.27.1 (above), which explains the
tool's 404 mechanistically — but that is an inference about a wrapper whose own source was not read
here, so it is a strong explanation and not a measurement. The web-UI finding is unchanged in kind.
+101 -30
View File
@@ -5,9 +5,9 @@ status: active
since: '2026-07-29'
supersedes: none
superseded-by: none
rule: 'The three inputs the exemption decision rests on must each be bound to something the judged PR cannot mutate. (1) BASE — `scripts/pr-changed-files.sh` takes the expected base BRANCH as a REQUIRED 5th argument and re-reads it before and after paging, because `/pulls/{n}/files` diffs against the PR''s live base and retargeting moves the answer without moving the head sha; the workflow passes `github.event.pull_request.base.ref` from the `pull_request_target` payload, which a retarget cannot rewrite. (2) BOT EXEMPTION — an author match is necessary but never sufficient: `pull_request.user.login` is the PR''s immutable CREATOR while its head is not, so the exemption additionally requires EVERY changed path to be a dependency manifest (`Directory.Packages.props` or `.config/dotnet-tools.json`, and ONLY those — the npm manifests are excluded because `package.json` `scripts` are executed by CI). (3) INHERITED SUCCESS — the never-overwrite short-circuit fires for a `success` only when it is POSITIVELY identified as an APPROVED REVIEWER''s verdict for THIS base, meaning a `.creator.login` that is a member of the workflow''s `H10_REVIEWERS` allow-list (an existing `failure` takes the weaker ATTRIBUTABLE test and needs no membership, because inheriting a rejection can only withhold an exemption while re-deriving one can turn it green on an exempt PR) — non-null was too weak, since any account''s credential satisfies it and `RENOVATE_TOKEN` is one that cannot be scoped down (#742) — AND a `Review-verdict:` description AND, when that description records a base (`(base: …)`, `release.verdict-status-check`), a base matching the PR''s — tested by requiring the description to END with the exact literal `(base: <base>)` and to contain exactly ONE such marker, never by extracting a value (see below); a present-but-different base is rejected, an absent one is not, since verdicts predating that convention carry none; every other shape, including any unrecognised one, is re-derived rather than trusted. The bot and docs-only exemptions are evaluated as INDEPENDENT predicates and the decision made afterwards, never as an `elif` chain. `edited` is in the workflow''s `types:` so a retarget reclassifies — which gives DETECTION, not atomicity: status writes are not serialized, so a stale run can still post over a fresher one. That residual is now FENCED rather than merely tracked (NARROWED, not resolved — a retarget between the fence''s final count and the POST still leaves a PERMANENT forged green, #849) — the job refuses to write at all if the PR''s timeline retarget COUNT moved while it was classifying (`ci.verdict-write-retarget-fence`, #706) — leaving TWO windows, not one: the sub-round-trip window that no API without compare-and-set can close, AND the post-final-count/pre-POST window, which is not sub-round-trip and yields a PERMANENT green because the successor can consume the `edited` event and exit before the stale run posts last (#849). The PROTECTED path list additionally covers `CLAUDE.md` and `AGENTS.md` (#751) — they are not prose but the documents DEFINING the completion protocol, the merge-consent convention and the H10 rule, so protecting `.claude/` while the file specifying what it enforces stayed docs-only-exempt was the same self-exemption one directory over; driving the real classify body with a lone `CLAUDE.md` change produced an exemption `success`. `README.md` is deliberately not listed. It also covers `.codex/` (#711), which mirrors `.claude/hooks/` byte for byte including the merge-consent hook — latent while that directory is untracked, live the moment it is tracked; the list stays ENUMERATIVE rather than derived, because a derived rule would have to be evaluated against the very file list being classified. Reading the CURRENT status for input (3) must tolerate `statuses: null`: `GET /commits/{sha}/status` serialises a nil slice as `null`, not `[]`, on a head with no statuses yet, and an `array`-only gate made `read_existing_verdict` `exit 1` and post nothing at all (#751, `ci.workflow-run-body-no-expressions`) — `null` is accepted only when `total_count` is 0, so a body that merely lost its array is still refused. Path predicates are evaluated by COUNTING with `grep -c`, never `| grep -q` (SIGPIPE inversion) and never a here-string (temp-space failure) — see `ci.grep-q-pipefail-inversion`.'
signals: 'verdict inherited from a bot token, RENOVATE_TOKEN posts a review verdict, who may post an h10 verdict, H10_REVIEWERS allow-list, forged review-verdict exemption, retarget race against the docs-only classifier, PR base changed mid-run, hijacked Renovate branch, bot exemption on a code change, machine-written success inherited as a verdict, status creator null vs user, never overwrite a human verdict, exemption chain skips docs-only for bots, why is my Renovate PR asking for a verdict, base ref binding on pr-changed-files.sh · paths: `.gitea/workflows/review-verdict.yml`, `scripts/pr-changed-files.sh`, `.claude/hooks/pretooluse-merge-consent.sh`, `scripts/tests/test_pr_changed_files.py` · issues: #849, #845, #742, #698, #697, #672, #663, #649, #632'
mechanics: '`scripts/pr-changed-files.sh <owner> <repo> <pr> <expected-head-sha> <expected-base-ref>` (5 args; a 4-arg call exits 2); workflow env `BASE_REF: ${{ github.event.pull_request.base.ref }}`; `BOT_MANIFESTS` anchored allow-list; `H10_REVIEWERS="timothy"` declared as a literal in the classify body (a repo `vars:` entry or secret is settable without a PR); membership by exact POSIX `[ = ]` comparison over word-split entries, never an UNQUOTED right-hand side — `[[ $rv == $ex_creator ]]` makes it a pattern, while a quoted RHS under `[[ ]]` is literal (probed, bash 5.3); POSIX `[ = ]` cannot glob at all, which is why it is used and never `grep -xF` (an API-supplied creator containing a newline reads as several patterns); `read_existing_verdict` sets `ex_attributable` (creator non-null AND `^Review-verdict:` AND base OK) and derives `ex_human` from it by membership; the INHERITANCE short-circuit reads `ex_human`, the LAST-MOMENT RE-READ reads `ex_attributable` plus a change against the state/creator/description triple snapshotted at the first read, and the post-write raced `jq` stays broad on `creator != null`; `types: [opened, reopened, synchronize, ready_for_review, edited]`'
rule: 'The three inputs the exemption decision rests on must each be bound to something the judged PR cannot mutate. (1) BASE — `scripts/pr-changed-files.sh` takes the expected base BRANCH as a REQUIRED 5th argument and re-reads it before and after paging, because `/pulls/{n}/files` diffs against the PR''s live base and retargeting moves the answer without moving the head sha; the workflow passes `github.event.pull_request.base.ref` from the `pull_request_target` payload, which a retarget cannot rewrite. (2) BOT EXEMPTION — an author match is necessary but never sufficient: `pull_request.user.login` is the PR''s immutable CREATOR while its head is not, so the exemption additionally requires EVERY changed path to be a dependency manifest (`Directory.Packages.props` or `.config/dotnet-tools.json`, and ONLY those — the npm manifests are excluded because `package.json` `scripts` are executed by CI). (3) INHERITED SUCCESS — the never-overwrite short-circuit fires for a `success` only when it is POSITIVELY identified as an APPROVED REVIEWER''s verdict for THIS base, meaning a `.creator.login` that is a member of the workflow''s `H10_REVIEWERS` allow-list (an existing `failure` takes the weaker ATTRIBUTABLE test and needs no membership, because inheriting a rejection can only withhold an exemption while re-deriving one can turn it green on an exempt PR) — non-null was too weak, since any account''s credential satisfies it and `RENOVATE_TOKEN` is one that cannot be scoped down (#742) — AND a `Review-verdict:` description AND, when that description records a base (`(base: …)`, `release.verdict-status-check`), a base matching the PR''s — tested by requiring the description to END with the exact literal `(base: <base>)` and to contain exactly ONE such marker, never by extracting a value (see below); a present-but-different base is rejected, an absent one is not, since verdicts predating that convention carry none; every other shape, including any unrecognised one, is re-derived rather than trusted. The bot and docs-only exemptions are evaluated as INDEPENDENT predicates and the decision made afterwards, never as an `elif` chain. `edited` is in the workflow''s `types:` so a retarget reclassifies — which gives DETECTION, not atomicity: status writes are not serialized, so a stale run can still post over a fresher one. That residual is now FENCED rather than merely tracked (NARROWED, not resolved — a retarget between the fence''s final count and the POST still leaves a PERMANENT forged green, #849) — the job refuses to write at all if the PR''s timeline retarget COUNT moved while it was classifying (`ci.verdict-write-retarget-fence`, #706) — leaving TWO windows, not one: the sub-round-trip window that no API without compare-and-set can close, AND the post-final-count/pre-POST window, which is not sub-round-trip and yields a PERMANENT green because the successor can consume the `edited` event and exit before the stale run posts last (#849). The PROTECTED path list additionally covers `CLAUDE.md` and `AGENTS.md` (#751) — they are not prose but the documents DEFINING the completion protocol, the merge-consent convention and the H10 rule, so protecting `.claude/` while the file specifying what it enforces stayed docs-only-exempt was the same self-exemption one directory over; driving the real classify body with a lone `CLAUDE.md` change produced an exemption `success`. `README.md` is deliberately not listed. It also covers `.codex/` (#711), which mirrors `.claude/hooks/` byte for byte including the merge-consent hook — latent while that directory is untracked, live the moment it is tracked; the list stays ENUMERATIVE rather than derived, because a derived rule would have to be evaluated against the very file list being classified. Reading the CURRENT status for input (3) must tolerate `statuses: null`: `GET /commits/{sha}/status` serialises a nil slice as `null`, not `[]`, on a head with no statuses yet, and an `array`-only gate made `read_existing_verdict` `exit 1` and post nothing at all (#751, `ci.workflow-run-body-no-expressions`) — `null` is accepted only when `total_count` is 0, so a body that merely lost its array is still refused. Path predicates are evaluated by COUNTING with `grep -c`, never `| grep -q` (SIGPIPE inversion) and never a here-string (temp-space failure) — see `ci.grep-q-pipefail-inversion`. The WRITER is bound to the same allow-list rather than trusted to match it (#845): `scripts/post-review-verdict.sh` reads its own status back, identifies that write by state and description, and refuses before writing the comment unless `.creator.login` is a member — so a writer credential that is not a reviewer fails at the terminal that made the mistake instead of stalling the PR one event later. The list is DERIVED from the literal in this workflow by `scripts/lib/h10-reviewers.sh`, never restated: one declaration, not two plus a parity test.'
signals: 'verdict inherited from a bot token, RENOVATE_TOKEN posts a review verdict, who may post an h10 verdict, H10_REVIEWERS allow-list, forged review-verdict exemption, retarget race against the docs-only classifier, PR base changed mid-run, hijacked Renovate branch, bot exemption on a code change, machine-written success inherited as a verdict, status creator null vs user, never overwrite a human verdict, exemption chain skips docs-only for bots, why is my Renovate PR asking for a verdict, base ref binding on pr-changed-files.sh · paths: `.gitea/workflows/review-verdict.yml`, `scripts/pr-changed-files.sh`, `.claude/hooks/pretooluse-merge-consent.sh`, `scripts/tests/test_pr_changed_files.py`, `scripts/post-review-verdict.sh`, `scripts/lib/h10-reviewers.sh` · issues: #849, #845, #742, #698, #697, #672, #663, #649, #632'
mechanics: '`scripts/pr-changed-files.sh <owner> <repo> <pr> <expected-head-sha> <expected-base-ref>` (5 args; a 4-arg call exits 2); workflow env `BASE_REF: ${{ github.event.pull_request.base.ref }}`; `BOT_MANIFESTS` anchored allow-list; `H10_REVIEWERS="timothy"` declared as a literal in the classify body (a repo `vars:` entry or secret is settable without a PR); `scripts/lib/h10-reviewers.sh` derives that same literal for the write side by an anchored single-assignment parse and refuses on 0 or >1 matches, on a non-plain-login entry, or on an empty list; membership by exact POSIX `[ = ]` comparison over word-split entries, never an UNQUOTED right-hand side — `[[ $rv == $ex_creator ]]` makes it a pattern, while a quoted RHS under `[[ ]]` is literal (probed, bash 5.3); POSIX `[ = ]` cannot glob at all, which is why it is used and never `grep -xF` (an API-supplied creator containing a newline reads as several patterns); `read_existing_verdict` sets `ex_attributable` (creator non-null AND `^Review-verdict:` AND base OK) and derives `ex_human` from it by membership; the INHERITANCE short-circuit reads `ex_human`, the LAST-MOMENT RE-READ reads `ex_attributable` plus a change against the state/creator/description triple snapshotted at the first read, and the post-write raced `jq` stays broad on `creator != null`; `types: [opened, reopened, synchronize, ready_for_review, edited]`'
---
`ci.gate-trigger-base-resolved` stopped a PR supplying the gate's own *definition*. This closes the
@@ -23,9 +23,14 @@ behind a green required check. Closed unmerged, branches deleted, no forged `h10
**Why a base BINDING and not a pinned diff.** Diffing two immutable shas would close it outright;
Gitea cannot serve that: `compare/{base}...{head}` returns an object whose only keys are `commits`
and `total_commits`, no `files` — measured on 1.25.4 and **re-confirmed on 1.27.1, 2026-08-28 (#747)**,
probed on `b16ec15d6c1e...9b4844c1a8a9`, 5 commits. Separately, and **not re-probed since 1.25.4**, a
`--depth=1` fetch of the two shas has no merge base, so three-dot is impossible and two-dot
over-reports everything `main` gained since the branch point. So the base is read before the first page
probed on `b16ec15d6c1e...9b4844c1a8a9`, 5 commits. Separately, a `--depth=1` fetch of the two shas has
no merge base, so three-dot is impossible and two-dot over-reports everything `main` gained since the
branch point. **Re-probed 2026-09-02 (#869), and the re-probe corrected how it is FILED: this is a GIT
property, not a Gitea one, so it was never 1.25.4-dated in the first place.** Carrying a Gitea version
stamp on it invited a re-run against the wrong axis — a Gitea upgrade cannot change it, and a git
upgrade can. Measured on git 2.55.0 in a disposable local repo: after `git fetch --depth=1 origin
<base> <head>`, `.git/shallow` is present, `git merge-base` exits **1**, and `git diff <base>...<head>`
exits **128** with `fatal: ...: no merge base`. Re-probe it against a GIT version. So the base is read before the first page
and after the last, and **the gap is stated plainly**: a retarget opening *and* closing strictly
between the files call and the re-read stays invisible from inside the enumeration.
@@ -38,7 +43,7 @@ than described as fixed. `edited` and re-derivation remain one fix — `edited`
on the existing `success`; re-derivation alone never gets a second run — but together they are
mitigation, not a guarantee.
**Narrowed 2026-08-03 (#706) — an earlier heading here said RESOLVED, corrected 2026-08-27 (#849): the fence never re-counts after its final pre-write read, so the post-final-count/pre-POST window remains, and is PERMANENT rather than transient. Worth recording that the narrowing finally came from somewhere else
**Narrowed 2026-08-03 (#706) — an earlier heading here said RESOLVED, corrected 2026-08-27 (#849): the pre-write fence does not re-count after its final read, so the post-final-count/pre-POST window remains. It was PERMANENT rather than transient until #849 added a post-POST re-count (`ci.verdict-unverified-write-sentinel`), which makes it transient — the window between the POST and the repair — rather than closing it. Worth recording that the narrowing finally came from somewhere else
entirely.** The missing piece was never ordering: `ci.verdict-write-retarget-fence` leaves the runs as
unserialized as they ever were and instead makes a run that was overtaken decline to write, keyed on
the timeline's monotonic retarget COUNT — the one signal the `main → scratch → main` ABA cannot make
@@ -48,9 +53,11 @@ non-cancelling concurrency group — the fix this record's residual implied and
measured doing nothing at all. The paragraph above stands as written; only its last sentence is
overtaken, and the sub-round-trip window it describes survives, because Gitea's status API has no
compare-and-set. **That is not the only window** — see the correction to
`ci.verdict-write-retarget-fence` (2026-08-27, #849): the fence never re-counts after its final
pre-write read, so a retarget landing between that read and the POST leaves a PERMANENT forged green
rather than a transient one, whenever the successor run consumes the `edited` event and exits first.
`ci.verdict-write-retarget-fence` (2026-08-27, #849): the PRE-WRITE fence does not re-count after its
final read, so a retarget landing between that read and the POST left a PERMANENT forged green
whenever the successor run consumed the `edited` event and exited first. #849 added a re-count AFTER
the POST, so the writing run now withdraws its own stale `success`; what remains is the observable
window between the POST and that repair, which is transient rather than permanent.
**Route 2 — a bot ACCOUNT does not attribute the CODE.** `pull_request.user.login` is the PR's
immutable *creator*; its head is not. Push application code onto an open Renovate branch and the PR is
@@ -214,27 +221,91 @@ the post-write history was unreadable. Re-review found both wanting, on grounds
reviewer posting inside the write window, for a stall that needs only the read failure, is not
obviously the safe direction, and it was described as a "one-run cost" when it is not.
So the whole of post-write verification stays in #849, where it can be designed once: every write
verified when a mark exists, a sticky sentinel when it does not, and a CHAINED test feeding run N's
real output into run N+1. Two lines inside a provenance change was the wrong size for it — the attempt
is recorded because the next reader will otherwise re-attempt it the same way.
So the whole of post-write verification went to #849, where it was designed once and landed on
2026-08-29 as `ci.verdict-unverified-write-sentinel`: every write verified when a mark exists, a
sticky **and reconcilable** sentinel when it does not, and a CHAINED test feeding run N's real output
into run N+1. Two lines inside a provenance change was the wrong size for it — the attempt is recorded
because the next reader will otherwise re-attempt it the same way, and because the withdrawal is what
established that "sticky" alone is not enough.
**A coupling this record does NOT assert, tracked as #845 instead.** `post-review-verdict.sh` posts with
whatever account owns `ETV_GITEA_TOKEN`/`ETV_GITEA_BASICAUTH` and never checks whose it is. If that
ever stops being an allow-listed account, every verdict silently stops being inheritable and each
later event re-derives it — to `pending` on a PR that needs a verdict, or to an exemption `success` on
a docs-only or Renovate-manifest one, which is the quieter and worse half. Either way the verdict is
gone, and the only diagnostic is a line in a job log.
Correct today — verified live 2026-08-26, reproduce with `GET /api/v1/user` (login `timothy`) and
`GET /api/v1/admin/users?limit=50` (exactly `renovate`, `timothy`) — but nothing keeps it correct
(#845). The job DOES name the creator and the allow-list whenever it declines to inherit — but in the
WORKFLOW RUN, while the mistake is made at the terminal running `post-review-verdict.sh`, which
reports success either way. That gap is what #845 is about, and it is why a louder annotation was not
the answer: an earlier draft added a `::warning::` alongside that line, and it was withdrawn after
producing three defects in three rounds (a message that contradicted itself for an allow-listed
creator, an unescaped API value interpolated into a workflow command, and a test asserting its absence
on a path that exits two guards earlier and so could never fail). The signal has to reach the WRITER,
not be repeated more loudly to the reader.
**One residual named here was closed there too.** An UNREADABLE combined-status read used to leave a
pre-existing off-list `success` standing: `read_existing_verdict` exited without replacing it, the job
went red, and this workflow's own job status is not a required check, so branch protection still saw
the green — the exact status this record exists to revoke, surviving a read failure. That read is now
retried once and, on a persistent failure, the unknown state is REPLACED with the unverified sentinel.
**The writer/gate coupling, ASSERTED at the writer since #845.** `post-review-verdict.sh` posts with
whatever account owns `ETV_GITEA_TOKEN`/`ETV_GITEA_BASICAUTH`. It used to never ask whose that was, and
the two values are coupled: the day the writer credential stops being an allow-listed account, every
verdict silently stops being inheritable and each later event re-derives it — to `pending` on a PR that
needs a verdict, or to an exemption `success` on a docs-only or Renovate-manifest one, which is the
quieter and worse half. Either way the verdict is gone. It now READS THE STATUS BACK after posting it,
identifies its own write by state and description, and refuses — before the comment, so the surviving
half-state is the `ask` one — unless `.creator.login` is on the allow-list. A `creator: null`
(Actions-token) status is refused with its own message, because there is no account to name and "add
them to the allow-list" would be the wrong advice.
The writer mirrors the state asymmetry above rather than simplifying it: membership is required for a
`success` only. A `failure` is written and commented normally from any attributable account, because
that is exactly what this record already says the gate does — requiring membership there would refuse
a verdict the gate honours, and withhold the comment that is the only supported way to record a
rejection. It also reads `.status` and NOT `.state`, with no fallback, because the gate reads
`.status`: tolerating a shape the gate cannot read would make the writer report success on a verdict
that gets re-derived, which is #845 recreated by the check meant to prevent it.
The allow-list is not restated on the writer side. `scripts/lib/h10-reviewers.sh` DERIVES it from the
`H10_REVIEWERS` literal in this workflow, so there is one declaration rather than two held together by
a parity test — the shape `testing.guard-derives-population-from-source` records six failed rounds
against. It is a parse rather than a shared declaration both sides source, because the gate runs
against a checkout of the PR BASE sha: a PR whose base predates such a file would not have it, and a
missing `source` under `set -euo pipefail` kills the job, which posts no `review-verdict/h10` at all
and blocks every merge including the repair (`block_admin_merge_override: true`). A fallback list for
that case would reintroduce the second copy. So the derivation runs on the side that can fail safely
and the body of this workflow is untouched. The parse admits exactly one anchored assignment or
refuses; `test_pr_changed_files.py::test_the_H10_REVIEWERS_list_is_glob_free_and_non_empty` reads the
same literal with the same anchors OVER THE SAME POPULATION — the whole file — so a shape change
reddens CI on the PR that makes it. The population is part of the rule, not an implementation
detail: that guard used to scope itself to the classify step's `run:` body, and a second anchored
assignment in any other `run:` block then left it green while making the writer refuse every verdict.
Measured before it was aligned: the guard green, and about half the writer suite red. What was
missing is worth stating precisely, because the obvious phrasing is wrong twice over. Those failures
DO carry the library's refusal, which names the count and the variable. And alignment ADDS a named
guard failure rather than replacing them. What was missing is a failure POINTING AT THE WORKFLOW,
since the reds surface under unrelated test names and the cause had to be inferred from a stack of
them. No figure is given for how many name it, and the trap is worth recording so one is not added
back: a `grep -c` over pytest output counts LINES rather than failures and `--tb=line` prints each
matching failure twice, so such a count overstates, while raising the traceback detail moves it the
other way by making more failures show the refusal text. Any figure is therefore a property of the
pytest flags that produced it rather than of the code, and reproducing one means parsing per-failure
sections instead of grepping lines. Such an edit reaching `main` WOULD be a repo-wide merge outage — no verdict could be
posted and `review-verdict/h10` is required — but the guard is not the only thing standing in the
way, and saying so would overstate it: the writer suite reddens too, in the ADVISORY `script-tests`
job (the required contexts, measured 2026-08-30, are `Build & test`, `EF migration integrity` and
`review-verdict/h10`). What alignment buys is ONE named guard failure pointing at the workflow, ALONGSIDE the half-suite of unrelated
failures. The route by which the outage is actually reachable is the snapshot boundary below — a
verdict posted from a checkout predating the edit — which is why the two paragraphs belong together.
**The one false ACCEPT this leaves, named with its direction.** The writer reads the workflow in the
LOCAL checkout; the gate runs the base-resolved one. On a branch that ADDS a reviewer — the likely
edit — the writer accepts that account while the live gate will not yet inherit from it, which is
the #845 stall reached through the check meant to catch it. Accepted rather than closed: a live
remote read of the base workflow puts a network dependency on the merge-consent write path, where a
read failure must resolve either to a refusal (a new way to block every verdict) or to a pass (the
hole, reopened). The window is one PR long, the person making the edit is the one who knows the list
is in flux, and the residual is a STALL, never a merge of unreviewed code.
The allow-list was `timothy` and the instance held exactly `renovate` and `timothy` when this was
measured on 2026-08-26 (`GET /api/v1/user`, `GET /api/v1/admin/users?limit=50`). That measurement is
no longer what keeps the coupling correct, which is the point of the change.
**Why the signal had to reach the WRITER.** The job already names the creator and the allow-list
whenever it declines to inherit — but in the WORKFLOW RUN, while the mistake is made at the terminal
running `post-review-verdict.sh`, which reported success either way. An earlier draft tried to fix
that by shouting louder at the reader: a `::warning::` alongside that line, withdrawn after producing
three defects in three rounds (a message that contradicted itself for an allow-listed creator, an
unescaped API value interpolated into a workflow command, and a test asserting its absence on a path
that exits two guards earlier and so could never fail). Recorded so it is not re-attempted: more
volume in the run was never the missing piece.
**What #742 does NOT close, restated because the allow-list invites the wrong reading.** An
allow-listed collaborator's own write-scoped token can still POST a verdict nobody formed. Branch
@@ -0,0 +1,68 @@
---
key: ci.fetch-depth-never-grafts-a-complete-clone
title: '2026-08-29 — A CI fetch passes --depth only to a checkout that is already shallow (#836)'
status: active
since: '2026-08-29'
supersedes: none
superseded-by: none
rule: 'Any `git fetch` in CI tooling passes `--depth` only when `git rev-parse --is-shallow-repository` already answers `true`, and passes none on any other answer including an unreadable one. `git fetch --depth=N` GRAFTS a complete clone shallow: it writes `.git/shallow` and cuts history at N even though every object is already present, so a depth chosen for one consumer silently breaks every other consumer that checks out `fetch-depth: 0`. A shared script whose consumers disagree on checkout depth must ask the repository rather than assume. And whatever the fetch feeds — a version, a diff, a base revision — must be ASSERTED rather than defaulted: a fallback that cannot fail converts the graft into a plausible wrong answer that never reddens.'
signals: 'git fetch --depth, .git/shallow, grafted clone, git describe finds no tag, InformationalVersion 0.0.0, fetch-depth 0 vs fetch-depth 2 · paths: `scripts/ci-detect-docs-only.sh`, `.gitea/workflows/docker-build.yml` · issues: #836, #746'
mechanics: 'docs/ci-cd.md → "A depth is passed only to a checkout that is already shallow"; guard `scripts/tests/test_docs_only_detector_clone_depth.py`, a MUTATION-graded row in `docs/guard-inventory.md`'
---
`scripts/ci-detect-docs-only.sh` has exactly four consumers in `docker-build.yml`, and they do not
agree on checkout depth: `test`, `migrations` and `functional-e2e` check out `fetch-depth: 2`, and
`build` checks out `fetch-depth: 0`. The script carried a `--depth` chosen for the first group and
applied it to all of them. **`build` is the only complete-clone consumer.** `api-docs` and `format`
also check out `fetch-depth: 0` and also have a step with `id: detect`, but that step runs their own
inline diff, not this script. **Enumerate the consumers with `grep -rn ci-detect-docs-only .gitea/`,
never by looking for a step with `id: detect`** — that is the trap, and it points at the wrong two
jobs. The grep returns five hits: the four `run:` call sites plus `docker-build.yml`'s own header
comment.
The harm is not theoretical. The push arm is the only arm `build` ever FETCHES on — it also takes
the tag early-exit on a release cut and the `workflow_dispatch` arm, neither of which fetches — and
that arm grafted `build`'s complete clone on every push to `main`;
the very next step, `Compute version and tags`, runs `git describe --tags --abbrev=0`, which then
found no reachable tag; and its `|| echo v0.0.0` turned that into a version string. Every `:latest`
image published from `main` carried `InformationalVersion 0.0.0-<sha>` instead of `26.x.y-<sha>`
from 2026-07-17, when #416 introduced the depth, until this change. Nothing went red at any point —
the defect was found by reading the string out of a running container.
**Why the depth is kept for the shallow group, stated as measured rather than as load-bearing.** A
depth-less fetch into a `--depth=2` clone leaves `.git/shallow` in place, still writes `FETCH_HEAD`,
and still lets `git rev-list --parents -n1 HEAD` report the parents the push arm reads. So dropping
the depth outright would not have broken those three jobs either. It is retained because it is what
they were given and nothing argues for widening their fetch — not because removing it was shown to
break anything.
**The PR arm's `--depth=200` had no complete-clone consumer at all.** On `pull_request` the only
jobs running this script are the three shallow ones, so that depth was inert rather than latently
firing. It is fixed in the same change anyway, because the clause is shared: the next
`fetch-depth: 0` consumer added to that arm would inherit the graft silently, and rediscovering it
would cost what #836 cost (`process.fix-the-boundary-not-the-site`). That is a guard against a
future shape, and grading it as a reproduction of a shipped one would overstate it.
**Why any answer but `true` passes no depth.** There is more than one such answer, and enumerating
them is the point: `git rev-parse` fails outright where there is no readable repository, and on a
git older than 2.15 the flag is unrecognised and rev-parse ECHOES IT BACK verbatim with status 0
(verified on 2.55 — an unknown `--is-...` flag prints itself and exits 0). Neither is `true`, both
take the no-depth branch, and in neither could a depth have helped: the base stays unresolvable and
every arm falls through to the full matrix, which is the safe direction for this script. Defaulting
the other way would re-graft on exactly the paths nobody can observe.
**The second half is the loud failure, and it is not decoration.** Removing the graft removes
today's cause; the fallback is what made any cause invisible. `Compute version and tags` now fails
the job when `git describe` fails on the non-tag path. No `:latest` at all is visible and
recoverable; a mislabelled one is neither, and `:latest` is what `ersatztv-test` tracks. The tag
path never calls `describe`, so a release cut is unaffected — which is also why `:prod` and
`:<version>` images were correctly versioned throughout.
**Fixture note for anyone testing this.** `git clone /path` and `git fetch /path` use the local
transport, which ignores `--depth` outright. A reproduction built on a plain path never grafts, so
every assertion holds and the test proves nothing; use `file://`. The guard carries a negative
control that performs the raw depth fetch and requires the graft to happen, so that if this ever
stops being true it is reported rather than silently making the rest vacuous.
Related: `ci.docs-only-detect-shallow-safe` covers the other half of this script's shallow
behaviour — which revision it diffs against, and why it is `FETCH_HEAD` with a two-dot diff.
@@ -28,6 +28,58 @@ and the base definition posted `h10=pending` on `opened` and `synchronize` alike
establishing the residual below — is counted because omitting it turns an honest partial into an
overclaim. Probes posted only probe-named contexts, never a forged `h10`. Full results in #699.
**The DEFINITION-RESOLUTION half re-established FROM THE v1.27.1 SOURCE, 2026-09-02 (#869)** — the
mechanism behind the measurement, without spending four more scratch PRs. **Be precise about which
half**, because an earlier draft of this block overclaimed and cold review caught it: `notify()`
establishes only WHICH COMMIT each trigger reads its definition from. It says nothing about which
`types` produce a run, and nothing about secrets availability — the file's only other
`pull_request_target` reference is `ifNeedApprovalWith`, an APPROVAL gate, not a secrets gate.
**What IS established on 1.27.1, stated no wider than it was measured:** `pull_request_target` fires
for `review-verdict.yml` continuously — **210 such runs across 189 distinct head shas**, counting only
runs started strictly after the 2026-08-05 upgrade (measured 2026-09-02). All 210 belong to this
workflow. The WINDOW is part of the claim: the all-time REPO-WIDE figure is 258 runs / 233 shas (this
workflow's own all-time count is 250 — the difference is **8 runs** from three since-deleted probe
workflows, all pre-upgrade), which splits 44 before the upgrade date + 4 ON it + 210 after, so citing
258 here would import 1.25.4 evidence into a 1.27.1 sentence. The 4 same-day runs are excluded rather
than assigned — the upgrade landed mid-session and their 17:41-22:36 **UTC** start times cannot be
placed either side of it without the exact upgrade time, so they go to the side that weakens the
claim. Read those times as UTC deliberately: this endpoint returns `started_at` Z-suffixed, while
other Gitea timestamps render in the host zone, Europe/Brussels — `+02:00` in August, `+01:00` in
winter — so a UI comparison shows different clock times. **What that
deliberately does NOT establish:** WHICH `types:` fire, since a run count identifies no event type;
and whether the STORED secret store is reachable under this trigger, since the job references only the
injected `secrets.GITEA_TOKEN`. Both stay 1.25.4-dated.
Two earlier attempts at this paragraph are worth not repeating. The first re-dated BOTH halves —
over-retreating past what production already shows. The second over-corrected into a per-PR
completeness claim ("every PR merged since the upgrade carries this job's context"), which is false
and refuted by this record's own PR #863 observation — that PR merged carrying "no review-verdict
context of any kind", because a PR into a scratch base does not match `branches: [main]`, the very
filter this record exists to explain. (Cited by content rather than by position: a "two paragraphs
down" pointer was wrong the one time it was written, and a positional reference breaks whenever
anything is inserted above it.) A blunt run count needs no per-PR census and cannot rot that way.
Now the mechanism itself. In `services/actions/notifier_helper.go`, one function resolves both. For a
PR event `input.Ref` resolves
to the PR's own HEAD ref — `WithPullRequest` sets `input.Ref = git.RefName(pr.GetGitHeadRefName())`
when the caller has not already supplied one; that ref's commit is passed to
`DetectWorkflows`, and every workflow found there is kept **only if** its trigger is NOT
`pull_request_target` (`if wf.TriggerEvent.Name != actions_module.GithubEventPullRequestTarget`).
Then, separately and only when `input.PullRequest != nil`, it resolves `baseRef := git.BranchPrefix +
input.PullRequest.BaseBranch`, calls `DetectWorkflows` again on that commit, and from THAT set keeps
only workflows whose trigger **is** `pull_request_target`. So the two triggers read their definitions
from two SEPARATELY RESOLVED refs — head and base — and a head rewrite cannot reach a
`pull_request_target` definition at all. (Separately resolved, not necessarily different: nothing
forbids the two refs resolving to the same commit, which is simply the uninteresting case where the
rewrite is not a rewrite.)
The same lines make the `branches: [main]` filter's necessity concrete rather than cautionary: the
base is resolved by BRANCH NAME (`BranchPrefix + BaseBranch`), not by a pinned sha, so "the base
definition" is whatever that branch points at when the event fires. That is why a PR opened into an
attacker-pushed base branch would run that branch's gate, and it is the same re-resolution the
retarget fence exists to fence (`ci.verdict-write-retarget-fence`).
That third item was **re-confirmed at the STATUS level on 1.27.1, 2026-08-28 (#747)**, as a by-product
of an unrelated probe (PR #863): a PR from a scratch head into a scratch base carried thirteen `pull_request`
contexts and no review-verdict context of any kind. The run list itself was not re-enumerated, so the
@@ -0,0 +1,74 @@
---
key: ci.image-build-delegates-the-spa-suite
title: '2026-08-30 — the image build builds the SPA and does not test it (#887)'
status: active
since: '2026-08-30'
supersedes: none
superseded-by: none
rule: '`docker/Dockerfile` runs no vitest suite. Its web-build stage lints, typechecks and BUILDS the SPA; the suite runs once, unfiltered, in `docker-build.yml`''s `test` job on a real checkout, and `build` carries `needs: [test, migrations, scan]` so no image is published past a red suite. THAT EDGE IS NOW THE ONLY LAYER, so the guard checks it is real and not merely present — and it does so by PINNING TEXT rather than parsing it. The commands each SPA-carrying Dockerfile stage runs, and the gating step''s `run:` body and `if:`, are compared as strings against a declared pin; the step and its job must carry no `continue-on-error` in any spelling, the job no job-level `if:`, and the publish step must keep its own `docs_only` gate. A guard that asks what a command MEANS was wrong nine times in three review rounds. A pin is immune to a different SPELLING of the command, which is that whole class; it is not immune to the same text meaning something else, so the routes to that are pinned or refused too: `working-directory` and the step''s own `shell:`, the workflow `defaults.run.shell` and the job-level `defaults` overriding it, a stage `SHELL`, `web/package.json`''s script map (pinned WHOLE — selecting on the literal `vitest` missed `npm run test`, `npm t` and the `prebuild`/`preinstall` lifecycle hooks), `web/vite.config.ts` PINNED WHOLE — it decides what the suite collects and what `vite build` loads, and pinning a BLOCK of it was defeated seven measured ways across three rounds, so the partial match was withdrawn rather than respelled an eighth time; and the ABSENCE of any config file that outranks it — `vitest.config.*` for vitest, and `vite.config.js`/`.mjs` for `vite build`, whose `DEFAULT_CONFIG_FILES` lists them BEFORE `.ts`. Pinning one file is worthless while a second can outrank it, and that family went short twice. The gating job''s `container:` image is left to `ci.image-pin-population` rather than guarded twice. What is unmodelled is a LIST, not an "only": an `ENV` changing what a pinned `RUN` resolves, the plugin BODIES, and a publish through another action. The count of such routes lives in the `docs/guard-inventory.md` row and nowhere else — it briefly existed in three places with two different values, which is how a number stays wrong. It is a running total, wrong at every count so far. They share one shape, which is the transferable part: A PIN ASSUMES IT IS PINNING THE ARTIFACT THAT STILL DECIDES, and every route found so far is authority moving to another file, another occurrence, another workflow, or a hook the pinned command invokes. Do NOT restore a filtered in-image run by naming the specs that cannot run there: that list is a population nothing derives, and the red it produces when it goes stale is unreachable on a PR — `Build & push image (amd64)` is `if: github.event_name != ''pull_request''` — so it lands on `main` and on the `v*` tag path, where it fails the release cut. Held in both directions by `scripts/tests/test_image_build_delegates_the_spa_suite.py`.'
signals: 'image build fails on main only · every image build fails · latest not republished · release cut fails at the image build · gitless build STAGE not context · node:22-bookworm-slim ships no git · virtual:etv-tracked-source-files could not read the git index · hand-maintained exclude list · exclusion list is the underived population · suite members needing the git binary vs a checkout · COPY .git would not help · needs edge is the only gate · unfiltered gating run · paths: `docker/Dockerfile`, `.gitea/workflows/docker-build.yml`, `scripts/tests/test_image_build_delegates_the_spa_suite.py`, `web/vite-plugins/trackedSourceFiles.ts` · issues: #887, #883, #819, #806, #420'
mechanics: 'Populations are DERIVED from the git index (tracked Dockerfiles and workflows, and the stages within them carrying the SPA source); the command lines in those places are PINNED as text. A population decides what is CHECKED, so a hand-written one goes silently short; a pin decides what is EXPECTED, so a stale one goes loudly red. Publishing jobs come from the `docker/build-push-action` step and the Dockerfile each builds from that step''s `file:` input, so `ci-image.yml` is out of scope by derivation rather than by an exemption. 73 mutants, 0 missed (development battery).'
---
The web-build stage is gitless twice over. It copies only `web/` and `design-system/`, so it holds
no `.git`. That is a property of the STAGE, not of the build context — the context is the repository
root (`context: .`) and `.dockerignore` does not exclude `.git`, so the directory is available to be
copied and simply is not; stating it the other way invites a reader to check, find `.git` in the
context, and conclude the whole note is stale. And `node:22-bookworm-slim` ships no git binary. Members of the SPA
suite need one or the other: `pageSizeCallSites.guard.test.ts` and
`completeAnnotations.guard.test.ts` derive their file population from `git ls-files`
and refuse to fall back to a directory walk (`testing.guard-derives-population-from-source`), and
`trackedSourceFiles.realgit.test.ts` builds a real temporary repository. Because the two
prerequisites OVERLAP rather than divide, adding a `COPY .git` fixes neither: the binary is still
missing.
So running the suite there at all costs a list of the members that cannot run, maintained by hand
beside a suite that grows. That list went stale the first time a guard was added without updating
it, and the failure it produced was invisible where it could have been cheap and fatal where it was
not: green on every PR, red on `main` and on the release tag.
**Why not the alternatives.** Each was considered and rejected on its own evidence, not by analogy:
* **Add a third `--exclude`.** Re-arms the same trap for the next guard, which is what the issue was
filed to prevent.
* **Derive the exclusions from source.** Buildable, but it requires a predicate for "needs git" over
spec text — two unrelated shapes today (a virtual-module import, a `git` spawn) and no reason to
think that stays two. A string predicate over source is the wrong instrument for a set whose
members are defined by what they DO at runtime.
* **Give the container git** (`apt-get install git` plus a real `.git`). It works, and it costs a
cache-busting `COPY` of VCS metadata on every commit, so the whole lint/typecheck/test/build layer
re-runs for changes that do not touch `web/`. Synthesising a repository in the image instead —
`git init && git add -A` — is worse than it looks: the resulting index is a filesystem walk with
extra steps, which is precisely the population the guards refuse.
**What the removal gives up, enumerated rather than waved through**
(`process.enumerate-workaround-behaviors-before-deleting`):
1. *Blocking a publish on a red suite* — retained, by the `needs:` edge. Two skips exist inside
`test`. `docs_only` also gates `Build and push`, so that arm ships nothing — ASSERTED, by
`test_the_DOCS_ONLY_arm_cannot_publish_an_image`, after cold review pointed out it had been
stated as fact with nothing checking it. The #420 revalidate skip fires only on a tree
byte-identical to a head that already carried a green combined status; that arm is a DEPENDENCY
on `scripts/ci-detect-already-validated.sh` rather than something asserted here, and that script
is graded `MUTATION: NONE`. Said plainly because #887 is what made the second arm load-bearing:
before it, the in-image run covered that case.
2. *Running the suite under `node:22-bookworm-slim` specifically* — **lost**, and this is the real
cost. It is small because the suite is jsdom and pure JS, and because the artifact the image
actually ships is the output of `vite build`, which still runs there: a test that passes in the
CI toolchain image and fails in bookworm-slim would be telling us about the test environment, not
about the image.
3. *`docker build` running the suite for a developer locally* — lost; `npm test -- --run` is the
direct route and is what `docs/testing.md` documents.
**The guard's own mechanism was replaced once, and the reason belongs here rather than only in the commit log, because the next person to widen it needs it.** Three versions asked whether a given command runs the suite and whether its failure can be swallowed. That predicate was wrong nine times across three cold-review rounds — executed heredocs treated as data, `#` truncating a command mid-word (including the live `${#reports[@]}` idiom), compound punctuation welding commands, `npm t` and `./node_modules/.bin/vitest` and `timeout`/`su -c` wrappers unrecognised, `true || npm test` counted as a run it never performs, and `continue-on-error: ${{ … }}` slipping a two-literal check. Twice, a clause added to remove a FALSE RED opened a FALSE GREEN on the guard's headline assertion. The mechanism was withdrawn rather than patched a tenth time: the risky command lines are now compared against a pin, so no spelling has to be recognised in order to be rejected. The cost is real and is the point — a legitimate edit to those lines reddens the guard and its author updates the pin deliberately.
A fourth cold review then attacked the pin itself and found the honest limit: the replacement removes the SPELLING problem outright, but the same pinned text can be made to mean something else, and two mutants re-armed this very defect through `web/package.json` — `RUN npm run build` executes whatever that file says — with every pin still matching. A fifth round then found the same mistake inside the fix: the package.json check SELECTED on the literal `vitest` where a PIN was available, and four one-line edits that never spell it — `npm run test`, `npm t`, and the `prebuild`/`preinstall` lifecycle hooks — each re-armed the defect with every other pin matching. The whole script map is pinned now, and so is `web/vite.config.ts` — WHOLE, after three further
rounds each defeated one spelling of a partial match of it, and two more routes bypassed the marker
entirely because `defineConfig` is the identity function and a later spread replaces what was
matched.
The general lesson is worth more than any of the fixes: replacing a predicate with a pin moves the problem from "can the checker recognise this?" to "is the checked text still the whole story?". The second question has a short, workable answer where the first did not — but it is still a question, the route list has been wrong at every count so far, and the honest form of that is a running total with its history attached rather than a closure claim.
`lint` and `typecheck` stay in the stage. They are gitless-safe with no member that is not, so they
carry no list and no trap. Whether the image build should run them at all is a separate question
this does not answer, and leaving them is not an argument that it should.
File diff suppressed because one or more lines are too long
@@ -34,9 +34,14 @@ sibling job carries an ignore-rule. Same question, opposite answers, for stated
**Unconditional, not path-filtered.** The suite's real input set spans more than `scripts/`:
`test_post_review_verdict.py` and `test_merge_consent_exemption.py` execute the actual
`scripts/post-review-verdict.sh` and `.claude/hooks/pretooluse-merge-consent.sh`. A `scripts/**`
filter would silently miss a `.claude/hooks/**` edit — and at ~10s a filter buys nothing but a
drift surface. Default checkout depth is sufficient: every `git` call in the suite runs against a
`scripts/post-review-verdict.sh` and `.claude/hooks/pretooluse-merge-consent.sh`, and since #845 the
former also READS `.gitea/workflows/review-verdict.yml` — the verdict writer derives the H10
allow-list from it, so editing that literal changes the suite's outcome. A `scripts/**` filter would
silently miss a `.claude/hooks/**` or `.gitea/workflows/**` edit. The enumeration is a lower bound
rather than a census, deliberately: the corpus tests read `docs/` too, and an exact list here is the
kind that goes stale — which it did, this way, before #845 extended it. The reason is the INPUT SET,
not the cost: a filter was cheap to skip when the suite ran in seconds and is still the wrong axis
now that it runs in minutes. Default checkout depth is sufficient: every `git` call in the suite runs against a
temp repo it creates itself, never this repository's history.
**Verified by measurement, not a green tick** (the #631 Done-when, and `ci.docs-only-detect-shallow-safe`'s
@@ -5,8 +5,8 @@ status: active
since: '2026-08-27'
supersedes: none
superseded-by: none
rule: 'A `push` trigger reachable from any ref other than `main` — that is `branches:` AND `tags:`, judged by the ref class it admits and never by which keyword is present — executes ref-supplied YAML, because Gitea resolves a `push` workflow''s definition from the pushed ref — so `ci-image.yml` is `branches: [main]`, and publishing a toolchain image from a feature branch is a deliberate `workflow_dispatch` on that branch rather than a side effect of pushing. Be precise about what a `branches:` filter buys: it is loaded from the pushed ref like the rest of the file, so a branch that deletes it re-enables the route — this removes the DRIVE-BY case, and is not a boundary against a writer who intends to run their own YAML. The self-referencing trigger path `.gitea/workflows/ci-image.yml` came out of BOTH its own `paths:` and `ci-image-pin`''s `expected` in the same change — a DECIDED tradeoff, not a necessity: keeping it is workable via the branch dispatch, but prices every edit to that file, comments included, at a ~2GB publish plus a five-pin bump, redone after every rebase. The cost is stated, not assumed away — a change to HOW the image is built that lives only in `ci-image.yml` no longer republishes, and the ONLY remedy is to make it alongside a `docker/ci/**` edit: publishing after the merge and then pinning cannot work, because `expected` is the last `docker/ci` commit and would reject that pin. This closes the push route INTO THIS FILE, not the class: `docker-build.yml` remains reachable from an arbitrary ref by a `v*` tag push and by `pull_request`, and four workflows carry an unrestricted `workflow_dispatch` (#853).'
signals: 'unfiltered push trigger, branches filter on push, attacker YAML on a runner, feature branch publishes an image, self-referencing workflow trigger, ci-image-pin expected goes stale, publish-then-pin two-step, toolchain image recovery after a rebase, workflow_dispatch on a branch · paths: `.gitea/workflows/ci-image.yml`, `.gitea/workflows/pr-checks.yml`, `.gitea/workflows/docker-build.yml`, `docker/ci/Dockerfile`, `docs/ci-cd.md`, `docs/decisions/records/ci/actions-credential-scoping.md`, `scripts/e2e-ui.sh` · issues: #744, #853, #697, #746, #835, #445, #390, #594, #597'
rule: 'A `push` trigger reachable from any ref other than `main` — that is `branches:` AND `tags:`, judged by the ref class it admits and never by which keyword is present — executes ref-supplied YAML, because Gitea resolves a `push` workflow''s definition from the pushed ref — so `ci-image.yml` is `branches: [main]`, and publishing a toolchain image from a feature branch is a deliberate `workflow_dispatch` on that branch rather than a side effect of pushing. Be precise about what a `branches:` filter buys: it is loaded from the pushed ref like the rest of the file, so a branch that deletes it re-enables the route — this removes the DRIVE-BY case, and is not a boundary against a writer who intends to run their own YAML. The self-referencing trigger path `.gitea/workflows/ci-image.yml` came out of BOTH its own `paths:` and `ci-image-pin`''s `expected` in the same change — a DECIDED tradeoff, not a necessity: keeping it is workable via the branch dispatch, but prices every edit to that file, comments included, at a ~2GB publish plus a five-pin bump, redone after every rebase. The cost is stated, not assumed away — a change to HOW the image is built that lives only in `ci-image.yml` no longer republishes, and the ONLY remedy is to make it alongside a `docker/ci/**` edit: publishing after the merge and then pinning cannot work, because `expected` is the last `docker/ci` commit and would reject that pin. This closes the push route INTO THIS FILE, not the class: `docker-build.yml` remains reachable from an arbitrary ref by a `v*` tag push and by `pull_request`, and four workflows carry an unrestricted `workflow_dispatch`. The DISPATCH THIRD of that is settled — #853 probed it and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`): no ref restriction exists at Gitea 1.27.1, and restricting it would close nothing anyway, because the head-resolved `pull_request:` route runs attacker-authored YAML that reaches every secret in the store. The `v*` tag push and `pull_request:` rows are NOT settled and remain open in #885. Do not re-derive any of this.'
signals: 'unfiltered push trigger, branches filter on push, attacker YAML on a runner, feature branch publishes an image, self-referencing workflow trigger, ci-image-pin expected goes stale, publish-then-pin two-step, toolchain image recovery after a rebase, workflow_dispatch on a branch · paths: `.gitea/workflows/ci-image.yml`, `.gitea/workflows/pr-checks.yml`, `.gitea/workflows/docker-build.yml`, `docker/ci/Dockerfile`, `docs/ci-cd.md`, `docs/decisions/records/ci/actions-credential-scoping.md`, `scripts/e2e-ui.sh` · issues: #744, #853, #885, #697, #746, #835, #445, #390, #594, #597'
mechanics: '`docs/ci-cd.md` -> "CI toolchain image" -> "Publishing from a branch is a dispatch, not a push"; branch-dispatch probe 2026-08-27 (run 2340, published `ersatztv-ci:43b1e45`)'
---
@@ -66,7 +66,14 @@ mechanics: '`docs/ci-cd.md` -> "CI toolchain image" -> "Publishing from a branch
`ersatztv:prod` with no dispatch at all. On top of that, four workflows carry an unrestricted
`workflow_dispatch`. What #744 changed is that none of this is any longer a side effect of an
ordinary branch push. Read it as protection against an accidental or drive-by push, **not** as a
boundary against a malicious or compromised writer; the whole remaining class is tracked in #853.
boundary against a malicious or compromised writer. That remaining class was worked in #853 and
**accepted rather than fixed** — `ci.workflow-dispatch-ref-unrestricted` carries the probe and the
reasoning: Gitea 1.27.1 has no ref restriction for `workflow_dispatch` and no protected-environment
concept, and restricting dispatch would change nothing, because `docker-build.yml`'s head-resolved
`pull_request:` route runs attacker-authored YAML, which reaches every secret in the store — so it
covers `renovate.yml`'s `RENOVATE_TOKEN` too, without dispatching `renovate.yml` at all. **Only the
dispatch third is settled**: the `v*` tag push and the `pull_request:` route itself stay open in
#885.
- **The persist-credentials exemption was downstream of this, and closed with it.** `ci-image.yml`'s
checkout was the one left without `persist-credentials: false` (#746), for exactly the two mechanical
reasons above. Both gone, the flag went on in the same change, which is what let #835 ship its guard
+1 -1
View File
@@ -41,7 +41,7 @@ context, which gives the login specs a signed-out browser without a logout dance
CI — a retry lets a flaky flow merge looking green. Coupling worth knowing: vitest's default `include`
glob would run `web/e2e/*.spec.ts` under jsdom, so `vite.config.ts` excludes `e2e/**` by spreading
`configDefaults.exclude` — not by narrowing `include` to `src/**`, which would silently stop collecting
the real vitest test under `web/scripts/`.
the real vitest tests under `web/scripts/`.
**Lifecycle correctness in `scripts/e2e-ui.sh`** — all four found by adversarial review, **none by a
passing run**; that is the transferable lesson (green runs never exercise the failure/interrupt paths).
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,90 @@
---
key: ci.workflow-dispatch-ref-unrestricted
title: '2026-08-30 — `workflow_dispatch` cannot be ref-restricted at Gitea 1.27.1, and restricting it would close nothing while `docker-build.yml` keeps `pull_request:` (#853)'
status: active
since: '2026-08-30'
supersedes: none
superseded-by: none
stale-after: '2027-02-28'
rule: 'Gitea 1.27.1 offers NO mechanism to restrict `workflow_dispatch` by ref, and has no protected-environment concept at all — PROBED across the REST API, the loaded config and the CLI, not assumed (the WEB UI was not swept; the body says why that is acceptable here and where it would matter). The dispatch body schema `CreateActionWorkflowDispatch` makes `ref` a required free-form string with no allow-list or pattern field; zero of the 308 documented API paths contain "environment", and Actions secrets exist only at org/repo/user scope with no per-ref or per-environment gate; `/api/v1/settings/actions` 404s; the config file the running server actually loads (`/etc/gitea/app.ini`, named by its own `--config`) sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`; and the `gitea` CLI exposes exactly ONE Actions subcommand, `gitea actions generate-runner-token`, which registers a runner and restricts nothing. Treat the VERSION, not the `stale-after` date, as the real trigger to re-probe: an upgrade past 1.27.1 invalidates every capability claim here the day it lands, months before the date fires. The four unrestricted dispatches (`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml`, `renovate.yml`) are therefore ACCEPTED — but the operative reason is NOT "repository write access is the boundary", which is the argument to avoid because it is unfalsifiable and it hides the real route. The operative reason is that **dispatch is not the cheapest route to ANY of it**: `docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that route executes ATTACKER-AUTHORED YAML — and such YAML can name any secret in the repo store, not merely the ones the committed workflows happen to reference (`ci.gate-trigger-base-resolved`, verbatim: "any PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store"). Label that step honestly: it is INFERRED from the repo-scoped secret model plus that record, NOT measured here, because the measurement would print a live credential into a run log. That generalizing step is what makes the argument cover all four rather than just the registry pair: `renovate.yml`''s `RENOVATE_TOKEN`/`GH_COM_TOKEN` are reachable from a PR without dispatching `renovate.yml` at all, and `dependency-scan.yml` references no `secrets.` whatever — which corrects #853''s own table row for it. On the registry credential, SIX jobs in `docker-build.yml` held `REGISTRY_PASSWORD` on the PR route (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them — `test` and `migrations` — branch-protection required contexts per `.gitea/required-status-contexts.json`. That is CLOSED as of 2026-09-04 (#885, `ci.pr-route-carries-no-stored-credential`): nothing on the `pull_request` route names any stored secret — no job and no workflow scope — the toolchain image is pulled anonymously and the commit-status API read unauthenticated, and the PAT survives in `build`, which the route cannot reach. The invariant it is held by is "no part of a `pull_request`-triggered workflow may name a `secrets.*`" — every job the trigger reaches AND the workflow scope outside `jobs:`, since a root `env:`/`defaults:` is materialised into every job and no job-level `if:` can take it off the route — DERIVED from the git index by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`, never the six-name list, and never "every `container:` job" — `toolchain-preflight` was deliberately container-free and took the credential through `ETV_REGISTRY_AUTH`, so that predicate names five of the six and reproduces on day one the exact staleness it was written to prevent. NOTE WHAT THAT DID NOT CHANGE, which is this record''s whole point: head-supplied YAML can still name every secret in the store, so the PR route is still cheaper than any dispatch. "Deliberate act" throughout carries `ci.toolchain-image-publish-is-a-dispatch`''s sense — an act OUTSIDE the ordinary contribution flow, not a raw step count: opening a PR costs zero such acts and a dispatch costs one. Restricting dispatch would therefore close the more visible route and change nothing. The residuals were the PR route AND the `v*` tag push — a single act, explicitly outside `release.main-direct-push-disabled` — and both were addressed in #885 rather than by restricting dispatch: the tag push is now whitelisted to the release operator (`release.tag-protection-v-star`) and the PR route carries no stored credential. Neither makes dispatch worth restricting, because the remaining exposure — head YAML naming any secret in the store — is reached by opening a PR and is unaffected by either.'
signals: 'workflow_dispatch ref restriction, restrict dispatch by branch, protected environment, environment-gated secret, Gitea 1.27.1 actions capabilities, settings/actions 404, attacker-supplied workflow YAML, head-resolved pull_request trigger, PR run gets repo secrets, REGISTRY_PASSWORD reachable from a PR, tag_protections, v* tag whitelist, renovate bot write scope · paths: `.gitea/workflows/ci-image.yml`, `.gitea/workflows/docker-build.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/renovate.yml`, `docs/ci-cd.md` · issues: #853, #744, #885, #697, #742, #748, #845'
mechanics: '`docs/ci-cd.md` -> "CI toolchain image" -> "Publishing from a branch is a dispatch, not a push"'
sources: 'Gitea 1.27.1 probe, 2026-08-30 — `swagger.v1.json` (308 paths, 0 matching "environment"); `CreateActionWorkflowDispatch` schema; live `tag_protections` = `[]`; live `branch_protections` = one `main` rule; `/api/v1/admin/users` = exactly `timothy` (admin) + `renovate` (non-admin), with `permission: write` from `/repos/timothy/ersatztv/collaborators/renovate/permission`; host `app.ini` `[actions]`; differential dispatch-authorization probe with anonymous and authenticated-non-writer controls'
---
- **The two capability questions the issue named were probed to a negative, and the negatives are the
durable part of this record.** There is no ref restriction for `workflow_dispatch` at 1.27.1: the
endpoint takes `ref` as a required free-form string and no repo, org or admin endpoint scopes which
refs may be selected. There is no protected-environment concept to gate a secret behind — not a
missing endpoint but a missing *feature*, evidenced by zero of 308 documented paths mentioning
"environment" and by secrets existing only at org, repo and user scope. THREE surfaces were swept,
because "the obvious call 404s" is not evidence of absence: the REST API, the config file the running
server names in its own `--config`, and the `gitea` CLI. **The web UI was not swept, and that is a
real limit on this evidence rather than a formality** — a Gitea Actions control can exist with NO API
surface at all, which `ci.actions-credential-scoping` records for the owner-level default: it 404s on
the API and was nonetheless set, verified and reverted. That record does NOT name the surface used —
a non-API one is an INFERENCE by elimination here, since `app.ini` sets only two `[actions]` keys and
the CLI exposes only `generate-runner-token`. It is left open because the
decision does not rest on it — what carries the conclusion is the secret-scope model (org/repo/user
only, no per-ref gate) plus the fact that restricting dispatch would close nothing regardless. Sweep
the UI before citing this record as proof that no such control CAN exist. **Check the CLI's
exit code, not its match count** — run as root the binary refuses to start and exits 1, so grepping
its empty help output "finds nothing" for entirely the wrong reason; run as the service user it does
list an `actions` command, whose sole subcommand is `generate-runner-token`. Re-probe on the VERSION
rather than the date: `stale-after` is a calendar field, but what invalidates all of this is an
upgrade past 1.27.1, which can land months earlier.
- **Do not close this class with "anyone with repository write can already do worse."** That argument
was the issue's own leading option and it is the one to reject, for two reasons. It is unfalsifiable
as stated, so it licenses accepting anything; and it skips the question that actually decides the
matter, which is whether dispatch is the *cheapest* route to the credential. It is not.
- **`pull_request:` is the cheaper route, and it is the finding that settles the decision.**
`docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR head, so the run
executes **attacker-authored YAML** — and that, not the specific jobs, is the whole argument: such
YAML can name any secret in the repo store, not only the ones the committed workflows reference.
`ci.gate-trigger-base-resolved` states it directly — "any PR-added workflow can reference
`RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store". That step is INFERRED from the
repo-scoped secret model plus that record, not measured here: measuring it prints a live credential
into a run log. **That is the step that makes this
cover all four dispatches**, `renovate.yml` included, even though `renovate.yml` has no
`pull_request:` trigger of its own; without it the argument would reach only the registry pair.
On the registry credential, SIX jobs held `REGISTRY_PASSWORD` on the PR route —
`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format` — of which
`test` and `migrations` are both branch-protection required contexts
(`.gitea/required-status-contexts.json`). That was closed in #885
(`ci.pr-route-carries-no-stored-credential`), by the invariant and not by the list: *no part of a
`pull_request`-triggered workflow may name a `secrets.*`* — neither a job the trigger reaches nor
the workflow scope outside `jobs:`, whose `env:`/`defaults:` are materialised into every job —
derived from the git index, because a remediation scoped to those six names silently misses the
seventh. Not "every `container:` job" either — `toolchain-preflight` is deliberately
container-free (`runs-on: small`) and took the credential through `ETV_REGISTRY_AUTH`, so that
predicate names five of six and is stale before it ships.
**The route itself is untouched by that fix**, which is why this record still stands: head-supplied
YAML can name any secret in the store, so "push a branch, open a PR" still reaches all of them with
**no act outside the ordinary contribution flow**, where a dispatch costs one — that is this
repo's sense of "deliberate"
(`ci.toolchain-image-publish-is-a-dispatch`), not a raw step count, which would read 2 vs 2. Any
control that restricted dispatch and left this open would close the more visible route and report
progress that did not happen. Per-`if:` ref gates on the publish steps do not change this: they live
in the same head-supplied file and an attacker edits them out.
- **The write set is two accounts, and the asymmetry between them is worth stating once.** They are
`timothy` (owner, site admin) and `renovate` (collaborator, `permission: write`, `is_admin: false`).
Against the admin, no control administered by that same admin is a boundary — that is a property of
the trust model, not a defect. Against the bot it is different, and the difference is easy to
misread: considerable machinery bounds what Renovate can get MERGED (`review-verdict/h10` as a
required context, and the exemption that auto-passes a Renovate PR only when every changed path is a
dependency manifest AND none is under any of the six protected directories `.claude/`, `.codex/`,
`.gitea/`, `.husky/`, `scripts/`, `docker/ci/` — #698, #742, #845; `ci.exemption-provenance` has the
authoritative form). None of that machinery bounds
what Renovate can RUN. A reader of `ci.exemption-provenance` could reasonably infer the bot is
tightly constrained; on the run path it is not. Measured rather than inferred: on a matched probe
against the same endpoint and payload, an authenticated non-writer short-circuits at authorization
with `"not found"` while `renovate` on this repo reaches ref resolution with `"ref ... doesn't
exist"` — the same response a known writer gets — so `renovate` passes the dispatch authorization
gate.
- **One cheap control was identified here and applied in #885: the `v*` tag rule.** Gitea 1.27.1
supports tag protection (`name_pattern` plus a username/team whitelist), and `tag_protections` was
empty when this record was written (2026-08-30), leaving `docker-build.yml`'s tag-push row open
against the bot. It was deliberately not applied as a rider on a decision record — its failure mode
is a broken release cut, so it earned its own change and its own verification. It now exists as
`v*` whitelisted to `timothy` (`release.tag-protection-v-star`), and it carries no weight against
the admin actor, which was true when it was proposed and is true now.
File diff suppressed because one or more lines are too long
@@ -6,7 +6,7 @@ since: '2026-07-22'
supersedes: none
superseded-by: none
rule: A channel can attach `GraphicsElement`s directly via a new `ChannelGraphicsElement` join table (a base layer under deco/playout-item elements), and a built-in text element (`on-now-next.yml`) is seeded once per database so the On Now/Next overlay works out of the box.
signals: 'ChannelGraphicsElement, Channel graphics attachment, GraphicsElementSelector base layer, on-now-next seeded element, GraphicsElementDefaults.OnNowNextFileName, builtIn discriminator · paths: `ErsatzTV.Core/Domain/ChannelGraphicsElement.cs`, `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs`, `ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs`, `ConfigElementKey.GraphicsOnNowNextSeeded`, `GraphicsElementResponseModel.BuiltIn` · issues: #74'
signals: 'ChannelGraphicsElement, Channel graphics attachment, GraphicsElementSelector base layer, on-now-next seeded element, GraphicsElementDefaults.OnNowNextFileName, GraphicsElementDefaults.OnNowNextSeededPath, builtIn discriminator · paths: `ErsatzTV.Core/Domain/ChannelGraphicsElement.cs`, `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs`, `ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs`, `ConfigElementKey.GraphicsOnNowNextSeeded`, `GraphicsElementResponseModel.BuiltIn` · issues: #74, #568'
---
#74 asked for a transient "On Now / Next" text bug burned onto the transcoded stream at each
@@ -40,8 +40,22 @@ four joins (composite key `{ChannelId, GraphicsElementId}`), added via a dual-pr
preset).
- The API needed a way for the SPA to find the built-in element without a fragile name-match — the
direct #67 lesson (`WatermarkResponseModel.imageSource`). `GraphicsElementResponseModel` gained a
server-derived `BuiltIn` bool, computed by comparing the row's `Path` filename to
`GraphicsElementDefaults.OnNowNextFileName` rather than trusting the element's editable `Name`.
server-derived `BuiltIn` bool, computed from the row's `Path` rather than the element's editable
`Name`. The test is `GraphicsElementDefaults.IsOnNowNext` — ordinal equality against the full
seeded path, plus `Kind == Text`. #568 sharpened it from the original bare-filename comparison,
which was folder-agnostic: a user element named exactly `on-now-next.yml` in another template
folder also reported `builtIn:true`. `Kind` is inside the predicate rather than a filter each
caller adds, because the seeder's lookup requires it: split across the two sites, an Image row at
the seeded path was `builtIn:true` on the wire while the seeder refused to resolve it. Case sensitivity is kept, deliberately: the remedy #568 prescribes — the
full seeded relative path — is exactly as case-sensitive as the filename match it replaces, and a
case-INsensitive test would hand the built-in identity to a user element differing from the seeded
path only in case. The built-in element is the exact file the seeder wrote, at the path it wrote it
to. Every site applies that predicate **in memory**, never as a `Where` clause — including the
seeder's own "does the row exist yet?" check, which calls `GetBuiltInElementId` rather than asking
the question a second way. `GraphicsElement.Path` carries no explicit collation, so a SQL `Path ==`
comparison answers case-sensitively under SQLite and normally case-INsensitively under MySQL: one
site in SQL and another in memory disagree on MySQL alone, which the SQLite suite cannot show. See
`graphics.on-now-next-on-by-default` and `docs/graphics-elements.md`.
- The channel editor's Branding-tab "Show On Now / Next overlay" switch follows the exact pattern
of the existing logo-bug toggle: on adds the built-in element's id to `graphicsElementIds`, off
removes it; disabled (with an explanatory caption) when the channel is HLS-Direct.
@@ -60,8 +60,10 @@ is simply absent, indistinguishable from never having enabled it — so the one-
re-attaches it. That is inherent to "enable it on all channels by default" rather than a defect. The
never-re-attach guarantee therefore holds *from the marker onwards*, not across the upgrade boundary.
Identity is the element's **filename** (`GraphicsElementDefaults.OnNowNextFileName`), never the
user-editable `Name` — the #67 lesson carried through #74.
Identity is the element's **full seeded path and `Kind == Text`** — `GraphicsElementDefaults.IsOnNowNext`,
ordinal equality against `OnNowNextSeededPath` — never the user-editable `Name`: the #67 lesson carried
through #74, sharpened from a bare filename to the full path by #568 because a filename-only match
was folder-agnostic (see `graphics.channel-level-attachment` and `docs/graphics-elements.md`).
**HLS Direct is excluded at both sites.** ErsatzTV is not transcoding there, so `GraphicsElementSelector`
returns empty and the editor disables the toggle; an attachment would be inert while still reading as
File diff suppressed because one or more lines are too long
@@ -0,0 +1,79 @@
---
key: process.hook-resolves-inputs-from-repo-root
title: '2026-08-30 — a hook resolves every path it sources or executes from `$repo_root`, never from an environment variable (#858)'
status: active
since: '2026-08-30'
supersedes: none
superseded-by: none
rule: 'A hook resolves every path whose CONTENT it sources, executes, or consults to decide, from `$repo_root` — derived from the hook''s own `${BASH_SOURCE[0]}` — and never from `$CLAUDE_PROJECT_DIR` or any other environment variable. There is NO telemetry exemption, and the attempt to write one is instructive: the first draft of this record exempted `ETV_HOOK_FIRE_LIB` on the grounds that "a wrong log destination is not a wrong verdict", and cold review refuted it by execution — that path is `. `-SOURCED, so whatever it names runs as CODE inside the hook before stdin is read and before `decide` exists; a file there that prints an `allow` decision and exits 0 grants the merge having bypassed every check in the file. A path''s PURPOSE does not bound its authority; how the hook consumes it does. THE SAME TEST DRAWS THE OTHER BOUNDARY, and it is not optional: a path the hook consumes as its SUBJECT — the tree it is asked to JUDGE — is caller-supplied by design, and binding it to `$repo_root` would break the guard, since the hook lives in one tree and must judge whichever worktree is being acted in. Two live cases, both deliberate: `pretooluse-bom-guard.sh` takes the committed tree from the command''s `cd`, then the payload `cwd`, then `${CLAUDE_PROJECT_DIR:-$PWD}` as a last-resort default; and `decisions-guard.sh` `cd`s to `git rev-parse --show-toplevel` and EXECUTES that tree''s `scripts/decisions_validate.py`, whose exit code is the hook''s decision — the stronger case, since it is code and not data, and it is admissible for the same reason: a pre-commit validator must validate the tree being committed, and under husky, its only launcher, git sets the cwd to that tree so the two roots agree anyway. The distinction is not first-two-sources-versus-third and not data-versus-code: it is WHOSE QUESTION the path answers. Ban the variable in the assignment naming the AUTHORITY — the code that decides — never every mention of it in a hook. The failure that matters is not an attacker — the same variable already names the hook binary in `.claude/settings.json`, so a hostile value has chosen which hook runs and the gate is moot before any inner path is read — it is LAUNCHER DIVERGENCE, and it is reachable: husky invokes the prepush hooks as `./.claude/hooks/…`, a relative path wholly independent of `$CLAUDE_PROJECT_DIR`, so the two roots genuinely disagree there. A wrong path holding a plausible file returns a confident answer about another tree; a missing path only asks, so the silent direction is the dangerous one. Two halves of one comparison must never come from two roots.'
signals: 'env var as a security input · `$CLAUDE_PROJECT_DIR` vs `$repo_root` · `${BASH_SOURCE[0]}` self-location · sourced file is code not configuration · which checkout''s code decided this · launcher divergence · husky relative path vs harness env · a wrong path returns a confident answer, a missing one asks · both halves of a comparison from one root · paths: `.claude/hooks/pretooluse-merge-consent.sh`, `scripts/hook-fire-log.sh`, `scripts/check-review-verdict.sh`, `scripts/check-required-contexts.sh` · issues: #858, #891, #787, #629'
mechanics: 'No linter can spot "this path should have come from `$repo_root`". Enforcement is review plus a behavioural test per site: point `$CLAUDE_PROJECT_DIR` at a decoy tree whose copy of the resolved file returns the OPPOSITE decision, and assert the outcome is unchanged. That test needs a NEGATIVE CONTROL or it proves nothing — the passing outcome is also what an inert decoy produces — so run the same decoy again with the hook COPIED INTO it, making the decoy genuinely `$repo_root`, and assert the decision DOES flip. Since #891 that test is PARAMETRIZED OVER THE DERIVED HOOK POPULATION in `test_hook_fire_log.py` — `test_CLAUDE_PROJECT_DIR_cannot_choose_the_SOURCED_sink` beside `test_the_sink_decoy_IS_sourced_when_it_genuinely_is_the_repo_root`, and the husky RELATIVE launch as its own pair, since that construct resolves through the CWD — so a hook added tomorrow arrives covered rather than needing someone to remember it. The STATIC half is not a pattern over the line but BYTE-IDENTITY of the preamble''s two lines (a lexical rule was tried and withdrawn; see the body), proved by `test_a_LATER_reassignment_the_regex_cannot_see_is_DETECTED` for the shape only it catches, with `test_an_ENV_VAR_resolved_sink_path_is_DETECTED` and `test_the_NEXT_env_var_to_be_invented_is_DETECTED` pinning the diagnostic arms, each asserting its OWN arm''s phrase — repair for `test_an_ENV_VAR_resolved_sink_path_is_DETECTED`, whose assertion the byte-identity message''s echo of the offending line did satisfy by itself, and prudence for the others. The sink''s own root resolution additionally requires the root to OWN it, proved by `test_the_report_REFUSES_a_root_that_does_not_OWN_this_sink`. Two worked single-site pairs live in `test_merge_consent_required_check.py`: `test_CLAUDE_PROJECT_DIR_cannot_choose_which_verdict_classifier_runs` and `test_the_SOURCED_fire_log_library_cannot_be_chosen_by_CLAUDE_PROJECT_DIR`, each beside its `..._IS_honoured/sourced_when_it_genuinely_is_the_repo_root` control.'
---
`.claude/hooks/pretooluse-merge-consent.sh` resolved three decision-bearing paths from two different
roots, in one file. #787 bound the guard-scope freshness checker and its snapshot to `$repo_root` and
wrote the reasoning beside them; the H10 verdict classifier, added at #629, kept
`${CLAUDE_PROJECT_DIR:-.}`; and the sourced fire-log library kept `${CLAUDE_PROJECT_DIR:-<self>}`.
**Two resolutions of the same question inside one file is the state most likely to be "tidied" toward
the weaker one.** That is why this is a record rather than a second inline comment: a reader
reconciling the inconsistency has even odds of reconciling it the wrong way, and the comment that
would have stopped them is attached to the other site.
**The exemption this record tried to grant, and why it does not exist.** The first draft drew the
boundary at "does its CONTENT change the decision" and placed `ETV_HOOK_FIRE_LIB` outside it as
telemetry. Cold review refuted that by running it: a decoy `hook-fire-log.sh` in an env-var-named tree
printing an `allow` decision and exiting 0 took the merge decision, 500 lines before the gate. The
line is `. `-SOURCED — sourcing is execution, and a file's stated purpose grants no protection at all.
The lesson generalises past this variable: **classify a path by how it is CONSUMED, never by what it
is called.**
**Name the real threat, or the rule gets argued away.** "An env var is a security input" invites the
correct objection that `$CLAUDE_PROJECT_DIR` already selects the hook itself, so an attacker who
controls it has won already — and an argument refutable on its face gets the rule reverted. The
defensible claim is narrower and holds: the harness launcher and husky are DIFFERENT launchers.
Claude PreToolUse hooks are started as `$CLAUDE_PROJECT_DIR/.claude/hooks/…`, so there the two roots
agree by construction and binding to `$repo_root` is consistency rather than repair. Husky starts the
prepush hooks as `./.claude/hooks/…` from the pushed tree, wholly independent of the variable —
measured 2026-08-30, a push from one worktree with the variable naming another sources the other
tree's code into a gate that can block or allow the push. That is ordinary, non-adversarial, and
produces a confident wrong answer rather than a visible failure.
**Scope actually shipped, so nobody reads more into it.** #858 fixed the verdict classifier and the
fire-log resolution in `pretooluse-merge-consent.sh` only — because leaving a total gate bypass above
the gate that PR was hardening would have made the rest of it decorative, not because one hook is
special. #891 then swept the other twelve tracked hooks **together, in one change**, and reconciled
the second resolution inside `scripts/hook-fire-log.sh` itself: its report preferred
`$CLAUDE_PROJECT_DIR` when deriving the hook POPULATION, which reads as harmless because a report
decides nothing — but the report's two halves ARE that population and the log, so resolving them
from two roots earns a NEVER-FIRED row for a hook that fired. Every tracked hook now carries that
assignment byte-identically. The twelve above is a dated fact about what #891 swept, not an
invariant: the INVARIANT is "every tracked hook", and no count of it is pinned anywhere, because the
population is derived from `git ls-files` by the guard — a new hook is covered the moment it exists
rather than when someone remembers to update a number. (That derivation is the GUARD's. `etv_hook_fire_report`'s own
population is a filesystem glob over the tree it roots at — a report, not a gate — so the two are
not the same population and should not be cited as one.)
**Byte-identical is the property, not an aesthetic.** Diverging one copy is how this repo acquired
the defect that extracting `scripts/lib/branch-rule-classifier.jq` was meant to end, so the sweep
was all-or-nothing, and the check is a comparison over the derived population rather than a comment
in each hook — one machine-checked rule beats a paragraph repeated once per hook and drifting in all
but one of them.
**The check pins BYTE-IDENTITY, and arriving there took a withdrawal.** The obvious rule is lexical
— ban `$CLAUDE_PROJECT_DIR` in the assignment — and it is worth exactly the one name in it. Three
review rounds walked the successors: `${NEXT_VAR:-$(… BASH_SOURCE …)}` satisfies a containment test
while the variable still wins; then backticks and `$((…))`; then `$(printenv VAR)`, `$1`, `$?`, an
INDENTED or `export`ed reassignment further down that a column-anchored regex cannot see, and `$'…'`
quoting that makes the required token literal. Each fix admitted the next shape, which is this
repo's recorded signal to stop rather than patch again — a guard was WITHDRAWN from that same test
file after four iterations of pattern-matching shell source. A shell assignment is not recognisable
by regex, so the guard stopped trying: every non-comment line mentioning the variable must be one of
the two canonical lines, byte for byte. The lexical arms over the preamble survive only to say WHY a
line is wrong — that includes the pattern matching the sourcing line, which byte-identity
necessarily matches and which therefore decides nothing on its own. The arms judging other things —
a self-disabled hook, and the begin call's PRESENCE, name, mode and ordering — are not subsumed and
still decide. Each is pinned by its own test asserting its own fault message, so this sentence is a
map and not the guard: deleting an arm on the strength of this list reddens the suite.
The cost is that a future rewrite of the line must edit one constant and all the hooks together,
which is the property #891 exists to hold.
@@ -0,0 +1,34 @@
---
key: process.orchestrated-session
title: 2026-09-04 — An orchestrated session runs several issue slots under one referee, and each slot is still a whole kickoff session (#907)
status: active
since: '2026-09-04'
supersedes: none
superseded-by: none
rule: A session may run several issues at once only as an orchestrated session — an orchestrator that never picks, claims, codes or pushes; one worktree per issue under `~/orca/workspaces/ersatztv/`; the local gate and cold review inside the worktree BEFORE the single push; the referee alone ticks `## Done-when` boxes and posts the verdict. Two scopings hold across every slot — a rebase onto `origin/main` pushed with `--force-with-lease` is the ONE sanctioned rewrite of a pushed branch (the commits are unchanged, only the base moved; H11 refuses the alternative and merging main in is forbidden), and it voids the verdict, so the rebased head is re-reviewed. Live-E2E needs no machine-wide lock — the launcher's conflict is its per-worktree `wwwroot`, so slots run on distinct ports and the launcher's pre-flight refuses a busy one.
signals: 'orchestrator · referee · slots · parallel issues · worktree per issue · force-with-lease · rebase voids verdict · who ticks Done-when · E2E per worktree port · paths: `docs/handoffs/orchestration.md`, `docs/handoffs/orchestrator-prompt.md`, `.claude/workflows/ersatztv-issue-build.js` · issues: #907'
mechanics: '`docs/handoffs/orchestration.md` owns roles, isolation and the landing order; the Workflow scripts under `.claude/workflows/` encode it. Box-ticking: the implementer writes the evidence per box into the `## Closing record`; the orchestrator ticks after reading it and the review evidence, then `scripts/post-review-verdict.sh`.'
---
The kickoff (`docs/handoffs/chicorytv-issue-queue.md`) binds a session that closes one issue.
Running several at once does not relax any of it; what it adds is a referee, and two places where
the single-session rules needed a stated scope rather than a contradiction.
**Force-with-lease.** `process.pr-routine-sequence` forbids amending or force-pushing a pushed branch;
`release.format-as-you-touch-rebase` (H11) refuses to push a branch behind `origin/main` and forbids
merging main in. With several slots merging, a pushed branch is behind main as the normal case, so the
only path that satisfies both is a rebase whose commits are byte-for-byte the reviewed ones, pushed
with `--force-with-lease`. That is not the rewrite the first rule targets, which is a fix folded into
an already-pushed commit. The cost is the verdict: `review-verdict/h10` binds to a sha, so the rebased
head is reviewed again before a new verdict is posted. Rebase once, right after the review loop, and
`git patch-id --stable` before and after proves the patch did not change.
**Who ticks the boxes.** `release.merge-consent-autogrant` derives consent from the ticked boxes, so a
box ticked by the agent whose work it certifies is self-consent. The implementer supplies the evidence
per box; the referee ticks.
**No E2E lock.** A `mkdir` lock around `scripts/e2e-local.sh` was tried and rejected 2026-09-04: the
script is a launcher that returns with the server still running, so the lock released before the
assertions ran, and its stale-holder path double-acquired in 4 of 91 measured races. The conflict the
launcher documents is its `wwwroot` under the repo root, which is per worktree; two worktrees on two
ports do not share it.
@@ -5,9 +5,9 @@ status: active
since: '2026-08-05'
supersedes: none
superseded-by: none
rule: 'Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are unaffected (`tag_protections` governs those separately), so the release cut still works.'
rule: 'Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are governed by `tag_protections`, an entirely separate mechanism that does NOT inherit from this rule; since #885 it carries one entry, `v*` whitelisted to `timothy` (`release.tag-protection-v-star`), which is intended to leave the release cut working for the operator while refusing a `v*` push by the `renovate` bot that would publish a `:prod` image. Both halves are EXPECTED, UNVERIFIED: only the `timothy` credential exists here, so neither a real release cut nor a refused bot push has been exercised, and that record carries the same caveat.'
signals: 'direct push to main, push whitelist, enable_push false, branch protection bypass, review-verdict/h10 bypassable without forging, merge consent derived not asserted, pre-receive hook declined, Not allowed to push to protected branch, protected branch, tag_protections, release tag push, GITEA_TOKEN repo write, RENOVATE_TOKEN, site admin bypass, PR-only flow · paths: `docs/ci-cd.md` · issues: #743, #697, #698, #622, #672, #706, #742, server-management#714'
mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returns `[]`; repo is `fork: false`, `mirror: false`. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout. MEASURED 2026-08-28 on 1.27.1 (#747), superseding the earlier source-attested-only note on this field. Four SEPARATE throwaway scratch bases, each with its own protection rule, head branch and PR (they differ by construction, not by mutating one rule in place — arm A merged, so its PR could not be re-used); `main` was never merge-probed and its rule never PATCHed, `updated_at` unchanged at 2026-08-05. Each base required one probe-named context that was never posted, except in the control step below. `scratch/747-base` (#863), field at its `false` default, ORDINARY merge: REFUSED HTTP 405 `Not all required status checks successful`; posting only that context as `success` and repeating the ordinary merge on the same PR returned HTTP 200 — the one attribution control in the set, and the only variable between those two calls is the context. `scratch/747a-base` (#864), field `false`, admin `force_merge`: MERGED HTTP 200. `scratch/747b-base` (#865), rule identical but for the field set `true`, same call: REFUSED HTTP 405 — the A/B pair is the evidence that the field governs the force path; B''s 405 has no control of its own. `scratch/747c-base` (#866), rule as A but with `enable_bypass_allowlist: true` and an EMPTY `bypass_allowlist_usernames`: MERGED HTTP 200, so in THAT configuration the allowlist does not substitute for `block_admin_merge_override`; a non-empty list was not tested, and this cannot distinguish `does not govern admins` from `empty list treated as unconfigured`. Probing never required merging an unreviewed PR into `main`: a scratch BASE branch carries its own rule, so the whole experiment is disposable. All #747 probe artifacts (four scratch bases — `scratch/747-base` for the ordinary-merge pair plus `scratch/747{a,b,c}-base` for the three force-merge arms — each with its own protection rule and head branch, so four rules and eight branches, and PRs #863-#866) deleted and confirmed gone; `origin/main` head unchanged at `b16ec15d6` throughout, and `main`''s own rule was never PATCHed.'
mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returned `[]` at that probe; since #885 it carries one `v*` rule whitelisted to `timothy` (`release.tag-protection-v-star`). Repo is `fork: false`, `mirror: false`. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout. MEASURED 2026-08-28 on 1.27.1 (#747), superseding the earlier source-attested-only note on this field. Four SEPARATE throwaway scratch bases, each with its own protection rule, head branch and PR (they differ by construction, not by mutating one rule in place — arm A merged, so its PR could not be re-used); `main` was never merge-probed and its rule never PATCHed, `updated_at` unchanged at 2026-08-05. Each base required one probe-named context that was never posted, except in the control step below. `scratch/747-base` (#863), field at its `false` default, ORDINARY merge: REFUSED HTTP 405 `Not all required status checks successful`; posting only that context as `success` and repeating the ordinary merge on the same PR returned HTTP 200 — the one attribution control in the set, and the only variable between those two calls is the context. `scratch/747a-base` (#864), field `false`, admin `force_merge`: MERGED HTTP 200. `scratch/747b-base` (#865), rule identical but for the field set `true`, same call: REFUSED HTTP 405 — the A/B pair is the evidence that the field governs the force path; B''s 405 has no control of its own. `scratch/747c-base` (#866), rule as A but with `enable_bypass_allowlist: true` and an EMPTY `bypass_allowlist_usernames`: MERGED HTTP 200, so in THAT configuration the allowlist does not substitute for `block_admin_merge_override`; a non-empty list was not tested, and this cannot distinguish `does not govern admins` from `empty list treated as unconfigured`. Probing never required merging an unreviewed PR into `main`: a scratch BASE branch carries its own rule, so the whole experiment is disposable. All #747 probe artifacts (four scratch bases — `scratch/747-base` for the ordinary-merge pair plus `scratch/747{a,b,c}-base` for the three force-merge arms — each with its own protection rule and head branch, so four rules and eight branches, and PRs #863-#866) deleted and confirmed gone; `origin/main` head unchanged at `b16ec15d6` throughout, and `main`''s own rule was never PATCHed.'
---
**Why a whitelist was the wrong shape.** #743 proposed "a push whitelist on `main` (or disable direct
@@ -63,8 +63,21 @@ forced one. `main` carries the field `true`, which is what closes the force path
differs from row A's only in `enable_bypass_allowlist`, and its PR merged just the same — so enabling it did not substitute
for `block_admin_merge_override`. A NON-empty username list was not tested, and this cannot distinguish
"the allowlist does not govern admins" from "an empty list is treated as unconfigured". Enough to
refuse the swap; not enough to describe the field's semantics. The claim that it postdates 1.25.4 comes
from #747's issue body, not from a probe here.
refuse the swap; not enough to describe the field's semantics.
**That `enable_bypass_allowlist` postdates 1.25.4 was an INHERITED claim and is now a checked one
(#869, 2026-09-02).** It came from #747's issue body rather than from any probe, which is the shape
this repo keeps getting burned by — a claim whose only provenance is another artifact's prose. Read
off `modules/structs/repo_branch.go` at both tags: `EnableBypassAllowlist` does not occur anywhere in
the file at `v1.25.4` and is present at `v1.27.1`, where this instance also returns
`enable_bypass_allowlist` in a live branch-protection read. So the claim holds, and the reason it is
believed is now the source rather than a sentence someone typed into an issue.
Note the neighbouring field does NOT share that history, which is worth stating because the paragraph
above could invite the inference: `BlockAdminMergeOverride` / `block_admin_merge_override` is present
at `v1.25.4` too — in all three structs (`BranchProtection`, `CreateBranchProtectionOption`,
`EditBranchProtectionOption`). It is not a 1.27 addition, and nothing here should be read as saying it
is. What was new in this repo was our USE of it, not the field.
**Trap: `mergeable` does not answer the gate question.** On PR #863 — row 1, the ordinary-merge arm —
the API reported `mergeable: true` at the moment that merge was being refused 405, so there it did not
@@ -96,11 +109,13 @@ mirror (not applicable: `fork: false`, `mirror: false`). Merge remains the one i
**Why the release cut does not deadlock.** #743 flagged that the tag path had to keep working, and
#719 documents H11 blocking a tag-only push on every release cut. Branch protection is scoped to
`refs/heads/main`; tags are governed by an entirely separate mechanism, and `tag_protections` on this
repo is empty, so tag pushes are unrestricted by anything except ordinary write permission. Demonstrated
rather than assumed: from one worktree, the branch push to `main` was refused and a tag push succeeded.
Do not conflate the two mechanisms — disabling branch push says nothing about tags, and a future
tag-protection rule would not inherit from this one.
`refs/heads/main`; tags are governed by an entirely separate mechanism. `tag_protections` was empty
when this record was written (measured 2026-08-24), so tag pushes were unrestricted by anything except
ordinary write permission. Demonstrated rather than assumed: from one worktree, the branch push to
`main` was refused and a tag push succeeded. Do not conflate the two mechanisms — disabling branch
push says nothing about tags, and the tag-protection rule #885 later added
(`release.tag-protection-v-star`, `v*` whitelisted to `timothy`) does not inherit from this one in
either direction.
**The `docker-build.yml` `persist-credentials` question (#743's fourth box), since resolved in #746.**
Head-resolved jobs kept a write-capable credential in `.git/config` because their `actions/checkout`
@@ -0,0 +1,34 @@
---
key: release.tag-protection-v-star
title: '2026-09-04 — `v*` tag pushes are whitelisted to the release operator, closing the tag-push route against the bot (#885)'
status: active
since: '2026-09-04'
supersedes: none
superseded-by: none
rule: 'The repository carries one tag-protection rule: `name_pattern: v*`, `whitelist_usernames: ["timothy"]`. This matters because `docker-build.yml` triggers on `push.tags: [v*]` and a `v*` tag push BUILDS AND PUBLISHES the `:prod` image — the tag the `jazz-media` Komodo stack follows. `release.main-direct-push-disabled` closed every route to `main`, and left this one open: a tag push is a separate mechanism that branch protection does not govern, so it was the cheapest remaining single act that reaches production. It buys NOTHING against the admin actor (`timothy` is on the whitelist, and no self-administered control bounds an owner) and everything it buys is against `renovate`, the repo''s other write-capable account (`permission: write`, `is_admin: false` from `GET /repos/timothy/ersatztv/collaborators/renovate/permission`, measured in #853 2026-08-30 and re-read 2026-09-04): the H10 verdict gate and the Renovate exemption rule bound what that bot can get MERGED, and nothing bounded what it could TAG. The rule''s failure mode is a BROKEN RELEASE CUT, so it is verified in both directions before it is trusted: the negative half is done and recorded below; the POSITIVE half — a real `v*` release-cut tag push by `timothy` still succeeding — is DEFERRED to the operator''s next real cut, because pushing a `v*` tag from any working session publishes `:prod` and a release is the operator''s act, not a verification step. Treat that as OPEN until a cut lands. There is no committed mirror of this rule: it lives in Gitea, and this record''s `mechanics:` carries the exact call and the read-back, the same shape `release.main-direct-push-disabled` uses.'
signals: 'tag_protections empty, v* tag push publishes prod, release cut blocked, renovate can push a tag, tag protection whitelist, release operator whitelist, prod image published by tag, deferred positive verification · paths: `.gitea/workflows/docker-build.yml` · issues: #885, #853, #743, #698, #742'
mechanics: 'Applied 2026-09-04 with `POST /api/v1/repos/timothy/ersatztv/tag_protections` body `{"name_pattern": "v*", "whitelist_usernames": ["timothy"]}`, which returned `{"id": 1, "name_pattern": "v*", "whitelist_usernames": ["timothy"], "whitelist_teams": [], "created_at": "2026-09-04T23:18:50+02:00"}`. Read back the same day with `GET .../tag_protections` -> the one rule, fields as echoed. It is reversible with `DELETE .../tag_protections/1`. NOTE THE ROUTE NAME: `/repos/{owner}/{repo}/tags/protection` is NOT it — measured 2026-09-04, that path resolves `protection` as a TAG NAME and answers 404 `{"message": "tag doesn''t exist: protection"}`. Before this the endpoint returned `[]` (measured #743 2026-08-24 and again 2026-09-04). NEGATIVE VERIFICATION available from this session and performed: a NON-`v*` tag (`probe-885-tagpush`) pushed by `timothy` succeeded and was deleted, confirmed gone by `git ls-remote --tags` — so tag pushes still work at all and the rule is not mis-scoped to `*`. NOT VERIFIED, and it cannot be from a working session: that a real `v*` release-cut push still succeeds, and that a push by `renovate` is refused (only the `timothy` credential is available here). The first is the operator''s next cut; the second would require the bot''s credential.'
---
**What this closes.** `release.main-direct-push-disabled` made every change to `main` go through a PR
with a sha-bound review verdict, and said in the same breath that "tag pushes are unaffected
(separate mechanism)". That sentence was accurate and was also the gap: `docker-build.yml` publishes
`:prod` on a `v*` tag, so one tag push reaches the image prod's stack follows, with no PR, no review
verdict and no merge gate anywhere in the path. `tag_protections` was empty, so nothing stood in it.
**What it does not close, said plainly.** Against `timothy` it is decoration: the whitelist contains
that account, and an owner can delete the rule with one API call anyway. No self-administered control
is a boundary against the actor who administers it. The rule is worth having because the actor set
has a *second* member — `renovate` holds repository write, opens PRs on its own, and is bounded on
the merge path by `review-verdict/h10` and the exemption rule (#698, #742, #845) while being
completely unbounded on the tag path. This makes the two paths agree.
**Why it was split out of #853 rather than bundled.** Its failure mode is not a leaked credential, it
is a release cut that will not push — a failure that surfaces at the worst moment, when someone is
trying to ship. That earns its own change and its own verification, which is why the positive half is
recorded above as deferred rather than quietly assumed. A rule whose only failure mode is untested is
a rule that gets deleted in a hurry by whoever hits it first.
**How to unblock a release cut that this rule breaks.** `DELETE /api/v1/repos/timothy/ersatztv/tag_protections/1`,
push the tag, re-`POST` the rule. Prefer adding the pushing account to `whitelist_usernames` with a
`PATCH` if the cut is moving to a different operator, so the protection is never left off.
@@ -0,0 +1,43 @@
---
key: scan.mediaserver-statistics-from-server
title: 2026-10-21 — A media-server scanner reads a version's statistics from the server, never from the library-items projection (#924)
status: active
since: '2026-10-21'
supersedes: none
superseded-by: none
rule: 'Every media-server scanner writes a head `MediaVersion`''s statistics (streams, width/height, aspect, frame rate) from what the server reports per item — `GetMediaServerStatistics` (Jellyfin/Emby playback info) or the metadata call that carries them (`ServerReturnsStatisticsWithMetadata`, Plex) — and NEVER writes the library-items projection''s version, whose `Streams` list is empty. When the server cannot supply them, nothing is written. In `MediaServerMusicVideoLibraryScanner` the hook is `abstract`, not a virtual returning `None`.'
signals: 'zero MediaStream rows, Width/Height 0, Sequence contains no matching element, SelectVideoStream, playback info, GetPlaybackInfo, projection Streams = [], deep scan to heal, music videos will not play · paths: `MediaServerMusicVideoLibraryScanner.UpdateStatistics`, `JellyfinMusicVideoLibraryScanner.GetMediaServerStatistics`, `MediaServerMovieLibraryScanner.UpdateStatistics`, `IMetadataRepository.UpdateStatistics`, `FFmpegStreamSelector.SelectVideoStream` · issues: #924, #496'
mechanics: '`JellyfinMusicVideoLibraryScannerTests` (`ScanLibrary_Should_Store_PlaybackInfo_Statistics_For_A_New_MusicVideo`, `..._Deep_Scan_Should_Read_Statistics_For_An_Unchanged_MusicVideo_Without_Streams`, `..._Should_Read_Statistics_For_A_Returning_Unavailable_MusicVideo_Without_Streams`, `..._Should_Keep_Stored_Statistics_When_PlaybackInfo_Fails_On_Rescan`, `..._Should_Refresh_Statistics_And_Projection_Chapters_When_The_Etag_Changes`, against the real `MetadataRepository`); `FFmpegStreamSelectorTests.SelectVideoStream`'
---
The Jellyfin and Emby library-items projections build every movie, episode and music-video version with
`Streams = []` and no dimensions; only the per-item playback info carries them. The movie and TV scanners fetch
statistics when the item is new, changed, deep-scanned or has no stored streams. The music-video scanner wrote
the projection's version instead, so every Jellyfin music video was stored without streams and
`FFmpegStreamSelector.SelectVideoStream` threw on play. Measured on prod 2026-10-21 (#924): the Jellyfin "Music Videos" library (id 16), 1520 of
1520 music-video versions with zero `MediaStream` rows (movies 0, episodes 4).
- **Writing the projection is worse than writing nothing.** `IMetadataRepository.UpdateStatistics` reconciles
the stored streams against the version it is handed, so an empty stream list REMOVES every stream an existing
row has. A failed statistics read therefore leaves the row untouched, matching the movie scanner.
- **The refresh condition is the movie scanner's**: `deepScan || IsAdded || etag changed || no stored streams`.
For music videos `IsAdded` is implied by the empty-streams arm while the projection carries no streams; it is
kept so the two conditions read the same.
- **`abstract`, not virtual-`None`.** The movie base defaults `GetMediaServerStatistics` to `None`; Jellyfin and
Emby override it, and Plex takes statistics from its metadata call (`ServerReturnsStatisticsWithMetadata`).
Music videos have one implementation, and a `None` default is the defect itself, so a future Emby/Plex
music-video scanner must answer the question to compile.
- **Chapters come from the incoming projection**, not from the stored item as in
`JellyfinMovieLibraryScanner`: `JellyfinMusicVideoRepository.GetByItemId` does not eager-load chapters, so the
stored item has none to write.
- **Healing stored rows takes a deep scan.** A regular scan skips an item whose etag is unchanged before the
refresh condition is reached, so rows already stored without streams stay that way until their library is
deep-scanned or the server changes the item. On a live instance that scan is a channel operation
(media-management), not part of shipping the fix.
- **The player names the gap.** `SelectVideoStream` on a version with no video stream fails with the version id,
its file and the remediation (rescan; deep scan for a media-server library) instead of `First()`'s bare
`Sequence contains no matching element`.
- **Known residual, shared with movies:** when the server cannot answer, the item's etag is still recorded, so a
transient playback-info failure on a new or changed item leaves it without streams until the next deep scan or
server-side change. Not changed here: skipping the etag write would retry the item on every scan, and the same
trade-off already holds for movies and episodes.
@@ -0,0 +1,100 @@
---
key: spa.dismissible-write-failure-reporting
title: '2026-08-29 — A write failure reports to a surface that OUTLIVES the dismissible surface that started it (#830)'
status: active
since: '2026-08-29'
supersedes: none
superseded-by: none
rule: 'A component that starts an async WRITE from inside a dismissible surface (`Dialog`, `SlideOver`, `ConfirmDialog`) must report failure through `useDismissSafeError` (`web/src/hooks.ts`), which renders the message INLINE while the surface is mounted and hands it to a caller-supplied `onFailed` once the surface is gone. Guarding the `setError` with an is-mounted check and stopping there is NOT sufficient: it converts a silent data-loss risk into a silent NO-OP the user reads as success. All three primitives dismiss through Escape and a backdrop/scrim click (`useOverlayBehavior`) plus a header close button, and NONE of those consult a busy flag — disabling the footer Cancel button, which every one of these dialogs does, looks like it closes the hole and does not. Report the OUTCOME past dismissal in both directions — a success callback gated on an is-mounted check makes a completed write silent too (measured on `AddToCollectionDialog`, #877) — but gate the DISMISS request (`onClose`) separately, because closing a surface that is no longer yours closes whatever replaced it. The surviving surface belongs to the PARENT, so the mechanism is a PROP CONTRACT rather than a rendering decision; as of 2026-08-29 exactly ONE screen is wired (`CollectionsScreen`, into its screen-level `role="alert"` banner), and the `notice`+`Toast` pair on `MediaBrowseScreen`/`SearchScreen` is a CANDIDATE second surface, not a wired one; there is no global toast host in this SPA and this decision does not add one. The reporting prop is REQUIRED where the host has a surface (`AddItemsDialog.onAddFailed`), so a failure cannot be dropped by forgetting to wire it; it is optional only where some host genuinely has nowhere to report, and there an omitted callback drops the failure exactly as before — a KNOWN remaining gap, not a claim of coverage.'
signals: 'failed add silently swallowed · dialog closed mid-request · Escape backdrop close button ignore adding flag · error banner unmounts with the dialog · useDismissSafeError inline vs onFailed · success outlives dismissal but failure does not · onDone has no failure counterpart · paths: `web/src/hooks.ts`, `web/src/screens/CollectionsScreen.tsx`, `web/src/media/addTo/`, `web/src/components/overlay.tsx` · issues: #830, #877, #740, #685'
mechanics: 'Pinned five ways. (1) `web/src/hooks.test.tsx` → "useDismissSafeError (#830)" pins both branches directly: mounted reports INLINE and does not call `onFailed`, unmounted calls `onFailed`, and the report goes through the LATEST callback rather than the one captured on first render. (2) `CollectionsScreen.test.tsx` → "reports a failed add on the screen when the dialog was dismissed before the request settled (#830)" drives the whole path — it parks the POST in flight, dismisses via Escape, then settles the request and asserts the message is on the screen and NOT inside a dialog. Executed: deleting `reportRef.current(message)` alone reddens it on `Unable to find an element with the text: Request failed with status 500`. That one clause is shared, so the same mutation reddens THREE tests (this one plus the two divert tests in (1)) — expect three reds, not one, when re-running it. (3) `CollectionsScreen.test.tsx` → "a late SUCCESS does not close the dialog the user reopened after dismissing (#830)" pins the OTHER half of the split: it dismisses mid-request, reopens the picker, then settles the request 204, and asserts the reopened dialog is still there — with an anti-vacuity check that the late response was actually processed (`onAdded` is `load`, so a second GET of the items endpoint must have happened), because otherwise "the dialog is still open" holds trivially. Executed: deleting the `if (mountedRef.current)` around `onClose()` reddens it alone. The POSITIVE direction is pinned separately, in the Song add test, because the negative one does not cover it: review measured that deleting the `onClose()` call ENTIRELY — so an ordinary successful add never closes the picker — left the whole suite green, so that test now asserts the dialog closes. (4) `CollectionsScreen.guards.test.tsx` counts is-mounted reads and moved from 2 to 1 when the catch''s guard migrated into the hook; its `...actual` module mock cannot see the hook''s internal `useIsMountedRef()`, which is why (1) exists. Executed: removing the surviving `finally` guard takes that count to 0 and reddens. (5) `CollectionsScreen.test.tsx` → "renders a failed add INSIDE the dialog while it is still open (#830)" pins which ARM the call site reaches, which (1) cannot: (1) proves the hook HAS an inline branch, not that this screen calls into it. It fails the POST with the dialog still up and asserts the message sits inside `[role="dialog"]` and appears exactly once in the tree. Executed 2026-09-04: swapping `reportFailure(...)` for `onAddFailed(...)` in the catch — the divert-while-open shape this record names as its own defect — reddens that test and NOTHING else (1 failed / 490 passed across the 42 `src/screens` files).'
---
**This record applies to ONE site, and the reason the rest were dropped is the useful part.**
An earlier draft claimed SUCCESS already outlives dismissal "at every one of these sites", reasoning
from `AddItemsDialog` — which does report success past dismissal, and says so in a comment — and
generalising to the four `web/src/media/addTo/` dialogs without reading them. Adversarial review
probed `AddToCollectionDialog` and MEASURED `onAdded` called 0 times after dismissal; the other
three carry a visibly identical gate (read, not probed). So those four gate BOTH halves of the
outcome behind their own `activeRef`, and dismiss-then-succeed is as silent there as
dismiss-then-fail.
Extending the mechanism to them was attempted and WITHDRAWN, which is why they are #877 and not this
record. Removing that gate reports success correctly but also un-gates `onClose()`, and the two mean
different things: `onAdded` is "tell the parent what happened", `onClose` is "close me" — addressed
to a surface that no longer exists. Measured against the real `AddToMenu`: a late success from a
DISMISSED dialog closed a dialog the user had since reopened. Read, not measured: the success
handlers on `SearchScreen` / `MediaBrowseScreen` also call `clearSelection()` unconditionally, so
the same late success would wipe a multi-select the user had rebuilt. Un-gating both is wrong,
gating both is wrong, and gating only `onClose` still
needs the parents to stop nulling the dialog themselves — three coupled edits across five files,
plus an unresolved question about whether `clearSelection()` should fire for a write the user walked
away from. That is a design decision, not a bug fix, so it goes to #877 with the measurements
attached rather than riding along here.
`AddToMenu` also has `onDone` and no failure counterpart at all, which is why a failed "Add to
collection" from a media card reports nowhere. Same issue.
**The outcome and the dismissal are different callbacks, and the exemplar proves it.** Review
measured the cost of conflating them twice: gating both makes a completed write silent, un-gating
both makes a late success close the dialog the user reopened to retry. `AddItemsDialog` now does
each correctly — `onAdded()` unguarded, because it reloads the parent's list and that must survive;
`onClose()` guarded, because after dismissal it is `setPickerOpen(false)` aimed at whatever the user
opened next. That split is only this simple where the parent has no competing closer: here
`onAdded` is `load`, which never touches `pickerOpen`. In the `media/addTo/` layer
`AddToMenu.handleAdded` closes the dialog itself, so the same one-line gate is NOT sufficient there
— measured — which is the concrete reason that half is #877 and not this record.
**Why the inline branch is kept rather than always reporting to the parent.** While the dialog is
up, inline is the better surface: it keeps the user's selections and the context they are looking
at. Diverting to a parent banner in that case would be its own defect — the message would surface
somewhere else while the dialog the user is staring at stays blank. Behind the dialog, in fact:
the panel is a `createPortal` with `aria-modal="true"`, so the screen banner is hidden from AT and
covered for everyone else. Pinned rather than argued — mechanics (5).
**Why not simply gate dismissal on the busy flag.** That was considered and rejected: gating
Escape/backdrop/close on `adding` traps the user behind an in-flight request with no cancel path,
which is exactly why `Dialog` does not do it by default. The request is also genuinely still
running — cancelling the UI would not cancel the write.
**#740 guarded this and did not fix it, correctly.** That issue added the is-mounted guard so the
`setError` could not fire on an unmounted tree. That is right and stays. Its scope was the async
guards, and it deliberately did not change the UX — which left the guard making the drop
*deliberate* rather than accidental. A deliberate drop is still a user who believes twelve items
were added when none were, and that is the half this record closes.
**Two limits of the mechanism, recorded rather than left to be rediscovered.** (1) `useIsMountedRef`
clears its flag in a PASSIVE effect cleanup, so there is a narrow window in which the DOM node is
detached but the flag still reads true — the message then renders inline into a dead tree instead of
diverting. `useLayoutEffect` would close it deterministically, but that hook is shared by every
async caller in the SPA (#578) and changing its timing is not something to do inside a bug fix;
tracked with the rest in #877. (2) A limit the SECOND surface will have, stated now so it is
not discovered when it is wired: `CollectionsScreen` reports into `role="alert"`, but the candidate
surface on `MediaBrowseScreen`/`SearchScreen` is `Toast`, which is `role="status"` (polite) and a
single last-writer-wins slot — so a failure diverted there would be announced less assertively and
could be overwritten by a later success. Nothing diverts to those screens today; they receive no
reporting callback.
**The hook has exactly one consumer, and that is worth saying.** `useDismissSafeError` is a shared
module with a single call site today. It earns the shape three ways: it is unit-tested directly in
`hooks.test.tsx`, in a way that would be awkward to keep if the hook were inlined into
`CollectionsScreen`; §3c prescribes it for the next site; and #877 is a queued second consumer. The
caveat:
#877 may conclude that A2's right answer is a shared reporting SURFACE rather than a per-site prop,
in which case this hook may never gain that second consumer and stays a one-off with a convention
section attached. That is an accepted risk, not an oversight.
**Scope, stated rather than implied.** A sweep of all 68 `Dialog`/`ConfirmDialog`/`SlideOver` call
sites (2026-08-29; 67 caller-owned plus `ConfirmDialog`'s own internal `<Dialog`) found three
shapes: (A1) the surface hard-unmounts and takes its own error state
with it — the shape fixed here, at `AddItemsDialog` ONLY — the four `web/src/media/addTo/` dialogs
are the same shape and are NOT fixed (#877, see above);
(A2) the error state lives in a parent that never unmounts, but the JSX rendering it is nested
inside the same `open`/`target` condition dismissal just cleared, so it has no DOM node to render
into — the majority pattern, mostly delete-confirm flows; (B) a genuinely surviving screen-level
banner, which `DecoTemplatesScreen` and `BlocksScreen` already have. Only ONE A1 site is fixed here.
A2 is
LEFT OPEN deliberately: it is a larger, mostly-delete-confirm population whose right answer may be
a shared reporting surface rather than a per-site prop, and folding it in would have made this PR a
rewrite of twenty screens. It is tracked in #877, which carries the sweep's verified-vs-inferred
split forward, rather than being recorded as done here.
@@ -7,7 +7,7 @@ supersedes: none
superseded-by: none
rule: 'Any path that writes a WHOLE entity or a WHOLE child collection — a PUT-replace handler, a hand-built request object, a test comparer standing in for one — derives its field list from the authoritative type and asserts SET EQUALITY against it, rather than enumerating the fields by hand. A hand-written list is correct on the day it is written and structurally unable to report the day it stops being: the field that drifts is the one nobody wrote a line for, so no amount of care in the existing lines can reach it. The failure is silent by construction — a full replace with a field omitted returns HTTP 200 and destroys that field''s value (#754 drifted from a 28-property DTO by one and cleared it; the symptom arrived hours later as missing pixels). SECOND CLAUSE, separable from the first: where a replaced child row carries state keyed to its identity — progression, ordering, an enumerator position — the handler RECONCILES BY ID rather than delete-and-reinsert, because reinsertion silently resets state a client never asked to touch (#252: a schedule PUT reset fill-group progression; #500: a dedup fix became permanent data loss because the add filter and the remove filter used different keys, so the two halves must agree on the key). Delete-and-reinsert is acceptable ONLY where no such state exists, and that emptiness is a fact about today''s schema that a later feature can silently invalidate — so record it where the handler is, dated, rather than leaving it to be re-derived. The canonical worked example is `ToolCatalogTests.Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields`, which reads the accepted fields from the generated OpenAPI document and compares both directions. ON THE SPA SIDE the same rule is enforced by the TYPE SYSTEM rather than by a test: a full-replace body is built as `Complete<T>` (`web/src/api/completeRequest.ts`), a mapped type that makes every member of a generated request type required, so a builder that omits one fails `npm run typecheck`. Annotate BOTH the API-wrapper parameter (so later callers inherit it) AND each construction site including every `.map` callback return type, because the excess-property check that catches a PHANTOM field fires only on a fresh literal in a contextually typed position and a generic `.map` callback is not one. Do not infer from "most builders already typecheck" that the gap is closed: a member is omittable exactly when it is absent from the schema `required` array in the ASP.NET-produced OpenAPI document, and two live cases (#807) sat unchecked inside a large majority of checked ones.'
signals: 'full replace asserts its field list · hand-maintained mirror drifts by one field · reconcile by id not delete and reinsert · 200 and the field is gone · add filter and remove filter must share a key · fill-group progression reset by a PUT · derive the comparer from the DTO · a lossless round-trip test that is itself a hand-copied list · anti-vacuity PIN not a floor on a reflective walk · stale exemption must still name a real property · a complete comparer over a hand-written fixture · universal negative in a record is a trap · the SPA builds a full-replace body as `Complete<T>` · a member is omittable only when it is absent from the OpenAPI `required` array · excess-property checking does not fire inside a generic `.map` callback · a comment warning about a silent reset is not a check · `weight` · `qsvPreferNativeDecoder` · paths: `ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemResponseRoundTripTests.cs`, `ErsatzTV.Mcp.Tests/ToolCatalogTests.cs`, `ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs`, `web/src/api/completeRequest.ts`, `web/src/api/completeRequest.guard.test.ts` · issues: #807, #779, #773, #757, #754, #500, #252'
mechanics: 'Detector G of `docs/defect-shapes-773.md` §4. Enforced per-site by a reflective comparison over the DTO plus a written-down count pin, not by a repo-wide check — see the record body for why a global one is not proposed. The exemption set is empty today; the machinery guarding exemptions remains for the first one that earns its place.'
mechanics: 'Detector G of `docs/defect-shapes-773.md` §4. Enforced per-site by a reflective comparison over the DTO plus a written-down count pin, not by a repo-wide check — see the record body for why a global one is not proposed. The exemption set is empty today; the machinery guarding exemptions remains for the first one that earns its place. ON THE SPA SIDE the same rule is carried by the type system (`Complete<T>`, `spa.full-replace-complete` in `docs/spa-conventions.md` §4b), and since ersatztv#820 its APPLICATION is checked rather than assumed: `web/src/api/completeAnnotations.guard.test.ts` derives the droppable schemas from the generated `v1.d.ts` and the annotations from the SPA AST, asserts set equality between the droppable SCHEMAS and a reviewed disposition table, requires a PRODUCTION annotation naming each schema DISPOSITIONED as needing one — three of the thirteen that can drop a member, the other ten being creates, triggers, read models and two MUST-NOT-ANNOTATE rows, each ruled on individually. A test-file annotation does not discharge that: `*.test.*`, `*.spec.*` and the one setup file named in `vite.config.ts` are excluded, and the setup path is pinned so a rename reddens. NOT covered, stated rather than implied — a SECOND `setupFiles` entry, which vitest would load every run; deriving that list would need another virtual-module plugin and is not proportionate to a three-site coordinated edit. The guard also forbids an annotation on the schemas where annotating is a bug, cross-checks its disposition values against this file''s table from the Python side, and is per-SCHEMA rather than per-site or per-wrapper — a wrapper-level rule was built and REMOVED before merge after four review rounds each defeated it. It also rejects a `Complete<X>` over a hand-written mirror rather than a generated alias. Before it, `scripts/tests/test_optional_request_members.py`''s COVERED disposition ASSERTED the annotation existed and nothing verified it. Residual: the per-CONSTRUCTION-SITE population is not derived (sites in code, #777), so the phantom direction still rests on the §4b site rule and on review.'
---
**This is `testing.guard-derives-population-from-source` applied to a write path, and it is worth its
@@ -160,11 +160,16 @@ in two places: at the full-replace API-wrapper boundary, so later callers inheri
it exists, and at each construction site, which is what keeps TypeScript's excess-property check
(the *phantom* direction) alive.
That is the rule, not a claim about how much of the tree currently follows it. Nothing enforces the
second half — #820 — so any sentence here asserting present coverage would be falsifiable by one
`tsc` run and would go stale on the next screen anyone adds. Two wrappers annotated under #807
initially shipped with unannotated construction sites for exactly that reason. To find out what is
actually annotated, read the code; do not read a count here.
That is the rule, not a claim about how much of the tree currently follows it. The two halves are
enforced UNEQUALLY, and the split is the thing to know. Since #820 the SCHEMA half is checked:
`web/src/api/completeAnnotations.guard.test.ts` derives the droppable schemas from the generated
types and the annotations from the SPA AST, and fails when a schema that can drop a member is named
by no PRODUCTION `Complete<…>`. It is per-SCHEMA: it catches the annotations being deleted, not one
being moved off an API wrapper onto some other production file. The
CONSTRUCTION-SITE half is still enforced by review only — that population is sites in code (#777).
So a count of annotated sites here would still be falsifiable by one `tsc` run and would go stale on
the next screen anyone adds. To find out what is actually annotated, read the code; do not read a
count here.
**Why the second half of the rule is the one that gets skipped.** The wrapper annotation catches a
MISSING member anywhere. The PHANTOM direction — a field the schema does not accept — relies on
@@ -247,11 +252,26 @@ contingency is now MONITORED — the derived test above reddens the moment one o
optional member — instead of being assumed. Annotating the rest is cheap and should happen when
each is next touched.
**Residue, named rather than implied closed.** `Complete<T>` proves its own semantics but NOT that
it is applied: reverting one screen to its pre-#807 form leaves the guard green, because the
population of construction sites is derived from nothing. The repo already owns the tool for that
— `web/src/api/pageSizeScan.ts` (TypeScript compiler API) plus a registry cross-checked in both
directions — and building it is tracked in #820. Until then this convention is enforced by review,
which is the weaker thing this record exists to warn about. Separately: `Complete<T>` is shallow,
so a new nested item request type needs its own annotation; and requiring a field to be *named* is
not the same as requiring it to be *populated* correctly.
**Residue, named rather than implied closed.** #820 built the half that had an enumerable
population: `web/src/api/completeAnnotations.guard.test.ts` derives the droppable schemas from the
generated `v1.d.ts` and the `Complete<…>` annotations from the SPA AST, so deleting every production
annotation for a schema dispositioned as needing one now reddens. It found one live hole on landing: two
`playouts.ts` request types were hand-written mirrors SHADOWING generated schemas of the same name,
so their `Complete<>` was checking a local copy rather than the contract.
What is NOT closed: the per-CONSTRUCTION-SITE population, which is sites in code (#777), so the
PHANTOM direction still rests on review, as does the wrapper half of the rule. A test-file annotation
cannot discharge the obligation in any spelling vitest COLLECTS (`*.test.*`, `*.spec.*`) nor from the
one setup file `vite.config.ts` names, whose path is pinned — but a SECOND `setupFiles` entry would,
and that direction is open —
but a screen that builds a body inline without annotating its `.map` callback is still only caught by
a reader, and so is an annotation moved off a wrapper. A guard that DID enforce the wrapper half was
built for #820 and removed before merge: four review rounds each found a fresh way past it, the last
being an `export function` to `export const` refactor invisible to both the AST scan and its
cross-check at once. Five defects from one mechanism, so the mechanism went.
Separately: `Complete<T>` is shallow, so a new nested item request type needs its own annotation. A
DEEP variant was probed under #820 and works against the real generated types, but it was rejected:
it cannot be applied blanket-wise, because `UpdateChannelRequest.logo` reaches
`ArtworkContentTypeModel`, where annotating would force a caller to fabricate server-computed
values — and it closes only the missing direction, not the phantom one. Finally, requiring a field
to be *named* is not the same as requiring it to be *populated* correctly.
@@ -0,0 +1,69 @@
---
key: testing.guard-pins-the-artifact-not-a-shape
title: '2026-09-05 — a predicate over an artifact whose grammar it does not implement pins the artifact WHOLE; matching a shape inside it is an exception that must argue for itself (#901)'
status: active
since: '2026-09-05'
supersedes: none
superseded-by: none
rule: 'A predicate that decides something by matching a SHAPE inside an artifact whose GRAMMAR it does not implement pins the artifact WHOLE instead — the whole file, or a whole named span, compared for equality under a STATED normalisation. The boundary is the grammar and never the file type: shell source, a workflow `run:` body, TOML, YAML, a jq expression and JSON5 all qualify, and a shape matched over the OUTPUT of a real parser for that grammar does not. Extracting a `run:` value with a real YAML parser and then matching shell inside it IS in the class, because the shell is the unparsed half. THE PIN IS THE DEFAULT FOR A NEW PREDICATE; REJECTED is the alternative of writing the shape-matcher and pinning only once it has been defeated. The defeat a remedy policy waits for is a false GREEN, so it is found by a reviewer or by an incident and never by the guard: "not defeated yet" is a statement about who has looked, not about the input space. A SHAPE-MATCHER IS THE EXCEPTION, and its argument carries FOUR things, written where the predicate is and re-read whenever a clause of it is widened. (1) The GRAMMAR named, and the parser that implements it — if the shape is matched over a real parse there is no exception to write. (2) The INPUT SPACE as a CLOSED enumeration with the reason it is closed: a generator whose output form is fixed, or a canonicaliser the guard itself runs. A list of the spellings the author thought of is not that reason, and is the thing that was defeated in all three sequences below. (3) The FAILURE DIRECTION measured rather than asserted: each defeat the matcher claims to catch is a DECLARED, executed mutation under `testing.mutation-claims-are-executed`, because a shape-matcher''s miss is a false green and an unrun claim about a fail direction is not evidence. (4) WHAT IT BUYS over the pin, priced in a cost the pin actually charges — the artifact is edited routinely or written by tooling, so a pin would redden as background noise and train its readers to ignore it; or the artifact''s population is DISCOVERED rather than fixed, so there is nothing to pin. Fewer than four is a refusal, and neither elegance nor an unwillingness to update a constant is one of the four. TWO OTHER BARS ARE REJECTED for this exception: a NUMERIC one ("survives N adversarial spellings"), which measures the reviewer''s imagination rather than the input space and would have licensed the parser above through most of those nine defects; and a REVIEWER SIGN-OFF, which makes the exception depend on the very signal the threshold below says arrives late. THE THRESHOLD for withdrawing a shape-matcher already shipped is not a count of spellings: it is the moment the NEXT spelling is found by the REVIEWER rather than by the author, which is evidence that the author''s model of the input space is not the input space. TWO RIDERS, both measured. A pin assumes it is pinning the artifact that still DECIDES — ask what else could decide this and whether the pin would still match: another FILE that outranks it, another OCCURRENCE in the same file, another WORKFLOW, or a HOOK the pinned command invokes. And WIDENING A CLAUSE RETIRES THE PROOFS CALIBRATED AGAINST THE NARROW ONE: a canary asserting that a finer mutation still SURVIVES has as its precondition that the guard is deliberately coarse there, so widening the main clause turns the canary into a tautology, which reads exactly like a passing proof — re-derive every proof calibrated against the narrow clause in the same commit that widens it. THE COST IS STATED RATHER THAN DISCOVERED LATER: any edit to a pinned artifact reddens, and the pin is updated deliberately in the same commit, with a reason. That red IS the review trigger the pin exists to be. A pin also declares its NORMALISATION and what the normalisation cannot see, because "pinned whole" invites a reader to assume byte equality.'
signals: 'pin the artifact whole · shape-matcher is the exception that argues for itself · the boundary is the grammar not the file type · predicate does not implement the grammar it parses · defeated by the next spelling · the reviewer found the next spelling · false RED versus false GREEN · nine defects one mechanism · seven respellings of a partial config match · three successive lexical rules over a hook preamble · the four-part exception argument: grammar and parser, closed input space, measured fail direction, what it buys · authority moves where the pin is not looking · an outranking config file · a decoy occurrence · an npm lifecycle hook · widening a clause retires its own canary · a survived-clause canary becomes a tautology · a pin declares its normalisation · instances: `ci.image-build-delegates-the-spa-suite`, `process.hook-resolves-inputs-from-repo-root` · paths: `scripts/tests/test_image_build_delegates_the_spa_suite.py`, `scripts/tests/test_hook_fire_log.py`, `docs/guard-inventory.md`, `docs/defect-shapes-773.md` · issues: #901, #887, #891, #774, #790, #881, #773'
mechanics: 'Two worked pins. `scripts/tests/test_image_build_delegates_the_spa_suite.py` pins command TEXT (`PINNED_STAGE_COMMANDS`), `web/package.json`''s script map (`PINNED_PACKAGE_SCRIPTS`) and `web/vite.config.ts` entire (`PINNED_VITE_CONFIG`), the last two after a SELECTION and then a partial match were defeated. The three normalise DIFFERENTLY, so a residual read off one of them NEED NOT transfer to the others — and one of them does transfer, because two share a primitive: the stage commands are compared after line continuations are joined and whitespace within each command is collapsed (`_normalise`), so a reflow is free and a line boundary is not; the script map is dict equality over parsed JSON, so the file''s own formatting and key ORDER are free while every script body is exact; and of the three only `PINNED_VITE_CONFIG` runs through `_normalise_lines`, which additionally drops blank lines. `_normalise_lines` IS `_normalise` applied per line, so the whitespace collapse — including inside a QUOTED STRING, where it can change meaning — belongs to both TEXT pins and not to the vite one alone; of the two only the vite test states it, in place of claiming byte equality, which is what the rule above asks of a pin. `scripts/tests/test_hook_fire_log.py` pins the hook preamble''s two lines byte for byte (`CANONICAL_SINK_ASSIGNMENT`, `CANONICAL_SINK_SOURCE`) — but its normalisation is a SELECTION, and the rule above obliges this record to say what that selection cannot see, because the pin itself does not. The compared set is the lines that contain the literal `ETV_HOOK_FIRE_LIB` and are not comment-led, so the pin reaches exactly the two preamble lines and nothing else in the file: a later reassignment of that variable which never spells the literal — one composed at runtime and `eval`ed, say — is outside the selection, and therefore outside the pin, however the checker then behaves. Byte-identity WITHIN a selection is not byte-identity over the file — the pin''s reach ends where its selector does. That is stated here as a structural fact about the selector and NOT as a measured checker outcome, because an outcome claim about a mutation is a declared, harness-executed `CLAIMS` entry under `testing.mutation-claims-are-executed` or it is not written, wherever it is written — this record included. The residual is #891 code and predates this record; stating it is this record''s own rule applied to one of its own worked pins. Each of the three image-build pins faults with a request for the reason in the same commit, and two of them name the CONSTANT to update (`PINNED_STAGE_COMMANDS`, `PINNED_VITE_CONFIG`) while the script-map fault names the FILE and prints both maps. The hook-preamble fault asks for neither: it reports the divergent lines, since the two canonical strings ARE what a deliberate change edits.'
---
**The class, and what approximating it costs.** A predicate over an artifact with a grammar is a partial
reimplementation of that grammar, defeated not by a clever attacker but by an ordinary respelling — and each defeat
looks local, one more case to handle, so the sequence stops only when someone stops it. Measured 2026-08-30 across
two unrelated subjects: a guard parsing shell text to decide whether a command runs the SPA suite was wrong **nine**
ways from one mechanism (#887), its replacement — a partial match of `web/vite.config.ts` — **seven** further ways,
and over a hook preamble **three** successive lexical rules each fell to the next shape (#891). Every fix was locally
correct; none of the three sequences converged. All three ended in the same move, and what the move buys is a fail
DIRECTION: a pin's failure direction is a false RED, which a human reads in the diff; a shape-matcher's is a false
GREEN, which nobody reads. Which edits a pin accepts is its NORMALISATION's business, so every pin — whole-file or
selected — declares that normalisation and what it cannot see, which is what `mechanics:` does for both worked pins.
**Why the pin is the DEFAULT and not the remedy — the first thing #901 left open.** The rejected alternative is the
tempting one: write the shape-matcher, and pin only once it has been defeated. It is rejected because of what the
trigger event is made of. A shape-matcher's failure is a false GREEN, which the guard by construction does not
report; the defeat therefore arrives from a reviewer or from an incident, so its absence measures who has looked
rather than what the input can be. Both measurements above are that asymmetry: neither predicate withdrew because a
count got high, and both had the precedent available for every round. The second reason is that switching later
costs more than starting there: a withdrawal costs the rounds already spent AND the proofs calibrated against the
narrow clause (rider 2), so "pin it when it breaks" understates its own bill.
**What a shape-matcher's exception argument must contain — the second thing #901 left open.** The four requirements
are chosen so a reviewer can REFUSE one: each is a thing the author either wrote or did not. Two alternatives are
rejected. A NUMERIC bar ("survives N adversarial spellings") measures the reviewer's imagination, not the input
space, and rises while the defect stands — it would have licensed #887's parser through most of those nine defects.
A REVIEWER SIGN-OFF bar is worse: it makes the exception depend on the very signal the threshold says arrives late.
What survives is requirement 2, the only candidate that is a claim about the ARTIFACT rather than about the effort
spent on it, and requirement 3, which forces the fail-direction claim to be executed, not reasoned.
**Rider 1 — a pin assumes it pins the artifact that still DECIDES.** This is where the pinned form fails, silently,
so it is the question to ask of every new pin. In #887 every hole found after the first withdrawal was authority
moving where the pin was not looking: another FILE that OUTRANKS the pinned one (the vite config ordering, a
VERSION-bound reading, is dated in `docs/guard-inventory.md`'s `test_image_build_delegates_the_spa_suite.py` row
rather than copied here), another OCCURRENCE in the same file, another WORKFLOW (a `needs:` edge naming a different
job called `test`), a HOOK the pinned command invokes (a vite plugin's `buildStart`, an npm `prebuild`/`preinstall`
script). A pin turns the SPELLING problem into its normalisation's and reduces the MEANING problem to a list that can
be worked through; a pin sold as immune to both stops being re-examined.
**Rider 2 — widening a clause can retire its own evidence.** A canary asserting that a finer mutation still SURVIVES
is calibrated against a deliberately coarse clause. Widen that clause and the canary does not go red, it goes
vacuous — indistinguishable from a passing proof at the point of reading — so the widening commit re-derives every
proof calibrated against the narrow form. Same shape as the `DETECTOR` grade in
`testing.mutation-claims-are-executed`: a surviving mutation is worth something only while the precondition that
made it survive still holds.
**Where this sits against its neighbours, so it is not read as a duplicate.** The two incidents keep their own
records — `ci.image-build-delegates-the-spa-suite` and `process.hook-resolves-inputs-from-repo-root` — and this is
the class they instantiate, while `testing.guard-derives-population-from-source` answers the case where the
authoritative source is MISSING (create one, never approximate it with a predicate over text); this one, the case
where the artifact IS present and authoritative and its GRAMMAR is what gets approximated.
`docs/defect-shapes-773.md` does NOT carry this rule: its §3.6 partition lists the nearest class, string-predicate
churn, as `no detector proposed`, and §3.7 argues that class away as a cross-cutting property. Only §4's
meta-finding supports the FORM — a class-level rule beats one record per instance — so the rule is new here,
resolvable BY TOPIC where `docs/guard-inventory.md` carries its precedent per incident, findable only inside one.
**What this does not cover.** Predicates over structured input a real parser produces — an AST, the git index,
parsed workflow YAML consumed as data — are not approximations and keep their shape matching. And a pin is not
mandatory for an artifact edited continuously by many hands: that is requirement 4's own escape hatch, and a pin
reddening as background noise is a guard nobody reads.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,77 @@
---
key: testing.scripted-engine-in-process-net
title: '2026-09-05 — Scripted playout is covered in-process, engine plus controller adapter; the Cli.Wrap process and the HTTP transport are permanently out of the automated suite (#563)'
status: active
since: '2026-09-05'
supersedes: testing.scripted-playout-golden-deferred@2026-07-22
superseded-by: none
rule: 'Scripted playout is characterized at two in-process levels and no further. (1) `SchedulingEngineTests` drives the engine build API directly with substituted repositories — the same setup order `ScriptedPlayoutBuilder` uses (`WithPlayoutId`/`WithMode`/`WithSeed`/`BuildBetween`/`WithReferenceData`/`RestoreOrReset`, and `WithReferenceData` MUST precede the last two or `PlayoutReferenceData.PlayoutHistory` NREs). (2) `ScriptedScheduleControllerTests` replays a committed script fixture (`ErsatzTV.Tests/Controllers/Fixtures/scripted-build.json`) through the REAL `ScriptedScheduleController` + `ScriptedPlayoutBuilderService.MockSession` + `SchedulingEngine` and pins the resulting `PlayoutItem`s as a snapshot in the golden line format — so exactly ONE action-to-engine mapping exists in the repo, the production one. Fixture bodies in that replay are deserialized with `ApiJsonSettings`, the configuration `Startup` applies to `AddNewtonsoftJson`, invoked from the same function rather than mirrored — and two tests WITNESS that choice instead of asserting it, each binding a body that separates the production binder from one plausible replacement: `Production_Body_Binder_Ignores_Required_Members` omits a C# `required` member, which Newtonsoft defaults and System.Text.Json rejects; `Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null` sends an explicit `"order": null`, which `NullValueHandling.Ignore` drops and a bare `JsonSerializerSettings` writes through into a 400. What is shared is the CONFIGURATION, never MVC''s settings OBJECT: `ApiJsonSettings.Create()` applies it to a bare settings object, so MVC''s `MaxDepth` of 32 and its `ProblemDetails`/`ValidationProblemDetails` converters are absent (measured 2026-09-05, pinned by `ApiJsonSettingsTests`); neither is reachable from a scripted request body, and no test may generalize from `Create()` to production past that. What no test observes is the `Startup` registration itself, so the extraction REMOVES the duplicate rather than detecting its drift. The `Cli.Wrap` launch of the user-authored program (exit code, timeout, stdout capture), the Kestrel/middleware/`ApiAuthorizationFilter` transport it calls back over, and MVC model binding as a WRAPPER (the input formatter, model validation and the `[ApiController]` automatic 400 either produces before an action runs — the serializer inside that wrapper is covered, the wrapper around it is not) are NOT tested and are not scheduled to be; that residue is named below rather than argued away. Both fixtures must aim every trimming instruction at an instant BETWEEN two content boundaries: a pad or duration that happens to land on one never enters the engine''s trim branch, so that action''s `trim` argument reaches nothing the snapshot can show. The controller is a thin adapter, NOT a 1:1 pass-through: an unparseable playback order is a 400, an unparseable filler kind SILENTLY degrades to `FillerKind.None`, an unknown build id is a 404, and the engine no-progress `InvalidOperationException` is translated to a 400 — each pinned by a test, because a prose claim of equivalence decays.'
signals: 'why is there no scripted golden; scripted integration harness; scripted transport vs engine; SchedulingEngine is in-process testable; Cli.Wrap external process is transport only; shell-out scoped out; MockSession seam; in-process stand-in for a scripted script; engine build API characterization; committed script fixture replay; ScriptedScheduleController is an adapter not a pass-through; scripted filler kind silently falls back to None; scripted playout no-progress halt maps to 400; scripted build 401 blind spot; EnumeratorForContent · paths: `ErsatzTV.Core.Tests/Scheduling/Engine/SchedulingEngineTests.cs`, `ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests.cs`, `ErsatzTV.Tests/Controllers/Fixtures/scripted-build.json`, `ErsatzTV.Tests/Serialization/ApiJsonSettingsTests.cs`, `ErsatzTV/Serialization/ApiJsonSettings.cs`, `ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs`, `ErsatzTV/Controllers/Api/ScriptedScheduleController.cs`, `ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs`, `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs` · issues: #563, #381, #395, #163'
mechanics: docs/testing.md → "Scripted playout coverage" and "Golden-file nets"
---
**What carries over from the predecessor.** Sequential (YAML) is golden-able and TZ-independent, and
`PlayoutBuildGoldenTests` covers Classic / Block / Sequential. Scripted's *end-to-end pipeline* stays out
of that net: `ScriptedPlayoutBuilder` builds nothing itself, it `Cli.Wrap`-executes a user-authored
program, hands it `http://localhost:{Settings.UiPort}` plus a build id, and reads the result straight off
the in-process engine afterwards. #395's `ContentEnumeratorBuilderTests` remains the direct net over the
enumerator-construction switch Scripted and Sequential share.
**What changed.** The predecessor deferred the whole harness to #563 and described the controller as a
"1:1 pass-through". Both are now settled: the coverage exists in-process, and the pass-through wording was
wrong — the four adapter mappings above are real behaviour that only a test can hold.
**Why not the full Kestrel + `Cli.Wrap` harness.** Four measured obstacles, not a preference:
1. *The production transport is not "a controller behind Kestrel".* `Startup` 404s any request whose Host
is not `localhost` and whose `Connection.LocalPort != Settings.UiPort` (a mutable process-wide static),
and `ApiAuthorizationFilter` fail-closes every mutating verb. A hand-rolled minimal host would test a
transport the product does not have; the real `Startup` drags in the whole DI graph (TvContext, ffmpeg
workers, search index, background services), which is what #381 deferred in the first place.
2. *The external program is user-authored by definition.* `Playout.ScheduleFile` is an arbitrary command
line, so any script we commit is a stand-in we wrote either way. The fork's own driver,
`scripts/scripted-schedules/entrypoint.py`, imports `etv_client`, a generated package that exists only
in the deploy environment, so it cannot run in the .NET test job without adding a client-generation step.
3. *It would put an interpreter and process spawning inside the pure in-memory .NET suite*, with the 30s
`Cli.Wrap` timeout nested inside CI's `--blame-hang-timeout 2m` — a new flake class on a shared runner.
4. *The stand-in loses nothing the harness would have added at the engine level.* Replaying the fixture
through the real controller and the real service keeps a single action-to-engine mapping under test;
what is absent is the two transport hops plus MVC binding as a wrapper, and the wrapper is the shallow
half — the serializer inside it is configured by `ApiJsonSettings`, the production configuration (not
MVC's settings *object*, whose remaining defaults the rule enumerates).
**The residue, named.** Uncovered: process launch, exit code and timeout handling; Kestrel plus the
forwarded-headers / compression / SPA-hosting middleware; MVC model binding as a *wrapper* — the input
formatter (content-type selection, malformed-body handling), model validation (a non-nullable reference
type picks up an implicit required check there) and the `[ApiController]` automatic 400 either produces
before an action runs, since every test hands an action an already-bound object; and
`ApiAuthorizationFilter`. The *serializer* inside that wrapper is deliberately not on this list — it is
`ApiJsonSettings` itself, and two tests hold the two ways it can be replaced. `ApiJsonSettings` exists so
that binder is *defined* once; it is not a drift **detector**, and the branch may not claim one, because
nothing observes `Startup.ConfigureServices` and a byte-equal hand-copy of `Apply` is indistinguishable
by construction. What the suites do separate is a mirror that has *lost* something. The mechanics doc
tabulates which suite separates which half of the configuration, and what neither reaches.
**Where the calibrating measurements are, and why not here.** `testing.mutation-claims-are-executed`
admits a sentence asserting that a specific mutation reddens — or does not redden — a named test only as
a `CLAIMS` entry in `scripts/tests/mutation_manifest.py`, executed every run; otherwise it is not
written. That harness resolves a proof to `scripts/tests/<node id>` and runs pytest, so an NUnit proof
cannot be declared in it and this coverage is entirely NUnit. The binder and `trim` mutants run while
building these tests therefore stay in ersatztv#563 — its pull request and the commit messages that
ran them — dated. Durable prose carries
the mechanism each test is built on instead — which serializer difference, which engine branch — because
that is re-checkable by reading the code rather than by trusting a remembered outcome.
`ApiAuthorizationFilter` on that list is not hypothetical — `ScriptedScheduleController` carries no
`[SkipApiAuthorization]` while
`ScriptedPlayoutBuilder` passes the script only host, build id and mode, with no `X-Api-Key`, and
`entrypoint.py` builds its client with no credential. Whether a scripted build therefore 401s on its first
POST in this fork is unverified against a live instance and is tracked in ersatztv#913; it is recorded here
as the concrete shape of what an engine-level net cannot see, so the scope-out is not read as a claim of
equivalence.
**Determinism constraints on the fixtures.** Both use Chronological order and only instant-preserving
instructions (`AddCount`, `AddAll`, `AddDuration`, `PadUntilExact`). `WaitUntil(TimeOnly)` and
`PadUntil(string)` read the LOCAL day and time-of-day, and `PadToNext` becomes offset-sensitive once a
call has localized `CurrentTime`, so introducing any of them — or a `shuffle` order — reopens the
timezone and seed question the fixtures were built to close.

Some files were not shown because too many files have changed in this diff Show More