Compare commits

..
Author SHA1 Message Date
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
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
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
timothyandtimothy 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
timothytimothyClaude 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
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
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
timothytimothyClaude 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
timothyandtimothy 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
timothyandtimothy 736649b3b7 fix(812): classify the narrative sites by who-benefits; keep the detector's reach (#882)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 22s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m33s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m52s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-29 20:28:14 +00:00
timothy 1afad0851d fix(823,824): a scheduling NULL collection reads as UNRESTRICTED and is guarded at both read sites; the Elastic indexer gets its own mutation proof (#879)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 17s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 32s
Build ErsatzTV Image / Build & test (.NET) (push) Canceled after 0s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Canceled after 0s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Canceled after 0s
Build ErsatzTV Image / Build & push image (amd64) (push) Canceled after 0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Canceled after 0s
2026-08-29 19:55:53 +00:00
timothyandClaude Opus 5 9685132ee0 fix(823): three cleanups from the coherence pass — a wrong witness, a wrong because, a duplicated paths:
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 14s
review-verdict/h10 Review-verdict: MERGEABLE @ 9685132 (base: main)
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 12s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 43s
Review verdict / Set review-verdict status (pull_request_target) Successful in 9s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 11m28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 14m32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m10s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 9m23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 10s
Round-five review confirmed the decision record is coherent with no third
survivor of the empty reading, and returned three LOW findings. All are in
prose I wrote in the last two commits.

- The comment defending `(IsAbstract && !IsSealed)` cited
  AlternateScheduleSelectorTests as an in-repo static-fixture witness. That
  class IS static, but it merely NESTS its [TestFixture]es and declares no test
  of its own, so it would fail the sibling "declares no runnable test" assertion
  rather than demonstrating the point. The rule is right and the witness was
  wrong, which is the worse of the two failures because a wrong example is what
  a reader checks the rule against. No witness is cited now, and why is stated.

- A mis-bound `because` in `rule:`: "assigning a null and calling SaveChanges
  SUCCEEDS ... because only the HTTP request records normalize with `?? []`".
  The `?? []` clause explains how a null could REACH the entity; what makes the
  save succeed is the column being nullable. A right observation with a wrong
  cause attached. Split into the two claims.

- `signals:` carried the literal token `paths:` twice, an artifact of appending
  the #823 path list to the existing one. It degrades the field the discovery
  surface parses.

Also recorded from that review, and NOT changed: `MonthsOfYear ?? AllDaysOfMonth()`
survives the selector fixture and no date can kill it -- 1..31 contains every
valid month, so it is an EQUIVALENT mutant there rather than a coverage gap.
Its non-equivalent twin at the DTO boundary is pinned per-dimension by
RecurrenceLimitsMapperNullTests. Left alone deliberately: chasing an equivalent
mutant with a contrived date would buy nothing and cost the fixture's
readability.

Local gate: ErsatzTV.Tests 2091 passed / 6 skipped, Core.Tests 697/1 -- 0
failures. Format clean, no BOM. decisions_validate OK.

Refs #823
Refs #824

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW
2026-08-29 21:14:27 +02:00
timothyandClaude Opus 5 3951fcf516 fix(823): stop patching the record by grep — a second ?? [] survived, and my "swept it" claim was false
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 18s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 9s
PR Gates / decisions lifecycle (pull_request) Successful in 14s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
review-verdict/h10 Awaiting review verdict for 3951fcf
Review verdict / Set review-verdict status (pull_request_target) Successful in 16s
PR Gates / Docs update reminder (pull_request) Successful in 11s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 7m52s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Canceled after 0s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Canceled after 0s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Canceled after 0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
Round-four review. One HIGH, again in the decision record, and the previous
commit message asserted this exact class was cleared. It was not.

THE HIGH, and the reason it recurred.

A second sentence still described the rejected reading: "The guard form is
`?? []` into a local rather than this record's Optional(x).Flatten(), a STATED
deviation". The shipped guard is `?? AllDaysOfWeek()`. That sentence is the one
that dictates guard FORM to the next implementer, so it would have taught the
`[]` reading the same record spends a paragraph calling data corruption -- and
it had already propagated into docs/decisions/README.md, the mandated entry
point, which carries `rule:` verbatim.

The mechanism, not the sentence, is the defect. I swept with a regex keyed on
"null" plus a reading word; this sentence talks about guard FORM and contains
neither, so it could not match. That is grepping the retracted WORDING instead
of sweeping the CONCEPT, which is exactly what this corpus warns about -- and
three rounds in a row have now found a defect introduced by the previous
round's targeted string edit. So the fix is not another targeted edit: the
whole `rule:` field was split into its 39 sentences and read back one by one
against the code. Everything below came out of that pass rather than a grep.

Its secondary damage is worth recording because it is the shape of a rationale
that outlives its claim: the deviation was justified by ".ToList() allocates
for nothing", which is now BOTH irrelevant to the choice AND false about the
shipped code, since AllDaysOfMonth()/AllMonthsOfYear() are themselves
Enumerable.Range(...).ToList() on exactly the null path it describes.

- The opening sentence of `rule:` prescribed Optional(x).Flatten() as THE
  read-site form. It is the sentence most likely to be read in isolation, and
  it is wrong for six of the eight columns. It now separates the universal half
  (a LOCAL, never assigned back) from the half that is not (the substituted
  value), and names where each applies.
- `signals:` had never been touched, so roughly 60% of `rule:` was unreachable
  by the discovery surface built for it -- no AlternateScheduleSelector, no
  mapper, no "unrestricted", and its paths: list named none of the files this
  work touched. It also advertised "Optional Flatten hoisted local" as the
  form, which is precisely what the six do NOT use.
- The body prose was still entirely about SongMetadata while `rule:` had grown
  a whole second subject. Added the two results that contradicted the prior
  reasoning, in prose, where a reader meets them.

A REAL BUG in my own guard, not just prose:

  fixture.IsAbstract.ShouldBeFalse(...)

A C# `static class` compiles to `abstract sealed`, and NUnit runs tests
declared in one -- this repo already has such a fixture
(AlternateScheduleSelectorTests is `public static class`). So the check I added
one commit ago to reject an un-runnable fixture would have falsely reddened a
perfectly good static one. Now rejects an abstract BASE (abstract and NOT
sealed), which is the case NUnit actually cannot instantiate.

A SURVIVING MUTANT the added controls did not kill:

AnyDate was 2024-03-06. With a day <= 12 a CROSS-WIRED substitution survives
the whole fixture -- `DaysOfMonth ?? AllMonthsOfYear()` hands back 1..12, which
still contains day 6, so every assertion passes while the guard substitutes the
wrong set. Moved to 2024-03-20, still a Wednesday in March, outside 1..12.
Measured both ways rather than reasoned: the cross-wire mutant passes the old
fixture and FAILS 2 of 11 on the new one.

Also re-witnessed, because I had modified that file and never re-proved it:
restoring `??=` in LuceneSearchIndex reddens the LUCENE fixture (1 red, 1
green) -- the exact mirror of the Elastic mutation. Extracting
ThrowOnWarningLogger did not cost #701 its proof, and the two fixtures are
independently load-bearing in both directions.

The record is now 73 prose lines, over the 60-line WARNING ceiling. Stated
rather than trimmed: it is 42nd of 42 records over that line, and the added
content is distinct findings (a second subject, a migration analysis and three
residuals), not redundancy against a sibling.

Local gate: ErsatzTV.Tests 2091 passed / 6 skipped (the three fixtures' MySQL
halves), Core.Tests 697/1 -- 0 failures. Format clean, no BOM. decisions
validate OK.

Refs #823
Refs #824

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW
2026-08-29 21:00:22 +02:00
timothyandClaude Opus 5 ea888011aa fix(823): the decision record argued BOTH readings — and the per-dimension mutant that survived the fixture
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 16s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 16s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Awaiting review verdict for ea88801
Review verdict / Set review-verdict status (pull_request_target) Successful in 18s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 7m4s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m55s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Canceled after 4m22s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Canceled after 0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
Round-three review findings. One HIGH, and it was in the durable artifact
rather than the code.

THE HIGH: the record stated the shipped reading and its inverse.

The semantic reversal (empty -> unrestricted) rewrote the residual and the
write-half of `media.nullable-primitive-collection-mutation` but left the
ORIGINAL reasoning standing two sentences earlier: "A null reads as EMPTY, so
the item matches nothing"; "the REJECTED alternative was the All*() set";
"SKIPPING the row is the conservative repair". The shipped code is
`?? AllDaysOfWeek()` -- precisely the alternative that passage calls rejected.
The previous commit then inserted residual (1), which reasons entirely FROM
the All*() reading, two sentences after the sentence denying it.

That is worse than a stale comment. A session resolving this key -- or reading
the MemPalace mirror, which carries `rule:` verbatim -- would have been told to
write the guard the other way, i.e. talked into the `[]` reading that the same
record elsewhere argues is data corruption one save later. Replaced the whole
passage, then swept the record for every other mention of the empty reading
rather than trusting the one replacement: the only survivor is the new sentence
that records EMPTY as the rejected alternative, which is the direction that
stops it being re-adopted.

THE MEDIUM: one arrangement did not close the hole it claimed to.

The discriminating control added last commit nulls DaysOfWeek against a
restrictive MonthsOfYear. It excludes "any NULL matches unconditionally" only
for that dimension. The review supplied the surviving mutant --
`if (item.MonthsOfYear is null) { return item; }` ahead of the checks -- and
traced it green through all nine tests. Verified by EXECUTION, not by reading:
applied to the previous fixture it passes; applied now it FAILS 1 of 11. Each
of the three dimensions is now nulled against a restriction on a different
dimension.

The rest, all from the same round:

- The coverage guard's test detection listed attribute TYPES, and each list
  falsely reddened whatever it omitted: TestAttribute alone missed [TestCase],
  and the three-type replacement missed [Theory]. Now decided by NUnit's own
  ITestBuilder/ISimpleTestBuilder interfaces, which cannot fall behind the
  vocabulary. It also dropped BindingFlags.Static (GetMethods() defaults to
  including it), which would have falsely reddened a static test method.
- The same guard accepted an ABSTRACT fixture -- NUnit never instantiates one.
  The indexer population already filtered IsAbstract; the fixture side now
  mirrors it.
- The record's `mechanics:` still described the old `[Test]`-only clause, in
  the same file the change edited.
- An <inheritdoc> made the ProgramScheduleAlternate empty-case test inherit a
  docstring written from the PlayoutTemplate test's viewpoint.

Two more mutations executed:
- `if (item.MonthsOfYear is null) return item;` -> 1 red, 10 green. This is the
  mutant that survived the previous head; it no longer does.
- an abstract type named in the covered set -> coverage guard red.

Local gate: ErsatzTV.Tests 2091 passed / 6 skipped (the three fixtures' MySQL
halves, skipping visibly without ETV_TEST_MYSQL_CONNECTION), Core.Tests 697/1
-- 0 failures. Format clean, no BOM on the touched set. decisions_validate OK.

Refs #823
Refs #824

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW
2026-08-29 20:43:07 +02:00
timothyandClaude Opus 5 6200713965 fix(823,824): close the review round's findings — a discriminating control, the second mapper's empty case, and honest test detection
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 16s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 17s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
review-verdict/h10 Awaiting review verdict for 6200713
Review verdict / Set review-verdict status (pull_request_target) Successful in 11s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 7m53s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Canceled after 7m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Canceled after 0s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Canceled after 0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
Follow-up commit (the branch is pushed, so not an amend). Two more cold
reviews landed on the previous head; both reported 0 Blocker and 0 High, and
these are their Mediums and Lows. Each fix carries its own witnessed mutation.

1. The selector fixture could not tell the fix from a much broader one.
   Every null test set a NULL and expected the item SELECTED, so all of them
   pass equally under "NULL means unrestricted" and under "any NULL makes this
   item match unconditionally" -- a refactor short-circuiting the whole date
   check on any null kept them green. Added the discriminating control: a NULL
   DaysOfWeek paired with MonthsOfYear = [1] against a MARCH date must be None.
   Only the narrow reading passes.

2. A_Null_Item_Does_Not_Disturb_Selection_Of_A_Later_Item never measured its
   own docstring. The nulled item was unrestricted and at Index 0, so it always
   won and the second item was never evaluated -- the stated invariant ("a null
   on the first item must not decide the second") went unmeasured while the
   test passed. Split into two: one where the nulled item genuinely does not
   match, which measures that the loop CONTINUES; and one that pins the
   index-order win separately.

3. The empty-preservation control existed for one of two identical mappers.
   The anti-mutant test for "empty or null becomes All*" covered only
   Playouts.Mapper; Scheduling.Mapper is a byte-identical triple in another
   file and had none, so a defensive edit to it alone would have rewritten a
   deliberately-empty user selection to 1..31 with the suite green. That is the
   one-helper-two-callers shape this repo has been bitten by. Added the
   matching test.

4. The coverage guard's [Test] clause did not check what its message claimed.
   GetMethods() without BindingFlags returns INHERITED methods, so a fixture
   that merely subclasses another satisfied it while driving the wrong indexer
   -- and Values.Distinct() cannot catch that, since the two Types differ. It
   also matched TestAttribute alone, so a future fixture written as [TestCase]
   would have falsely reddened, and it accepted an [Explicit]/[Ignore]d fixture
   that never runs, which is the "wired is not running" failure the guard
   exists to prevent. Now DeclaredOnly, the full test-method vocabulary, and
   Explicit/Ignore rejected at both method and fixture level.

5. Three residuals recorded on media.nullable-primitive-collection-mutation
   that the previous head asserted nothing about:
   - the LOUDNESS change, worst for an all-three-NULL ProgramScheduleAlternate,
     which now matches unconditionally and shadows the default schedule where
     it previously threw. Unreachable today, and a choice over an unreachable
     state rather than a measured requirement -- said plainly.
   - the normalization is ONE-WAY and WHOLE-LIST: both PUT paths are full
     replaces, so editing any row persists All*() over EVERY NULL row in that
     playout, and afterwards "the operator selected all 31" and "this is a
     legacy row" are indistinguishable. An ordinary user action closes that
     door.
   - the WRITE side disagrees with the READ side about what ABSENCE means: an
     omitted daysOfWeek normalizes to [] ("never applies") while a NULL column
     reads as unrestricted, so an API client gets HTTP 200 and a row that
     silently never fires. Filed as #880 rather than folded in here, because a
     client omitting a field on a write is a different question from what a
     legacy NULL meant.

Two more mutations executed, both witnessed:
- DaysOfWeek guard disarmed in Scheduling.Mapper -> 1 red, 3 green.
- A fixture with no DECLARED test named in the covered set -> coverage red.

Local gate (MySQL lane armed): ErsatzTV.Tests 2097 passed / 0 skipped,
Core.Tests 695/1 -- 0 failures. Format clean, no BOM on the touched set with
the population count asserted. decisions_validate OK.

Refs #823
Refs #824
Refs #880

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW
2026-08-29 20:27:33 +02:00
timothyandClaude Opus 5 95b2700f09 fix(823,824): a scheduling NULL collection reads as UNRESTRICTED and is guarded at both read sites; the Elastic indexer gets its own mutation proof
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 18s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Docs update reminder (pull_request) Successful in 21s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 11s
review-verdict/h10 Review-verdict: MERGEABLE @ 95b2700 (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 23s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 8m6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m55s
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) Canceled after 2m56s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
Both issues are #701 deferrals, and they land together because both rewrite
the same decision record.

#823 -- can a null reach one of the six collection-valued scalar columns?

MEASURED against a real TvContext on BOTH providers (SQLite, and MySQL 8.4
on an ephemeral server), because the reasoning available beforehand pointed
the wrong way. The two converters differ on their read side --
IntCollectionValueConverter maps null-or-blank to Array.Empty<int>(), while
EnumCollectionJsonValueConverter would dereference the result of
JsonConvert.DeserializeObject -- so the expectation was that a NULL row
behaves differently per column. NEITHER RUNS: EF does not invoke a value
converter for a NULL column at all. All six materialize as CLR null, the
int converter's null-to-empty branch is dead on this path, and unguarded
each .Contains in AlternateScheduleSelector throws NullReferenceException.

A NULL reads as UNRESTRICTED -- the All*() sets -- not as empty. This is
the whole semantic question and the first draft got it backwards. It is
decided by the one NULL reachable WITHOUT any code writing one: Sqlite's
20240113140741_Add_PlayoutTemplate_DaysOfMonth adds the column with
nullable:true and NO defaultValue, so a PlayoutTemplate row inserted before
it holds NULL and by construction had no day-of-month restriction. Reading
that as empty INVERTS the row's meaning and silently stops the template
applying at all. All*() preserves it, and is how "no restriction recorded"
is already represented (GetPlayoutAlternateSchedulesHandler,
PreviewBlockPlayoutHandler). What does NOT decide it, and was wrongly cited
in the first draft: the API request records normalize an omitted field with
`?? []`, but that is a client omitting a field on a WRITE and says nothing
about what a legacy database NULL meant.

Two read sites, not one. Guarding only the selector would have left the
entity->DTO mappers unguarded, and those feed the SPA: PlayoutScheduleEditors
spreads the collection (`[...template.daysOfMonth]` -> TypeError on a JSON
null) and playoutTemplateCalendar's appliesToDate -- an exact port of
GetScheduleForDate -- calls .includes on it. Both mappers now substitute the
SAME defaults, so the preview agrees with what is actually scheduled. Neither
guard is assigned back onto the entity, which is the
media.nullable-primitive-collection-mutation mechanism.

Reachability, stated precisely rather than overclaimed. All six are
nullable:true on both providers, but a nullable column does not produce a
NULL row: five of the six were present at CreateTable, so a NULL there still
needs code to write one, and on MySQL there is NO code-path-free NULL for any
of the six. The write path ACCEPTS a null (SaveChanges succeeds, stores SQL
NULL) but no caller supplies one today -- every production construction of the
two commands goes through the request records. That is a property of the code,
not a live caller; claiming otherwise would be the banned "it's AsNoTracking
today" argument pointed the other way.

#824 -- ElasticSearchIndex.UpdateSong had no regression test

Issue option 1 (a non-network transport) shipped, and needed no new package:
Elastic.Transport.InMemoryRequestInvoker is public in the pinned version and
ElasticsearchClientSettings(NodePool, IRequestInvoker) accepts it, injected
into the private _client the way #701 injects the Lucene IndexWriter.
UpdateItems never runs `_client ??= CreateClient()`, so the injected instance
is the one used.

Two traps there are load-bearing, both measured: the canned response must
carry an `X-Elastic-Product: Elasticsearch` header or the client's product
check throws UnsupportedProductException INTO UpdateSong's catch, and an empty
body fails to deserialize the same way. Either turns the fixture into a green
measurement of the error path -- which is how it first failed here, caught by
the ThrowOnWarningLogger. The document id is asserted as the LAST PATH SEGMENT,
not by substring: the index name carries digits, so ShouldContain would stop
discriminating for a song whose id collided with one.

Six mutations executed, each disarming ITS OWN clause alone:

- `??=` restored in ElasticSearchIndex only -> the Elastic fixture reddens on
  "metadata.Artists should be null but was []" while the LUCENE fixture stays
  GREEN. The #824 hole demonstrated, not described.
- DaysOfWeek guard disarmed in the selector -> 4 red, 3 green (DaysOfMonth and
  MonthsOfYear unaffected). Each clause is independently load-bearing.
- DaysOfMonth guard disarmed in Playouts.Mapper -> 1 red, 2 green.
- Elastic dropped from the covered set / mapped to the SAME fixture as Lucene /
  mapped to a class with no [Test] -> SearchIndexMutationCoverageTests reddens
  on each.

That coverage guard is the boundary fix the issue asked for: the covered set is
compared against an ISearchIndex population DERIVED FROM THE ASSEMBLY. Its claim
stops where the check does -- no static check can establish that a named fixture
actually DRIVES its indexer, so it forces a human to look rather than proving
coverage. ThrowOnWarningLogger moved to ErsatzTV.Tests/Support so both fixtures
share it; the Lucene fixture's assertions are otherwise untouched, since it is a
witnessed proof artifact.

No production change in ElasticSearchIndex.cs -- #824 is coverage only.

Docs: testing.md gains a "Provider-parity fixtures" section naming all THREE
opt-in-MySQL fixtures and recording that CI runs none of them (#627);
docs/README.md gains the matching task signal; guard-inventory.md's
hand-written C# guard list goes from five files to six. Scheduling/Mapper.cs
loses the UTF-8 BOM it inherited, per #311 fix-as-you-touch.

Local gate (with the MySQL lane armed): ErsatzTV.Tests 2096 passed / 0 skipped,
Core.Tests 693/1, Infrastructure.Tests 114, Architecture.Tests 7, Scanner.Tests
1504 -- 0 failures in each. scripts/tests 1228 passed / 2 skipped. dotnet format
whitespace --verify-no-changes clean; BOM check over the touched set with the
population COUNT asserted, because a bare zsh loop silently checks one
concatenated filename. decisions_validate OK.

Fixes #823
Fixes #824

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW
2026-08-29 20:02:52 +02:00
timothyandtimothy 8aeacd534a fix(819): derive the SPA page-size guard population from the git index (#875)
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 8m52s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m23s
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 5m5s
The guard asserted EXACT completeness over a population enumerated by a directory
walk, so an untracked .ts/.tsx under web/src/ entered it and failed as unregistered
on that developer's checkout while CI — which only ever checks out tracked files —
stayed green.

The glob still supplies file CONTENT; the POPULATION is now the git index, read by
web/vite-plugins/trackedSourceFiles.ts in Vite's own Node context and handed to the
app project as a virtual module. That reaches the index without admitting
@types/node to tsconfig.app.json, the obstacle that deferred this in #818.

Three mechanisms carry the proof, each added because the previous was measured
insufficient: a closed-form restatement of the shared scope predicate (sharing no
helper at any depth with what it checks); a second independent `ls-files --others`
query cross-checking the population; and real-git tests that execute the derivation
against a temp repository.

Six residuals are stated with their MEASURED fail-directions, and
testing.guard-derives-population-from-source gains a bounded exception plus the
closed-form criterion.

fixes #819

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-29 09:14:15 +00:00
timothyandtimothy b6b3520bdb fix(809,822): isolate the suite from the production hook-fire log by construction (#874)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 8s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 26s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m12s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m14s
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 4m32s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-29 02:32:26 +00:00
90f96c14a5 fix(803,664): fence the HEAD alias on the PR timeline's pull_push count (#873)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 15s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 26s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m16s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m39s
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 4m37s
A force-push H1 -> H2 -> H1 spanning `pr-changed-files.sh`'s paging leaves its final
`.head.sha` comparison equal while the middle pages came from H2, so a mixed file list
could produce a docs-only exemption `success` no single head ever justified. The base
alias had been fenced since #706 by a monotonic `change_target_branch` count; the head
axis had nothing, and three contracts asserted otherwise.

`count_retargets` becomes `count_pr_mutations`: one timeline walk, two tallies, one shared
trust flag, a separate fence arm and diagnostic per axis. The advisory hook re-reads
`.head.sha` at the same hoist and off the same response as the base re-read. All three
overclaiming contracts are corrected, plus four paraphrases the first sweep missed.

Measured, not assumed: Gitea 1.27.1 still serves no `files` on `compare/{base}...{head}`;
every push is a `pull_push` event and its count cannot alias; PR #761 really went
`8798a1d -> 830a407 -> 8798a1d`; and Gitea creates the push comment BEFORE emitting the
synchronize notification, so a run cannot abstain on its own trigger.

Two pre-existing fail-opens in the shared walk were found by review and fixed: an empty
ARRAY first page was trusted on any page while the `null` arm required `page > 1`, and no
row was validated before `.type` was selected on.

NOT closed, and documented rather than overclaimed: the walk's `null` terminator is
defeatable, because Gitea pages before it filters (#870). The fence closes the ABA on a
timeline with no truncating block, not the ABA outright.

fixes #803
fixes #664
Refs #870

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-28 23:29:47 +00:00
timothyandtimothy 4b3f6f6c90 fix(786,789): workflows declare their own per-job metadata; guard populations derive from it (#872)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m21s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m58s
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 4m8s
Closes #786 and #789, bundled because working either alone would build the artifact the other removes.

Every job in all six tracked workflows declares `env.CI_JOB_ROLE` (guard/report-only/none); the
`docker-build.yml` jobs also declare `env.CI_EXECUTION_CLASS` (toolchain/bare-runner). Both guard
populations derive from those markers; the `TOOLCHAIN_JOBS`/`BARE_RUNNER_JOBS` literals are deleted.
A missing or unrecognised marker is a hard failure in both checkers.

#789's literal had a real justification — set equality between two DERIVED sets is blind to a member
leaving both at once — so the marker is the anchor that replaces it, and the cost (proximity to the
`container:` block) is paid by a THIRD derivation from each job's own steps, which is also the only
check that sees the failure #789 filed: a .NET step moved into a bare-runner job, where no set
changes. The residual is disclosed: drop the block, flip the marker AND hide the tool behind a
script and all three go blind, bounded by the failure mode being a loud missing-binary crash.

#786's guard jobs join a machine-checked population: a new `test_workflow_job_guards.py` asserts set
equality both ways against a new "Workflow-job guards" table, and the four jobs with no dropped-step
guard each carry a recorded decision.

Two issue claims were refuted by measurement: #789's "editing docker-build.yml re-points the pin"
(the pathspec is `docker/ci` only) and #786's job count (17, not 15).

Four cold adversarial review rounds across two model families; rounds 1-3 BLOCKED, all findings
fixed and each fix demonstrated by reproducing the reviewer's own test. The recurring defect class
was prose drifting from code, including a mechanism claim in the decision record that execution
refuted. All five mutation proofs redden when their shipped detector is disarmed.

New decision record: `testing.workflow-declares-its-own-job-metadata`.

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-28 20:54:53 +00:00
timothyandtimothy e11d577193 docs(796): verification code is code under test, and the proof it was claiming an exemption from (#871)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 8s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m38s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m9s
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 4m18s
Records `testing.verification-code-needs-its-own-proof`: the proof obligation follows the
VERDICT rather than the file, so it binds harnesses, wrappers, timeouts and checkers — not
only the files the guard population derives.

The issue asked for a stated position on whether non-guard checker scripts get mutation
proofs. The position as first written claimed `scripts/mcp_smoke.py` "cannot participate"
because driving it needs the gitignored `.mcp.json` and a cold-built language server. Cold
review refuted that by execution: it takes its config path and server name as positional
arguments. The record had failed its own headline rule on the one claim its decision rested
on, so this ships the proof instead of the exemption.

- `scripts/tests/test_mcp_smoke.py` — a hermetic stub JSON-RPC responder and six cases
  pinning the defects the checker has already had, with the positive control as a fixture
  the refusal tests depend on, so a node-id or `-k` selection cannot skip it.
- A declared clause in `mutation_manifest.py` targeting the unguessable request id, using
  the `guard=test / target=script` shape that already exists for `mutation_harness_lib.py`.
  Witnessed red: `id_init = 1` makes the pre-answer accepted at `initialize` (rc 9 -> 10),
  and only that test moves.

`mcp_smoke.py` still gets no inventory row — one is rejected as a phantom (measured). The
row goes to the test file, which joins the derived population automatically.

Five cold-review rounds, four BLOCKED. Round 2 caught a `ruff format` red that would have
failed `script-tests`. Rounds 3-5 found only hand-maintained counts and uniqueness claims in
prose, three of them created by the previous round's fix; that class was deleted rather than
corrected again, per this record's own stop-and-subtract rule.

Docs updated in the same PR: `docs/README.md` task-signal map and `docs/guard-inventory.md`
(row, summary counts, scope-limit item 6).

fixes #796
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-28 20:06:45 +00:00
timothy 609fd852c2 fix(763): page both /statuses/{sha} reads to a validated terminator (#868)
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 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 20s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m10s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m2s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m9s
2026-08-28 17:34:48 +00:00
timothyandClaude Opus 5 11287a54ba fix(763): satisfy ruff — E741 and formatting on the touched test file
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 10s
review-verdict/h10 Review-verdict: MERGEABLE @ 11287a5 (base: main)
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 19s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
PR Gates / Docs update reminder (pull_request) Successful in 16s
Review verdict / Set review-verdict status (pull_request_target) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 6m51s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m5s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
CI's `Script lint and tests` job went red. Cause: I never ran ruff locally,
which this repo's Python convention requires after any .py change.

  - E741 twice: `l` as a comprehension variable in the sort-order guard.
  - `ruff format --check`: the file was correctly formatted on `main`; my edits
    broke it. One of them left a docstring line at column 0, which `ruff format`
    then "corrected" by over-indenting the rest of the paragraph — repaired at
    the source rather than accepting that rewrite.

Verified the way CI does: local ruff is the pinned 0.12.11, and both
`ruff check` and `ruff format --check` run under bash over the full tracked
population (`git ls-files -z '*.py' '*.pyi' '*.ipynb'`, 46 files) are clean.
The population is counted, not assumed — an empty glob would pass vacuously,
which is the failure `scripts/tests` guards against elsewhere.

`scripts/tests` 1097 passed, 2 skipped after the reformat.

refs #763

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 18:51:32 +02:00
5fb9c8537a docs(747): re-verify the Gitea 1.25.4-pinned CI claims on 1.27.1, and measure the merge-gate semantics that were source-attested (#867)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m25s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m48s
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
Population derived from `git ls-files`, not the issue's 9-key list (~21 claim sites).

Re-confirmed unchanged on 1.27.1: the distinct `skipped` commit-status state; `compare` serving
no `files`; no agent-side cancel route (REST route + swagger only); `branches: [main]` suppressing
the run off a non-main base.

Newly measured on four throwaway scratch bases, `main`'s rule never PATCHed: an absent required
context blocks an ORDINARY merge without needing `block_admin_merge_override` (that field governs
the FORCE path only), and `enable_bypass_allowlist` with an empty list is NOT a substitute for it.
Trap recorded: the PR API reports `mergeable: true` while such a merge is refused.

Left explicitly dated with reasons: push-supersession auto-cancel, `pull_request_target` overlap,
`--depth=1` no-merge-base, and the scope-enum/`reqRepoWriter`/403 items. Not a corpus sweep, and
`ci.actions-credential-scoping` now says so. `review-verdict.yml` untouched — #763 holds that file.

Five adversarial review rounds (21/12/9/6/2). Caveat: all same-model-family; Codex was rate-limited.

fixes #747

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-28 16:51:13 +00:00
timothyandClaude Opus 5 462118d98d fix(763): round 9 — assert the repair's reason positively, not by absence
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 22s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 17s
PR Gates / Docs update reminder (pull_request) Successful in 21s
review-verdict/h10 Review-verdict: MERGEABLE @ 462118d (base: main)
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 23s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Failing after 14s
Review verdict / Set review-verdict status (pull_request_target) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Canceled after 1m31s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Canceled after 0s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Canceled after 0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
A sixth cold review found everything in round 8 clean except one line, and it
is the rule this branch keeps rediscovering: the test pinned the new
`raced_why` only by asserting the ABSENCE of the borrowed wording. Measured —
replacing the string with `zzz` left the suite green while an operator would
get `::error::… — zzz.` beside a sticky sentinel. The sibling test 330 lines
away states the rule and follows it; this one did not.

Now asserted positively, with the em-dash and full stop discriminating the
`::error::` reason from the `::warning::` text that continues ", which cannot
be true". The `zzz` mutation reddens it.

Three nits from the same review, all verified by execution rather than reading:
  - the earlier fixture's row was excluded by the strict `> $since` because the
    mark became its OWN id, not because it sat below the mark.
  - the predecessor comment said `main` "warned only on `null`". True of the two
    EMPTY shapes being contrasted; an empty body and a non-array object warned
    as well. Scoped.
  - `docs/ci-cd.md` and the record described the `::error::` as a two-way split
    (found vs unverifiable). Round 8's whole argument is that a complete read
    returning an IMPOSSIBLE answer is a third case, not a variety of the second
    — which is the operator-facing point, since it decides whether to go looking
    for an API failure that never happened. Both now say three.

The review re-verified, by comment-stripped diff, that round 8 changed no
executable line beyond the `raced_why` string and the if/elif restructure, and
independently reproduced both inertness measurements and the `origin/main`
predecessor behaviour.

Verification: `scripts/tests` 1097 passed, 2 skipped; decisions_validate and
build_decisions_catalog --check exit 0.

refs #763

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 18:42:22 +02:00
timothyandClaude Opus 5 4164efbc3e fix(763): round 8 — a fixture must pose the scenario it narrates
A fifth cold review confirmed the gate's behaviour is correct and proof-backed,
and blocked on three non-behavioural items. All three fixed; none touches the
shipped logic.

MEDIUM — the round-7 fixture narrated a raced human verdict it did not
construct. `null-page1-after-post` appended the row unconditionally, so it also
joined the PRE-write read and lifted the high-water mark above itself; removing
it changed nothing. The reviewer's suggested fix was to gate the append on the
post-write read. Measured after gating: still inert, because page 1 answers
`null` before any row reaches the wire.

So the row is gone rather than gated, and the prose now describes what the
fixture actually poses: a response asserting an empty history for a sha this job
wrote to must not be accepted as proof that nothing raced. Whether a verdict
really raced is not modelled and does not need to be — the response is not
evidence either way. A row the test cannot observe is decoration that reads as
coverage, which is the same class this branch has now been blocked on five
times.

LOW — the comment claimed the predecessor "at least produced a `::warning::`".
Half false, measured against `origin/main`: its `jq -e 'type == "array"'` gate
ACCEPTED `[]` silently and warned only on `null`. What is actually new is that
the paged walk reports such a read as a SUCCESS.

LOW — when the empty clause fired it set `ph_ok=no`, so the log said "could not
be read completely" beside a walk that completed on a validated terminator. The
answer was impossible, not unreadable, and an operator holding a sticky sentinel
needs to know which. It now carries its own `raced_why`, asserted by the test.

Both clauses mutation-proved: disarming the empty check, and reverting to the
borrowed wording, each redden the named test.

Verification: `scripts/tests` 1097 passed, 2 skipped; decisions_validate and
build_decisions_catalog --check exit 0.

refs #763

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 18:18:37 +02:00
timothyandClaude Opus 5 e6f04cc739 fix(763): round 7 — reject an empty post-write history, and the fourth overclaim
A fourth cold review returned NOT-MERGEABLE on two Mediums. Both fixed, plus
its three Lows.

MEDIUM, and a defect this branch introduced. Tolerating a `null`/`[]` page 1 as
"complete, zero rows" is correct for the PRE-write caller — a head nothing has
posted to genuinely has no statuses — and impossible for the POST-write one,
which has just written a row to that sha. The body is well-formed, so nothing
retries it, and the walk reports success: `raced=0` concluded from a list that
cannot be real, on the one path whose failure direction is toward SUCCESS.
Worse than the code it replaced, which at least emitted a `::warning::` — a
logged fail-open had become an unlogged one. Reviewer measured both directions.

The post-write caller now rejects an empty result itself; the walk stays
caller-agnostic because the pre-write caller genuinely needs the empty answer.
This is NOT the withdrawn currency witness: that asked whether ANY row sat above
the mark, which an unrelated newer row satisfied while the rejection stayed
hidden, and it fired on schema-valid staleness. This asks only whether the list
is EMPTY — a state no unrelated row can produce and no ordering can disguise.
It carries neither defect. Proved by fixture; disarming it reddens the named
test, and the previously-uncovered `null`-at-page-1 clause is now covered too.

MEDIUM — the fourth overclaim of the same class, in the decision record body:
"Uncertainty must fail closed at both ends … Both repair now." The page-2 probe
was DELETED, not converted; it repairs nothing. It also contradicted the
record's own `rule:` ("the two directions are NOT symmetric") and the bullet
directly beneath it. Round 5 retracted this wording in `docs/ci-cd.md` only —
the sweep was by subject, not by the retracted words.

Also fixed:
  - the record presented "an empty FIRST page is legitimate" as a property of
    the walk; it is a property of the pre-write caller.
  - `docs/ci-cd.md` called the numeric-only id comparisons a fix for mark
    inflation; they are a TYPE guard, closing the string half. A corrupt but
    genuinely numeric id still inflates the mark — not attacker-controllable,
    since ids are server-assigned, and now stated rather than implied.
  - `test_a_partial_mark_is_SAFE...`'s self-guard promised to detect that the
    fallback ran; it keys on a warning emitted by a different condition, so
    deleting the fallback left it green. Its sibling is what reddens; the
    message now says what it actually pins.
  - the order-faithful fixture appended the job's own POST after the reversal,
    serving the NEWEST row on the OLDEST page — the opposite of DESC, in the one
    fixture that exists to be ordering-faithful.
  - "twice per walk" for the wasted sleep; it is once per walk, twice per run.
  - a dead counter read in the DESC mode.

Rebased onto b16ec15d6 (the other session's #781/#799 docs work; no file
overlap, no conflicts).

Verification: `scripts/tests` 1097 passed, 2 skipped; fifteen executed mutations
across rounds 2-7; decisions_validate and build_decisions_catalog --check exit 0.

refs #763

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 17:47:06 +02:00
timothyandClaude Opus 5 89db6022d9 fix(763): round 6 — measure the partial-mark fallback under the REAL ordering
A fourth cold review (Opus, isolated worktree, tests/double/docs focus)
reported no correctness bugs in shipped behaviour but two coverage defects on
exactly the two things this change advertises. Both are closed.

The partial-mark fallback's safety is a claim ABOUT THE ORDERING — page 1 holds
the newest rows, so a walk that fails later still saw the true maximum. The
fixture pinning it served ASCENDING ids, i.e. the arrangement the design calls
unsafe, and passed anyway because the raced row's id sat above even the partial
mark. It could not distinguish safe from unsafe.

The stub now HONOURS the sort parameter: order-faithful modes serve DESC by
default and ASC when the request asks. The new fixture holds a PRE-EXISTING
base-mismatched verdict at id 7055 among 60 rows. Under DESC the salvaged mark
is 7059 and that row is below it — the exemption correctly stands. Under ASC
the mark would be 7049 and that untouched row tests as NEWER, a sticky repair
on a head nothing raced. So re-adding `sort=highestindex` now reddens by
BEHAVIOUR, not only by the structural assertion added in round 5. Measured:
re-adding it reds both tests.

Most modes stay ordering-blind on purpose and now say so: they test walk
COMPLETENESS, which is order-independent, and insertion order is what lets a
fixture place a row beyond page 1.

Also fixed:
  - `null` is accepted as an empty page. An array-only gate is the exact shape
    of #751 — `count_retargets` had one, the timeline really did return `null`
    past the end, and the fence withheld EVERY exemption from the day it
    shipped. The same narrowing here is worse, because this walk's failure is
    the STICKY sentinel: every exempt PR would need a hand-posted verdict, per
    head. Tolerating `null` cannot misread `[]`. Proved by fixture.
  - the fail-closed comment said "past the 1000-row page cap"; the bound is 950,
    as the walk's own comment and both docs already said.
  - the docs claimed "only a read returning no rows at all abandons the mark".
    False: a VALIDATED empty history yields a mark of 0 and is not abandoned —
    that is the normal first run. What abandons it is a read that both FAILED
    and returned nothing. Corrected in ci-cd.md and the record `rule:`.
  - a comment pointed at the page-2 probe "a few lines further down"; it was
    deleted, so the deixis pointed at nothing.
  - the stub claimed its logical-read counter "is only reached on a SUCCESSFUL
    page-1 serve" — measured false; it counts page-1 requests, retries included.
  - five `(round N)` markers removed. A round number is session chronology and
    does not parse for a reader who never saw it (`docs.no-session-narrative`);
    an issue number does. The four that remain predate this change.

Verification: `scripts/tests` 1096 passed, 2 skipped. Thirteen executed
mutations across rounds 2-6. The reviewer independently re-ran the earlier
matrix and confirmed it, with one correction carried here: two of those
mutations redden MORE than their named test, so "each reddening exactly its
named test" was wrong — they redden at least it.

refs #763

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 17:34:08 +02:00
timothyandClaude Opus 5 53ee44cbc1 fix(763): round 5 — correct the overstated motivation, and fix the string-id twin
A third cold review (Opus, isolated worktree) returned NOT MERGEABLE with one
High and three Medium. All are addressed.

HIGH — the stated motivation was wrong, and self-contradictory once round 4
landed. Under the server default (`created_unix DESC`) page 1 holds the NEWEST
rows and ids are monotonic with `created_at`, so page 1 already carried the
true maximum id AND every row newer than the mark — the only rows the
post-write check selects on. A single-page read therefore missed a raced
verdict only if more than 50 rows were created INSIDE the write window, not
merely on "a head with more than 50 rows", which the issue, the comments and
the docs all asserted. Reviewer executed an order-faithful DESC stub: a
page-1-only reader repairs identically to the full walk.

What actually removed #761's stall is retiring #751's page-2 probe, not the
paging. The walk still earns its place, for a reason now stated instead of the
false one: it stops the gate's one fail-toward-SUCCESS path depending on an
undocumented ordering the server honours only coarsely (page 1 came back
`114,112,113,111,110`). That measurement was deleted in commit 1 and is
restored, since round 4's safety argument rests on exactly it.

MEDIUM/real defect — the string-id TWIN, live on `main` and one expression
away from the fix already made: `select((.id? // 0) > $since)`. jq orders
strings above every number, so a PRE-EXISTING row with `"id": "3"` reads as
newer than any mark, is counted as having raced the write, and gets the sticky
sentinel plus a false "was overwritten" on EVERY later run — a permanent
per-sha stall no re-trigger clears. Now numeric-only, with a test.

Also fixed: a non-empty history carrying no numeric id was collapsed to a mark
of 0 (making every pre-existing row look newer); it is now reported unusable
and the check is skipped. `sleep` no longer fires after the final attempt.

Three unpinned clauses now have tests, each proved by an executed mutation:
  - the page cap is a refusal, not a terminator (1050-row fixture)
  - the `::error::` found-vs-unverifiable distinction (forcing `raced_why=human`
    reddened nothing before)
  - the walk requests no sort order — a structural guard on round 4's
    withdrawal, which nothing mechanical protected. It reads request LINES, not
    comments, since the withdrawal note names the parameter to explain it.

Honest scoping, not new code: the test double is ordering-blind, so the paging
tests prove WALK COMPLETENESS, not that a real raced verdict would otherwise be
missed — under DESC it would not be. The stub comment and the docstrings now
say so rather than implying the stronger claim.

Docs: `ci-cd.md` and the record's `rule:` carry the corrected reachability, the
DESC dependency of the partial-mark fallback, and both rejected alternatives
stated as rejected alternatives rather than as draft chronology
(`docs.no-session-narrative`).

Verification: `scripts/tests` 1094 passed, 2 skipped; eleven executed
mutations across rounds 2-5, each reddening exactly its named test;
decisions_validate and build_decisions_catalog --check exit 0.

refs #763

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 17:34:07 +02:00
timothyandClaude Opus 5 1eb7b41fc1 fix(763): round 4 — withdraw the ASC sort, which inverted the partial-mark fallback
Round 3 added `sort=highestindex` to close a mid-walk-insert gap: under the
server default (`created_unix DESC`) a row inserted while the walk is running
lands at position 0, on a page already read, so the walk never sees it.

That fix and the round-2 partial-mark fallback are incompatible. ASC puts the
OLDEST rows on page 1, so an incomplete walk takes its high-water mark over
the oldest rows — leaving every pre-existing row above the mark and read as
"raced". That is a spurious STICKY repair on a head nothing raced, which is
precisely the #761 failure this whole issue exists to remove. Under the
default DESC the newest row is on page 1 by construction and ids are monotonic
with `created_at` (measured), so a partial mark is at or very near the true
maximum and "lower is safe" actually holds.

Two defects from one mechanism again, so the mechanism goes rather than
getting patched: the sort is withdrawn and the mid-walk-insert residual is
ACCEPTED and documented. It is bounded — a row arriving after this job's POST
is not one this job overwrote, and being newest it wins on the combined
endpoint branch protection reads.

Both the code comment and the docs record the withdrawal and the reason, so
the next reader does not re-adopt it.

Verification: `scripts/tests` 1090 passed, 2 skipped; the partial-mark mutation
still reddens `test_a_PRE_WRITE_paging_failure_still_yields_a_usable_high_water_mark`;
decisions_validate and build_decisions_catalog --check both exit 0.

refs #763

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 17:34:07 +02:00
timothyandClaude Opus 5 007d2fd3df fix(763): round 2+3 — close the fail-opens the paging change introduced
Two independent cold reviews (Codex GPT-5.6 cross-family, and an isolated
Opus agent) converged on the same blocker, which is fixed here along with
everything else they found.

BLOCKER — the mark walk turned a fail-closed case into a fail-open. The
high-water mark gates the post-write race check entirely: `max_id_before=-1`
skips it. Before paging, only a failure of the single page-1 request could
reach that. Requiring a COMPLETE walk newly routed a page-2 hiccup, an
over-cap history, or one malformed id on a later page into the same hole, so
a human rejection racing the write was left green where `main` repaired.
A partial list now still yields a mark: it can only be LOWER than the true
maximum, which makes the check more eager, never blinder. Only a read
returning no rows at all abandons it — the pre-existing #849 gap, unchanged
and now asserted by a test so it stays visible.

WITHDRAWN — the "currency witness". It produced two defects from one
mechanism, which is the signal to remove rather than patch twice: counting
ANY row above the mark does not witness this job's write, so a stale-but-valid
snapshot carrying an unrelated newer row passed while hiding a rejection; and
a schema-valid stale read is not retried, so one such response turned a
transient anomaly into a permanent sentinel. The hazard has no mechanism here
either — Gitea is a single instance with no read replicas. Removing it
restores the pre-change exposure on that path, a non-regression.

Also fixed, each a fail-open with a fixture and an executed mutation:
  - `.creator` is type-tested before indexing. `.creator.login` on a non-object
    exits jq 5 and `set -e` took the step down after the green was posted and
    before the repair. Reproduced by both reviewers.
  - the mark is the max over NUMERIC ids only. jq orders strings above every
    number, so one `"id": "99999"` passed the numeric gate and inflated the
    mark until nothing looked newer.
  - an unusable `raced` count now repairs instead of "not acting on it".
  - `sort=highestindex` (ASC, measured) so a row inserted mid-walk appends at
    the end rather than at position 0 on a page already read. An unknown sort
    value silently falls back to DESC, so this is insurance, not load-bearing,
    and the comment says so.
  - `ph_ok`/`ph_rows` renamed off `read_existing_verdict`'s `st_ok`. No live
    bug, but a name collision in a 1400-line step.

Tests the reviews showed were missing, each proved by an executed mutation:
  - verdict beyond a SHORT page (a deliberately unfaithful truncated response
    — against a faithful double a short page is always the last, so the rule
    "terminate only on an EMPTY page" was unobservable)
  - pre-write paging failure still yields a usable mark
  - pre-write read returning nothing abandons the mark and says so
  - a TRANSIENT page failure is retried (the retry was unproven code: every
    other error mode fails on every attempt, so disarming it reddened nothing)
  - a string id cannot inflate the mark
  - a malformed `creator` row does not kill the job

Stub corrections, both the same class as the earlier `[]`-vs-`null` gap: it
served one flat list (so paging was unobservable) and computed its own-post id
with `max()` over mixed str/int, which raised TypeError and made the string-id
test pass because the DOUBLE crashed rather than because the mark was right.

Mutation matrix, all executed, each reddening exactly its named test: retry
disarmed; numeric-max reverted; partial-mark fallback removed; short-page
terminates; page-1-only walk; post-write fail-closed flipped open; jq
type-guard reverted. The unusable-count arm is unreachable by any fixture and
is annotated as such rather than claimed as proved.

Verification: `scripts/tests` 1090 passed, 2 skipped; decisions_validate and
build_decisions_catalog --check both exit 0; terminator, clamp, sort order and
id monotonicity all re-measured live on Gitea 1.27.1.

refs #763

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 17:34:07 +02:00
timothyandClaude Opus 5 4368cc8cbe fix(763): page both /statuses/{sha} reads to a validated terminator
`review-verdict.yml` read the per-POST status history twice with a single
`?limit=100` request. `limit` clamps to the server-wide `MAX_RESPONSE_ITEMS`
(measured 50), so on a head carrying more rows than the clamp both reads saw a
partial list. The high-water mark was only page 1's maximum, and — the direction
that matters — a raced human verdict beyond page 1 was invisible to the
post-write race check, leaving a forged green over a rejection.

Both reads now walk to a validated empty page (`[]` on this endpoint, measured
2026-08-28 against PR #761's 114-row head: pages 1-2 return 50, page 3 returns
14, page 4 is `[]`), never terminating on a short page, under a 20-page cap and
retrying each page once. Correctness does not depend on the cap value.

This retires #751's page-2 "assume raced" probe, which repaired every head that
outgrew one page. It fired on Renovate PR #761: an `::error::` claimed a human
verdict had been overwritten on a head carrying none, and the sticky sentinel
then refused re-exemption on every later run.

Two properties replace it. Uncertainty now fails closed at both ends — the
unreadable-history branch warned and left the exemption green while the page-2
probe repaired on the same uncertainty, one check disagreeing with itself; this
is affordable only because paging removed the common trigger. And the post-write
read must witness the job's own write: reaching a validated empty page proves the
walk finished, not that it saw a current list, so at least one row above the
pre-write mark must exist because the job just posted one.

The `::error::` now distinguishes a verdict actually found from an unverifiable
read. The sentinel description stays generic — the classification recognises it
as a fixed point, so its wording is load-bearing.

The stub gained faithful paging (50-row slices, `[]` past the end, one snapshot
per logical read so a counter mode cannot describe two different histories across
pages) and, separately, modelling of the job's own POST appearing in the history
— which it had never done, so in its world every ordinary run looked like a head
nothing had been posted to. `own-write-invisible` withholds exactly that detail
as the negative control for the currency witness.

Mutation-proved by execution, one clause at a time:
  - walk reads page 1 only -> RUNNING_PAST_PAGE_1_is_PAGED_and_the_exemption_
    STANDS, raced_verdict_on_PAGE_2_is_detected_and_repaired and both UNREADABLE
    history tests go red
  - currency-witness zero branch deleted -> CANNOT_SEE_OUR_OWN_WRITE red
  - fail-closed flipped to fail-open -> both UNREADABLE history tests red

Verification: `scripts/tests` 1085 passed, 2 skipped; decisions_validate and
build_decisions_catalog --check both exit 0.

fixes #763

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 17:34:07 +02:00
timothyandtimothy b16ec15d6c docs(781,799): re-measure the tooling audit from a derived population, and adopt serena (#862)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m11s
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 4m13s
§5.3's verdicts rested on a single surface, which manufactured four false zeros: codex is driven
through `codex exec` inside Bash, security-guidance and ralph-loop expose no tool at all and run as
hooks (1,086 executions each), and feature-dev is used through its agents. The audit also compared
current enablement against historical usage — six of the eight plugins it called "genuinely unused"
were disabled for 16 of the 30 corpus days.

The retirement half of #781 is answered *no* on evidence: the zeros split six ways and only one is
grounds for removal. Eight plugins are kept by operator decision.

#799's observation was correct and its cause is now established. serena was `false` in settings.json
until 2026-08-14T12:31Z, when a concurrent session enabled it; its tools appear in no transcript
before 12:42:54Z. #799's session started at 12:01Z and never reloaded, so its probe correctly found
nothing while the settings file already said `true`. serena is adopted and documented as the third
code-intelligence surface.

Four review rounds, two independent cold reviewers (one cross-family); rounds 1-3 BLOCKED.

fixes #781
fixes #799

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-28 14:37:34 +00:00
timothyandtimothy 761e575836 fix(787): derive the dropped-step guard's scope, and reconcile its snapshot against the server (#861)
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 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m22s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m56s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m33s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-27 22:37:02 +00:00
timothyandtimothy 8aebba4d89 fix(748): declare permissions: on all six workflows, and prove the declaration binds (#860)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 18s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m11s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m4s
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 4m20s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-27 22:02:44 +00:00
timothyandtimothy 45b17e58e1 fix(744,835): ci-image.yml publishes from main only; guard persist-credentials with no exemption (#857)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 6s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m23s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m15s
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 4m27s
Closes the push route into ci-image.yml (#744) and ships the persist-credentials guard that was waiting on it (#835).

ci-image.yml's push trigger had no branches: filter and was path-scoped to docker/ci/** AND to the workflow file itself. Gitea resolves a push workflow's definition from the pushed ref, so any branch push touching those paths ran that branch's own YAML on a docker-capable runner holding the credential that writes ersatztv:prod and the ersatztv-ci:<sha> five container: jobs execute.

Be precise about what the 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 closes the DRIVE-BY case - publication as a side effect of an ordinary push - and is not a boundary against a writer who intends to run their own YAML. The wider class is #853.

The self-reference left both paths: and ci-image-pin's expected in the same change - a decided tradeoff with both prices stated, not a necessity. Branch publishing moves to workflow_dispatch, probed live: run 2340 on this branch published ersatztv-ci:43b1e45 and left :latest unchanged.

With both mechanical blockers gone, ci-image.yml's checkout takes persist-credentials: false (16 of 16) and scripts/tests/test_workflow_persist_credentials.py holds the convention with NO exemption list - git-index population, declared clause mutation re-run every suite, guard-inventory rows.

fixes #744
fixes #835

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-27 20:44:44 +00:00
timothyandtimothy d68ce42fcb fix(742): inherit an h10 verdict only from an allow-listed reviewer (#850)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 12s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m55s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m0s
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 4m25s
`review-verdict.yml` decided whether an existing `review-verdict/h10` was worth INHERITING by
testing `.creator.login != null` — satisfied by any account's credential, including the `renovate`
bot's `RENOVATE_TOKEN`, a `write:repository` PAT that cannot be scoped down the way #697 scoped the
registry credential. The test is now membership in `H10_REVIEWERS="timothy"`, a literal in the
base-resolved definition.

The design that survived 11 cold review rounds:

* `read_existing_verdict` carries TWO flags. `ex_human` (attributable AND allow-listed) gates
  INHERITANCE; `ex_attributable` gates the last-moment re-read, which asks the opposite question and
  must stay broad. Narrowing both — the first draft — makes the job post its exemption over a
  mid-run rejection, and the post-write repair does not cover that.
* The two calls no longer compute an identical predicate, so "changed" is made explicit: the
  state/creator/description triple from the first read is snapshotted and compared.
* The allow-list governs an inherited `success` ONLY. An existing `failure` inherits on
  attributability alone, because inheriting a rejection can only withhold an exemption while
  re-deriving one can turn it green on an exempt PR. A symmetric rule was a measured fail-open.
* The post-write raced check stays broad — not because narrowing would let a rejection go green
  (a real reviewer is on the list by construction), but for the misconfiguration case.

Two mechanisms were WITHDRAWN rather than patched a third time, and both withdrawals are recorded
in `ci.exemption-provenance` so they are not re-attempted: a `::warning::` annotation that produced
three defects in three rounds, and a post-write fix whose generic `pending` would have been
re-derived anyway and which had no retry trigger.

Verified: the inheritance predicate driven against the LIVE Gitea API on a probe-named context,
both allow-list directions; every clause mutation-proven against the shipped file; `scripts/tests`
1012 passed, 2 skipped.

Follow-ups filed: #845 (post-review-verdict.sh does not check its own account is allow-listed) and
#849 (post-write verification: three routes leaving an exemption `success` over a human `failure`,
plus the retarget fence's post-POST gap, plus the prose sweep that lands with the behaviour).

fixes #742

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-27 02:52:02 +00:00
timothyandtimothy ed8b602445 feat(735): bound the numeric FFmpeg profile fields with a 422, and expose readrate pacing (#847)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 11s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m41s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m23s
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 6m23s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 22:05:38 +00:00
timothyandtimothy 469d19852c fix(788): one declarative H10 verdict vocabulary, derived by both sides (#846)
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 8m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m17s
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 4m22s
The verdict words lived in two hand-written shell copies — the `case` arms of
post-review-verdict.sh (write) and the POS_RE/NEG_RE regexes of
check-review-verdict.sh (read) — held together by nothing but a comment that had
already gone stale. scripts/lib/review-verdict-vocabulary.sh now declares them
once and both sides derive; neither script enumerates a verdict word any more.

Only the WORD SET moved. The grammar stays in check-review-verdict.sh, where
every #629 false-open actually lived.

No parity test: #774 shipped one and withdrew it after six rounds, because a
regex over shell source is not a shell parser. The proof is behavioural and
graded MUTATION — the harness restores the pre-#788 hardcoded POS_RE each run and
requires it to redden.

Enforcement is a DATA dependency, not a control-flow gate. Review round 1 found a
real fail-open in the first commit: `${#arr[@]}` is nounset-safe only for a
declared-empty array, and under `set -u` that error inside a function called as
`if ! validate` skips BOTH branches — so on the reader (deliberately no `set -e`)
an explicit BLOCKED @ head classified `positive`, exit 0. Validation now sets a
sentinel on its last line and the derived views refuse without it.

Six cold review rounds; rounds 2-6 found no fail-open across differential fuzzing
(4788 / 2612 / 7560 payloads, zero divergences from origin/main's grammar),
sentinel forgery, environment poisoning, declare -p evasion on bash 5.3 and 3.2,
path/symlink resolution and probe TOCTOU. Every malformation fails closed: reader
exit 2, writer exit 1 with nothing posted.

Also corrected: CLAUDE.md and release.review-verdict-gate both enumerated the
vocabulary without LGTM, a word the code has accepted since #629.

fixes #788

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 20:58:09 +00:00
timothyandtimothy ba6a4b08aa feat(732): On Now / Next gets a background box, and is on by default (#843)
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 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m18s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m13s
probe742/combined-newest SECOND
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 19:28:25 +00:00
timothyandtimothy f2551b778e fix(746): drop the persisted checkout credential; unmask the base-ref fetches (#842)
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 24s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 30s
Build ErsatzTV Image / Build & test (.NET) (push) Failing after 1m37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m14s
Build ErsatzTV Image / Build & push image (amd64) (push) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m1s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 18:50:56 +00:00
timothyandtimothy 40a3232d9e feat(734): field-level progressive disclosure — shared FieldHelp trigger + panel (#841)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 15s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m57s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m32s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m18s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m27s
Implements the three-level field-help pattern from #734 as a shared component: field name + optional one-sentence summary → a one-short-paragraph panel behind a consistent Info-icon trigger → a future external-docs deep link (`docsHref`, built and typed; no screen passes one yet).

Adopted on FFmpegProfilesScreen (9 fields), documented as docs/spa-conventions.md §15 with decision record `spa.field-progressive-disclosure`, and mirrored into the design-system prototype.

The panel is portalled to document.body: `.ctv-card` sets `overflow: hidden`, which clips a positioned descendant whatever its z-index, and one field's explainer rendered 12px of a 92px paragraph in every state of the Audio card.

A `::before` hover bridge was added and then WITHDRAWN — it held for a vertical descent onto the panel and failed for a diagonal one, leaving a safe sideways exit of 1.25px on an 18px icon. Hover reads the paragraph in place; the panel's interactive content is reached by pinning.

Four cold adversarial review rounds; the first three returned BLOCKED. They found five wrong copy claims across nine paragraphs and two vacuous tests in a row for the same mechanism.

Deferred with owners: #839 (placement verified by hand, not by a test) and #840 (the portal puts a docsHref link at the end of the tab order).

fixes #734

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 18:05:53 +00:00
timothyandtimothy 35affecd29 docs(755): ersatztv owns the fork code, media-management owns channel operations (#838)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 8s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 20s
Build ErsatzTV Image / Build & test (.NET) (push) Failing after 1m41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m7s
Build ErsatzTV Image / Build & push image (amd64) (push) Skipped
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
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 17:29:16 +00:00
timothy e8e17f3844 docs(708): record the live route-2 reproduction against PR #761 (#834)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 26s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m57s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m53s
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
2026-08-26 09:11:23 +00:00
timothyandtimothy 08cd3a002d fix(690,758): count the same query a paged handler pages (#833)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 14s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m18s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m40s
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 5m46s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 07:39:23 +00:00
timothyandtimothy 7453dd3a82 fix(721,740): align Auto-Tune proposal rows on a grid; guard AddItemsDialog's async searches (#831)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 8s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m35s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m38s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m58s
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 1m17s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-25 21:55:23 +00:00
310 changed files with 52760 additions and 2552 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
+294 -47
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
@@ -22,7 +22,7 @@
# The "## Done-when" issue-body checklist is the convention (docs/decisions.md, CLAUDE.md Task
# Completion Protocol). One box is "adversarial review passed"; the others are per-issue.
# The H10 review-verdict convention: after reviewing a PR (or its latest fix commit), post a PR
# comment carrying a line `Review-verdict: <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> @ <head-sha>`.
# comment carrying a line `Review-verdict: <MERGEABLE|APPROVED|LGTM|BLOCKED|NOT-MERGEABLE> @ <head-sha>`.
#
# Decision policy — a CONSENT gate, so it does NOT fail silently open:
# - state derivable and satisfied -> grant (auto-approve: permissionDecision "allow",
@@ -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
@@ -96,8 +108,9 @@ sha=$(printf '%s' "$prjson" | jq -r '.head.sha // ""' 2>/dev/null || true)
body=$(printf '%s' "$prjson" | jq -r '.body // ""' 2>/dev/null || true)
# --- Docs-only exemption: if every changed file is docs/process, skip the gate. ---
# The file list must be enumerated EXHAUSTIVELY, validated row by row, and bound to ONE head, or the
# exemption is unsafe. ALL of that now lives in scripts/pr-changed-files.sh — the single shared
# The file list must be enumerated EXHAUSTIVELY, validated row by row, and checked for head/base
# movement across the paging round trips, or the exemption is unsafe. (That check detects ONE-WAY
# movement only — this said "bound to ONE head" until 2026-08-28, ersatztv#803.) ALL of that now lives in scripts/pr-changed-files.sh — the single shared
# implementation, also called by .gitea/workflows/review-verdict.yml (ersatztv#649).
#
# Why it moved: this logic was written twice. This copy is ADVISORY (a failure produces a human
@@ -173,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).
#
@@ -194,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")
@@ -212,6 +225,48 @@ fi
# From here on both names are the freshly-confirmed base; they are equal by the check above.
base_ref=$base_now
live_base=$base_now
# THE HEAD IS RE-READ AT THE SAME HOIST, FROM THE SAME RESPONSE (ersatztv#803).
#
# `$sha` comes from the PR snapshot at the top of this hook, and until 2026-08-28 every later check
# consumed that captured value: the CI combined status, the `review-verdict/h10` status, and the
# verdict-comment classification were all evaluated against `/commits/$sha/status` and `--head $sha`.
# A push landing in the gap — which includes the docs-only enumeration's up-to-forty round trips —
# was therefore checked against the commit it had just replaced, and the hook would report "a
# positive Review-verdict references the current head" about a head that was no longer current.
#
# This is the SAME defect the base had until #778 hoisted the re-read above, and it is fixed the same
# way rather than a different way. Reading `.head.sha` off `$prjson_now` — the response the base
# check already fetched — costs NO extra round trip, and it keeps the two axes on ONE snapshot, so
# they cannot disagree about which moment they describe. Two separate reads would answer about two
# different instants while reading as one check.
#
# DENY, not ask, and for the same reason the `stale` verdict class denies: a head that moved means
# the verdict this hook is about to accept covers an OLDER commit, which is a state we have
# positively established rather than failed to establish. An UNREADABLE `.head.sha` is the different
# case and asks.
#
# WHAT THIS DOES NOT CLOSE, said here rather than left to be inferred. A push landing after this
# check still passes, exactly as a retarget does — the file's rule against a second re-read applies
# unchanged (see the branch-protection block below), because two reads only move the window rather
# than closing it. That residual is bounded server-side and this hook is not what bounds it: the new
# head has no `review-verdict/h10` status, and that context is REQUIRED on `main`, so Gitea refuses
# the merge (#622). The hook's job here is to stop CLAIMING a head is reviewed when it can see that
# it is not — an advisory gate that states something false is worse than one that asks.
if [ -n "$sha" ]; then
sha_now=$(printf '%s' "$prjson_now" | jq -r '.head.sha // ""' 2>/dev/null || true)
if [ -z "$sha_now" ]; then
decide ask "H10 merge gate: PR #$pr reports no head commit (.head.sha) on re-read, so whether the review verdict still covers the current head could not be confirmed. Check the PR, then merge."
fi
if [ "$sha_now" != "$sha" ]; then
decide deny "H6/H10 merge gate: BLOCKED — PR #$pr's head moved from ${sha:0:7} to ${sha_now:0:7} while this gate was evaluating. Every check formed against ${sha:0:7} — the changed-file enumeration, the CI status and the review verdict — describes a commit that is no longer the one being merged (ersatztv#803). Re-review the current head and run: scripts/post-review-verdict.sh $pr MERGEABLE"
fi
# From here on `$sha` is the freshly-confirmed head; the two are equal by the check above. Mirrors
# `base_ref=$base_now` a few lines up, and is written for the same reason that one is: it makes the
# value every later check consumes the one that was just re-read, so a future edit moving a
# consumer above this point fails visibly rather than silently reading the stale capture.
sha=$sha_now
fi
if [ -n "$sha" ]; then
# This is the THIRD read of this endpoint in a worst-case hook run (the ordinary-CI branch and the
# scheduled-auto-merge branch each do their own). Sharing one snapshot would close a narrow
@@ -278,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."
@@ -402,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
@@ -410,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
@@ -472,30 +570,33 @@ else
# directions, and it is rare in practice: as of 2026-08-19 this repo's only rule is the plain
# name `main`, which the classifier resolves to `exact` on every run. That is a dated
# observation about mutable remote config, not a property to rely on.
bp_verdict=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c '
def esc: gsub("(?<c>[.+?^${}()|\\[\\]\\\\])"; "\\" + .c);
def offs: [match("[*?\\[\\]{}\\\\]"; "g").offset];
def superset: . as $n | (offs) as $o
| ($n[0:$o[0]] | esc) + ".*" + ($n[($o[-1]+1):] | esc);
def nonascii: explode | any(. > 127);
. as $rules | $b as $base |
($rules | map(select((.branch_name // .rule_name // "") as $n
| (($n|offs|length) == 0)
and (($n|ascii_downcase) == ($base|ascii_downcase))))) as $exacts |
(($base|nonascii) or ($rules | any((.branch_name // .rule_name // "") as $n
| ($n|offs|length) == 0 and ($n|nonascii)))) as $unfoldable |
if ($rules | any((.branch_name // .rule_name // "") as $n
| (($n|offs|length) > 0)
and ($base | test("^" + ($n|superset) + "$")))) then {verdict:"undecidable"}
elif $unfoldable then {verdict:"undecidable"}
elif ($exacts | length) > 1 then {verdict:"undecidable"}
elif ($exacts | length) == 1 then {verdict:"exact", rule:($exacts | first)}
else {verdict:"none"} end' 2>/dev/null || true)
# The classifier is a FILE now (ersatztv#787), so its absence is a new failure mode: `jq -f` on a
# missing program exits 2 with empty stdout, which reaches the `*)` arm below and asks that "this
# repo's branch-protection rules came back in a shape this hook could not parse" — blaming the
# payload for a missing local file. That is precisely the states-a-cause-that-did-not-happen defect
# 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
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
@@ -511,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
@@ -520,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
@@ -585,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
@@ -614,6 +741,126 @@ case "$class" in
decide ask "H10 merge gate: unrecognized verdict classification '$class' for PR #$pr. Confirm the review covered the latest commit ($short) before merging." ;;
esac
# --- (d) Guard-scope freshness (ersatztv#787): the committed mirror of `main`'s required status
# checks must still match the server. ------------------------------------------------------
# ORDERED LAST, and that is a severity argument rather than a stylistic one. Every check above
# can DENY; this one can only ever downgrade an otherwise-satisfied auto-grant to a prompt. Run
# earlier it would preempt those verdicts and report a stale guard scope at a reader whose merge
# is blocked for a completely different and more serious reason, and it would ask on payloads the
# checks above are about to reject anyway. Placed here it is also PAST the point where the two
# merge paths converge, so it covers both without duplicating anything.
# `scripts/tests/test_ci_dropped_step_guard.py` DERIVES which jobs must carry per-step execution
# markers from `.gitea/required-status-contexts.json`, because its CI job checks out with
# `persist-credentials: false` and cannot ask Gitea. That makes the snapshot the single
# hand-maintained input in the chain: a fourth required context added on the server leaves the
# snapshot — and therefore the guard's scope — silently behind, which is the whole of #787.
#
# THIS RUNS ON BOTH MERGE PATHS, deliberately, and it is placed here rather than beside the
# branch-protection read in the scheduled-auto-merge branch for that reason.
#
# WHAT IT DOES NOT COVER, said here rather than left to be discovered: a PR whose changed files are
# all docs/process — `.gitea/` included — exits at the docs-only passthrough far above, so this arm
# never runs for it. A PR that edits ONLY `.gitea/required-status-contexts.json` is docs-only BY
# CONSTRUCTION, and that is exactly the snapshot-NARROWING direction the decision record names as
# this design's residual. Excluding that path from the allow-list would not buy the protection it
# looks like it would: this arm compares the live server against the snapshot in the LOCAL CHECKOUT,
# not against the version the PR proposes, so it cannot see a narrowing that has not landed yet.
# What does hold is that the passthrough is a passthrough — a human prompt, never an auto-grant —
# which is the `.gitea/` treatment ersatztv#317 asked for. That read is inside
# `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", 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
# from the one the scheduled branch asks — "is review-verdict/h10 required on the base I am merging
# into" — so this is not a second copy of that classifier and the two cannot drift into disagreeing:
# they consume different fields of different rules for different decisions.
#
# ASK, NEVER DENY. Drift does not make THIS merge unsafe: Gitea enforces the live required set
# server-side, so a newly required context with no status blocks the merge on its own. What has gone
# 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` — 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"
# THIS ARM IS ABOUT ONE REPO, and the merge tool is not. Every other check here reads
# `$owner/$repo` from the tool input and is repo-agnostic; this one compares a HARDCODED branch
# against a snapshot committed in THIS checkout. Merging a PR in another repo from a session opened
# here would otherwise weigh that repo's live contexts against this repo's mirror and report a
# confident, flatly false finding about it — measured: server-management returns `[]`, which
# classifies as `nomatch`. So the snapshot names the repo it describes and the arm runs only for it.
# An unreadable snapshot cannot answer "is this my repo?" either, so it asks rather than skipping.
ctx_repo=$(jq -r 'if (.repo | type) == "string" then .repo else "" end' "$ctx_snapshot" 2>/dev/null || true)
if [ -z "$ctx_repo" ]; then
decide ask "H6 merge gate: $ctx_snapshot is missing, unreadable, or names no \`repo\`, so the dropped-step guard's scope could not be checked against branch protection — nor could it be established whether this snapshot even describes $owner/$repo (ersatztv#787). Restore the file, or check the required checks manually."
fi
# CASE-FOLDED, because Gitea resolves owner/repo case-insensitively: verified live, both
# `/repos/timothy/ersatztv` and `/repos/TIMOTHY/ErsatzTV` answer 200. A byte-exact compare would let
# any case variant sail through every other arm and SKIP this one, so drift would go unreported with
# no ask — the gate failing open on a spelling. The hook already treats case folding as
# decision-relevant one section up, where `MAIN` vs `main` makes the governing rule undecidable.
ctx_repo_fold=$(printf '%s' "$ctx_repo" | tr '[:upper:]' '[:lower:]')
target_repo_fold=$(printf '%s' "$owner/$repo" | tr '[:upper:]' '[:lower:]')
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
# 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=$(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 "$ctx_err"
case "$ctx_class" in
match) : ;;
drift)
decide ask "H6 merge gate: the required status checks on 'main' no longer match .gitea/required-status-contexts.json (ersatztv#787). scripts/tests/test_ci_dropped_step_guard.py derives its marked-job scope from that snapshot, so until it is reconciled a required context may have NO dropped-step guard — a step the runner drops would conclude success and take that check green having done no work (ersatztv#756). Re-read the live list and update the snapshot in a PR (the guard will then demand markers for any newly required job, or an ACCOUNTED_ELSEWHERE entry naming what covers it). This does not make the merge in front of you unsafe — Gitea enforces the live required set server-side — so approve if you have judged it unrelated." ;;
nomatch)
decide ask "H6 merge gate: no branch-protection rule governs 'main' at all, so the required status checks the dropped-step guard scopes itself to could not be confirmed (ersatztv#787). Branch protection on 'main' is what makes 'review-verdict/h10' load-bearing (ersatztv#743) — check it before merging." ;;
undecidable)
decide ask "H6 merge gate: a glob branch-protection rule could govern 'main', so which rule's required contexts to compare against .gitea/required-status-contexts.json is not derivable without reimplementing Gitea's matcher (ersatztv#787). Confirm the required checks manually." ;;
unreadable)
decide ask "H6 merge gate: branch protection for 'main', or .gitea/required-status-contexts.json itself, came back in a shape the required-contexts checker could not consume, so whether the dropped-step guard's scope is still current is unknown (ersatztv#787). Check the rules and the snapshot manually." ;;
readfail)
decide ask "H6 merge gate: could not read branch protection for the guard-scope freshness check (HTTP '${ctx_code:-none}' — Gitea unreachable, or these credentials lack the repo-admin scope that endpoint needs), so whether .gitea/required-status-contexts.json is still current is unknown (ersatztv#787). Confirm the required checks on 'main' manually." ;;
*)
decide ask "H6 merge gate: the required-contexts checker returned '${ctx_class:-nothing}', which is not a class this hook understands, so the dropped-step guard's scope could not be confirmed against branch protection (ersatztv#787).${ctx_diag:+ It said: ${ctx_diag}}Check scripts/check-required-contexts.sh." ;;
esac
fi # end of the guard-scope freshness arm (opened at `if [ "$ctx_repo_fold" = ... ]` above). The
# body is left unindented to match the rest of this file, which is flat throughout; the marker
# is here because the block is long enough that its extent is otherwise easy to misread.
if [ "$class" = "positive" ]; then
# (a) CI + (b) all Done-when ticked + (c) positive verdict @ current head -> SATISFIED. Auto-grant.
# The reason string must not claim more than was actually checked: on the merge_when_checks_succeed
+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
+102 -2
View File
@@ -4,8 +4,15 @@ description: "ErsatzTV custom IPTV channel management — REST API, SQLite DB, J
---
> **Canonical copy: `~/ersatztv/.claude/skills/ersatztv/SKILL.md`** (ersatztv owns this skill per that
> repo's `CLAUDE.md` → Project Boundaries). `~/server-management/.claude/skills/ersatztv` is a symlink
> to it. Edit it in the ersatztv repo; never fork a second copy (ersatztv#617).
> repo's `CLAUDE.md` → Project Boundaries and `process.ersatztv-owns-code-not-operations`). Both
> `~/server-management/.claude/skills/ersatztv` **and** `~/media-management/.claude/skills/ersatztv`
> are symlinks to it. Edit it in the ersatztv repo; never fork a second copy (ersatztv#617, #755) —
> media-management's copy had silently become a divergent fork still describing a Blazor UI that no
> longer exists, which is what made this the rule rather than a preference.
>
> **Channel OPERATIONS (create/edit a live channel, lineup, collection, schedule, playout, logo,
> overlay) are `media-management`'s job**; ersatztv owns the fork code, `/api/v1`, CI and releases.
> This skill serves both — it is the operator's reference *and* the developer's map.
# ErsatzTV Channel Management
@@ -180,6 +187,99 @@ POST /api/v1/libraries/{id}/scan-show \
POST /api/v1/channels/{channelId}/playout/reset
```
### Scripted Schedule API — `/api/v1/scripted/…`
For **programmatic playout building**: each call mutates one build session, addressed by `buildId`.
Documented by its own OpenAPI spec, **separate from `v1.json`** — which is why
`docs/endpoint-index.md` does not list any of it. It ships as **two** files, both served at
`/openapi/` (measured 2026-08-26 on prod: `scripted-schedule.json`, `scripted-schedule-tagged.json`
and `v1.json` all return 200). They carry the same 28 paths, so either answers "what operations
exist"; they differ only in grouping — the plain file puts everything under one `ScriptedSchedule`
tag, the `-tagged` one splits it into Scripted Content / Control / Metadata / Scheduling. Scalar's
`/docs` page renders the `-tagged` file (`Startup.cs` registers `openapi/scripted-schedule-tagged.json`),
which is why the browsable docs are grouped and a raw fetch of the plain file is not.
The base path is **`/api/v1/scripted/playout/build/{buildId}/`**, and `buildId` is routed as a GUID
(`ScriptedScheduleController.cs`). An older archived copy of this skill gave it as `/api/scripted/…`,
without the `v1`; no such route is registered.
**You cannot tell a wrong base path from a stale `buildId` by probing** — measured on prod
2026-08-26, `GET …/context` with a non-existent build id:
| | `/api/v1/scripted/…` | `/api/scripted/…` (no route) |
|---|---|---|
| no key | 401 | 401 |
| valid key | 404 | 404 |
Unauthenticated everything is 401, because the api-key filter runs before routing. Authenticated, the
correct path 404s too — the build session does not exist — so the 404 that a wrong path earns is
indistinguishable from the one a correct path earns. The bound: this holds **while the build id is
not live**. Against a real, open build session the correct path would answer 200 and the difference
would show — but that is not the situation you are in when you are probing to find out why nothing
works. Confirm the route in `ErsatzTV/Controllers/Api/ScriptedScheduleController.cs`; do not infer it
from a status code.
```
# 28 operations, derived from scripted-schedule.json on 2026-08-26 (ersatztv#755)
POST add_all {content, fillerKind, customTitle, disableWatermarks}
POST add_collection {key, collection, order}
POST add_count {content, count, fillerKind, customTitle, disableWatermarks}
POST add_duration {content, duration, fallback, trim, discardAttempts, stopBeforeEnd, offlineTail, fillerKind, customTitle, disableWatermarks}
POST add_marathon {key, groupBy, itemOrder, guids, searches, playAllItems, shuffleGroups}
POST add_multi_collection {key, multiCollection, order}
POST add_playlist {key, playlist, playlistGroup}
POST add_search {key, query, order}
POST add_show {key, guids, order}
POST add_smart_collection {key, smartCollection, order}
POST create_playlist {key, items}
POST graphics_off {graphics}
POST graphics_on {graphics, variables}
POST pad_to_next {content, minutes, fallback, trim, discardAttempts, stopBeforeEnd, offlineTail, fillerKind, customTitle, disableWatermarks}
POST pad_until {content, when, tomorrow, fallback, trim, discardAttempts, stopBeforeEnd, offlineTail, fillerKind, customTitle, disableWatermarks}
POST pad_until_exact {content, when, fallback, trim, discardAttempts, stopBeforeEnd, offlineTail, fillerKind, customTitle, disableWatermarks}
POST pre_roll_off (no body)
POST pre_roll_on {playlist}
POST skip_items {content, count}
POST skip_to_item {content, season, episode}
POST start_epg_group {advance, customTitle}
POST stop_epg_group (no body)
POST wait_until {when, tomorrow, rewindOnReset}
POST wait_until_exact {when, rewindOnReset}
POST watermark_off {watermark}
POST watermark_on {watermark}
GET context (no body)
GET peek_next/{content} (no body)
```
Re-derive rather than trusting this table (it is prose and will drift):
```bash
# Absolute path on purpose: this skill is symlinked into ~/server-management and
# ~/media-management, where a repo-relative path would not resolve. ~/ersatztv is the
# shared checkout and can lag origin/main — use the live-instance form below to see
# what is actually deployed.
python3 -c "import json;d=json.load(open('$HOME/ersatztv/ErsatzTV/wwwroot/openapi/scripted-schedule.json'));\
print('\n'.join(f'{m.upper()} {p}' for p,i in d['paths'].items() for m in i if m in('get','post')))"
```
Without a checkout — straight off the running instance (prod; test is port 8410):
```bash
ssh timothy@192.168.1.29 'curl -s http://localhost:8409/openapi/scripted-schedule.json' \
| python3 -c "import json,sys;d=json.load(sys.stdin);\
print('\n'.join(f'{m.upper()} {p}' for p,i in d['paths'].items() for m in i if m in('get','post')))"
```
Field lists above are the request-body property names only; consult the spec for types,
required-ness and defaults. That omission matters for the three on/off pairs: `graphics_on`/
`graphics_off`, `watermark_on`/`watermark_off` and `pre_roll_on`/`pre_roll_off` are **separate
operations, not one toggle**, and the difference is not always visible as differing property names.
`graphics_*` and `pre_roll_*` differ outright. `watermark_on` and `watermark_off` both list
`{watermark}`, but only `on` marks it **required** — `watermark_off` with an **empty** list turns
*every* scripted watermark off (`SchedulingEngine.WatermarkOff`: `watermarks.Count == 0` →
`ClearChannelWatermarkIds()`; `GraphicsOff` is the same shape). Read the schema, not this table,
before sending an `_off`.
## SQLite DB Operations
```bash
+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 34 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 }
+12
View File
@@ -0,0 +1,12 @@
{
"repo": "timothy/ersatztv",
"branch": "main",
"read_on": "2026-08-27",
"source": "GET /repos/timothy/ersatztv/branch_protections -> the rule governing `main` -> status_check_contexts",
"why": "ersatztv#787. The committed mirror of the required status checks on `main`. It exists because the guards that make a required context trustworthy run in `pr-checks.yml::script-tests`, which checks out with persist-credentials:false and holds no Gitea credential, so it cannot ask the server. scripts/tests/test_ci_dropped_step_guard.py DERIVES its marked-job scope from `contexts` rather than repeating it as a literal, and scripts/check-required-contexts.sh compares this list against the live one wherever a credential does exist. Editing `contexts` by hand without re-reading the server is the one move that defeats both. The `repo` field exists because the merge-consent hook fires for whatever owner/repo the merge tool was called with: without it, merging a PR in another repo from an ersatztv session compares that repo's live contexts against THIS repo's mirror and reports a confident, flatly false finding about it.",
"contexts": [
"Build ErsatzTV Image / Build & test (.NET) (pull_request)",
"Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request)",
"review-verdict/h10"
]
}
+85 -11
View File
@@ -4,29 +4,74 @@ name: Build CI Toolchain Image
# pushes it to the Gitea container registry (ersatztv#390). The toolchain jobs in
# docker-build.yml consume it via `container:`, pinned to an immutable :<sha>.
#
# push touching docker/ci/** -> :<short-sha> (+ :latest only from main)
# workflow_dispatch -> manual rebuild
# schedule (weekly) -> picks up base-image security updates
# push to MAIN touching docker/ci/** -> :<short-sha> + :latest
# workflow_dispatch on main -> :<short-sha> of main's HEAD + :latest
# workflow_dispatch on a branch -> :<short-sha> of that branch's HEAD ONLY (never :latest)
# schedule (weekly) -> picks up base-image security updates
#
# Deliberately separate from docker-build.yml: this image changes rarely (a Dockerfile edit or
# the weekly cron), while docker-build.yml runs on every push/PR. Coupling them would rebuild a
# ~2GB toolchain image on every commit.
#
# ROLLOUT NOTE: the jobs pin an immutable :<sha>, never :latest — a broken toolchain image would
# otherwise block every converted job the moment it was pushed. Bumping the toolchain is therefore
# a deliberate two-step: merge a docker/ci/Dockerfile change (this workflow publishes a new :<sha>),
# then update the pin in docker-build.yml in a follow-up PR whose CI proves the new image works.
# See docs/ci-cd.md -> "CI toolchain image".
# otherwise block every converted job the moment it was pushed. Bumping the toolchain is a deliberate
# two-step, and BOTH steps land in the SAME PR: publish (push the docker/ci commit as branch HEAD,
# dispatch this workflow on that branch), then commit the pin bump in docker-build.yml. Merging first
# is not available: a PR that changes docker/ci/** without moving the pin turns `ci-image-pin` red,
# and the merge-consent hook reads the COMBINED commit status, so it will not auto-grant. That much
# predates ersatztv#744 — what #744 changed is how the publish half is performed.
# See docs/ci-cd.md -> "Publishing from a branch is a dispatch, not a push".
#
# Like docker-build.yml: the Gitea registry is HTTP-only, so BuildKit needs the inline
# `http = true` config (it does not inherit the host daemon's insecure-registries setting).
on:
# Publishing from a branch is a DELIBERATE act, not a side effect of pushing (ersatztv#744).
# Gitea resolves a `push` workflow's definition from the pushed branch, so an unfiltered `push`
# trigger ran this file's own YAML — attacker-supplied, unreviewed, with no status check in the
# loop — on a docker-capable runner holding the credential that writes `ersatztv:prod` and the
# `ersatztv-ci:<sha>` five `container:` jobs execute.
#
# BE PRECISE ABOUT WHAT THIS BUYS, because the mechanism cuts both ways: the filter below is read
# 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 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
# observable claim is the one that matters — a release cut no longer republishes the toolchain
# image as a side effect.
#
# `.gitea/workflows/ci-image.yml` is NOT in `paths:`, and it left `ci-image-pin`'s `expected` in
# the same change. That pairing is a DECIDED TRADEOFF, not a necessity: keeping it works, because
# the dispatch above can publish the ci-image.yml commit itself and the pin then matches. The
# price is what decided it — that route charges a full ~2GB publish plus a five-pin bump for
# EVERY edit to this file, comments included, and a rebase charges it again. The cost of the side
# taken is stated here and in ci-cd.md: a change to HOW the image is built that lives only in
# 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. 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]
paths:
- 'docker/ci/**'
- '.gitea/workflows/ci-image.yml'
schedule:
# Mondays 05:00 UTC. Gitea registers `schedule` only from the default branch (main).
#
@@ -50,6 +95,21 @@ env:
REGISTRY: 192.168.1.95:3000
CI_IMAGE: 192.168.1.95:3000/timothy/ersatztv-ci
# Explicit token scope (ersatztv#748) so the owner-level Actions default can move to Restricted
# (server-management#714). Declaring `permissions:` is EXHAUSTIVE, not additive: a unit omitted here
# 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".
# This workflow's registry pushes authenticate with the scoped REGISTRY_* PAT
# (`ci.actions-credential-scoping`), so the injected GITEA_TOKEN serves only its single
# `actions/checkout`. This file was the one workflow #748 could not originally reach: editing it
# re-pointed `ci-image-pin`'s `expected` at the editing commit and reddened a BLOCKING job, and its
# own `paths:` made the edit publish an image. ersatztv#744 took this path out of both
# (`ci.toolchain-image-publish-is-a-dispatch`), so the exemption that briefly existed here is DELETED
# rather than documented — which is what ersatztv#835 asked for.
permissions:
code: read
jobs:
build:
name: Build & push CI image
@@ -58,13 +118,23 @@ jobs:
# toolchain image — the heaviest thing that ran in that lane. `small` is now
# git-only and capped at 1g per job, which would OOM this build.
#
# Rare trigger (pushes touching docker/ci + a weekly cron), so it costs the
# ubuntu-latest lane almost nothing, and ci-runner (.127) runs no prod workload.
# Rare trigger (main pushes touching docker/ci, a weekly cron, and the occasional
# branch dispatch), so it costs the ubuntu-latest lane almost nothing, and
# ci-runner (.127) runs no prod workload.
runs-on: ubuntu-latest
env:
CI_JOB_ROLE: none
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# ersatztv#746's convention, applied here once #744 removed the reason it was skipped:
# without it the action leaves a write-capable Authorization header in .git/config for
# every later step. Nothing here pushes with git — the only git call is the
# `rev-parse --short HEAD` below — and the repo is public, so the clone needs no
# credential of its own. Guarded for every workflow by
# scripts/tests/test_workflow_persist_credentials.py (ersatztv#835).
persist-credentials: false
# only docker/ci/Dockerfile is needed; no git describe/log here
fetch-depth: 1
@@ -76,7 +146,11 @@ jobs:
# Always publish the immutable :<sha> — that is what docker-build.yml pins.
TAGS=("${CI_IMAGE}:${SHORT}")
# :latest is a convenience/floating pointer for humans and the weekly rebuild; jobs must
# never consume it. Only main may move it.
# never consume it. Only main may move it — and since #744 the `push` trigger is
# main-only, so on that path the branch check is satisfied by construction. It is now the
# SOLE protection on the one event that never exercised it before: a `workflow_dispatch`
# selects any ref, and the branch-side publish path documented in ci-cd.md runs exactly
# that. Do not simplify this away on the reasoning that the trigger is already main-only.
if [ "${GITHUB_REF}" = "refs/heads/main" ]; then
TAGS+=("${CI_IMAGE}:latest")
fi
+14
View File
@@ -33,13 +33,27 @@ env:
DOTNET_CLI_USE_MSBUILD_SERVER: "0"
MSBUILDDISABLENODEREUSE: "1"
# Explicit token scope (ersatztv#748) so the owner-level Actions default can move to Restricted
# (server-management#714). Declaring `permissions:` is EXHAUSTIVE, not additive: a unit omitted here
# 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".
# Holds no registry credential and reads nothing from the Gitea API; the injected GITEA_TOKEN serves
# only its one `actions/checkout`.
permissions:
code: read
jobs:
scan:
name: NuGet vulnerable packages
runs-on: ubuntu-latest
env:
CI_JOB_ROLE: guard
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup .NET
uses: actions/setup-dotnet@v4
+116 -37
View File
@@ -38,7 +38,9 @@ name: Build ErsatzTV Image
# report `success` in seconds — the two REQUIRED contexts (`Build & test (.NET)`, `EF migration
# integrity (SQLite + MySql)`) must keep reporting or a docs-only PR could never merge. We do NOT
# `if:`-skip a required job: on Gitea 1.25.4 a skipped job reports commit-status state `skipped`
# (verified, throwaway PR #418) and we don't rely on how branch protection treats a skipped
# (verified, throwaway PR #418; re-confirmed on 1.27.1, 2026-08-28, ersatztv#747 — `Build & push
# image (amd64)` is `if:`-skipped on every PR and reported `skipped` on the two heads sampled,
# PRs #829 and #828) and we don't rely on how branch protection treats a skipped
# REQUIRED context. See docs/ci-cd.md -> "Docs-only skip".
#
# RELEASE-PATH DELIMITER GATE (ersatztv#767): the `scan` job runs the PyYAML-based delimiter-ban
@@ -113,6 +115,40 @@ env:
DOTNET_CLI_USE_MSBUILD_SERVER: "0" # no persistent MSBuild server process
MSBUILDDISABLENODEREUSE: "1" # MSBuild worker nodes exit with the build instead of lingering
# Explicit token scope (ersatztv#748) so the owner-level Actions default can move to Restricted
# (server-management#714). Declaring `permissions:` is EXHAUSTIVE, not additive: a unit omitted here
# 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".
# 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 — a line-number citation here has gone stale within two lines
# of being written.)
permissions:
code: read
jobs:
# Answers "is the toolchain image still there?" in ONE place, so a deleted pin does not read as
# five broken jobs and a broken diff (ersatztv#772). Deliberately container-free and deliberately
@@ -121,12 +157,15 @@ jobs:
toolchain-preflight:
name: CI toolchain image resolves
runs-on: small
env:
CI_EXECUTION_CLASS: bare-runner
CI_JOB_ROLE: guard
steps:
- name: Checkout
uses: actions/checkout@v4
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
@@ -140,13 +179,14 @@ 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
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
# git history/tags are needed by the `build` job's `git describe` (ersatztv#190) and,
# here, by the #420 revalidate step's `HEAD^2` tree comparison on a main merge commit.
fetch-depth: 2
@@ -166,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
@@ -360,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)
@@ -415,10 +450,14 @@ jobs:
--health-interval=5s
--health-timeout=5s
--health-retries=30
env:
CI_EXECUTION_CLASS: toolchain
CI_JOB_ROLE: guard
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
# was the default fetch-depth: 1 (ersatztv#190); bumped to 2 so the #420 revalidate
# step's `HEAD^2` tree comparison can resolve on a main merge commit.
fetch-depth: 2
@@ -435,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
@@ -578,13 +615,14 @@ 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
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
# bumped from 1 (ersatztv#190 default) so the #420 revalidate step's `HEAD^2` tree
# comparison can resolve on a main merge commit.
fetch-depth: 2
@@ -595,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
@@ -690,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:`
@@ -724,9 +760,14 @@ jobs:
scan:
name: Delimiter ban (release path)
runs-on: small
env:
CI_EXECUTION_CLASS: bare-runner
CI_JOB_ROLE: guard
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
@@ -743,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
@@ -796,10 +837,14 @@ jobs:
# where an image is published and never booted.
needs: [test, migrations, scan]
if: github.event_name != 'pull_request'
env:
CI_EXECUTION_CLASS: bare-runner
CI_JOB_ROLE: none
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
# ersatztv#416: a docs-only push to main has nothing to rebuild (docs are not in the image),
@@ -819,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
@@ -978,22 +1043,29 @@ 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
CI_JOB_ROLE: guard
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- name: Detect API-surface changes
id: detect
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=100 origin "$base_ref" || true
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
if ! git fetch --no-tags origin "$base_ref"; then
echo "::error::git fetch of origin/${base_ref} failed, so this job cannot compute the changed-file set it derives its work from. That is a broken job, not an empty change set (ersatztv#746). Check the base branch still exists and that the runner can reach the repository."
exit 1
fi
if ! changed="$(git diff --name-only "origin/${base_ref}...HEAD")"; then
echo "::error::git diff against origin/${base_ref} failed, so the changed-file set could not be computed — do not read this as 'nothing changed' (ersatztv#746). If it reports no merge base, rebase this branch onto ${base_ref}."
exit 1
fi
echo "Changed files in this PR:"; printf '%s\n' "$changed"
if printf '%s\n' "$changed" | grep -Eq '^ErsatzTV/Controllers/Api/|^ErsatzTV\.Core/Api/'; then
echo "api_changed=true" >> "$GITHUB_OUTPUT"
@@ -1073,22 +1145,29 @@ 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
CI_JOB_ROLE: guard
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- name: Detect changed C# files
id: detect
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=100 origin "$base_ref" || true
changed="$(git diff --name-only --diff-filter=ACM "origin/${base_ref}...HEAD" -- '*.cs' 2>/dev/null || true)"
if ! git fetch --no-tags origin "$base_ref"; then
echo "::error::git fetch of origin/${base_ref} failed, so this job cannot compute the changed-file set it derives its work from. That is a broken job, not an empty change set (ersatztv#746). Check the base branch still exists and that the runner can reach the repository."
exit 1
fi
if ! changed="$(git diff --name-only --diff-filter=ACM "origin/${base_ref}...HEAD" -- '*.cs')"; then
echo "::error::git diff against origin/${base_ref} failed, so the changed-file set could not be computed — do not read this as 'nothing changed' (ersatztv#746). If it reports no merge base, rebase this branch onto ${base_ref}."
exit 1
fi
echo "Changed .cs files in this PR:"; printf '%s\n' "$changed"
if [ -n "$changed" ]; then
printf '%s\n' "$changed" > /tmp/changed-cs.txt
+111 -29
View File
@@ -42,40 +42,84 @@ concurrency:
group: ersatztv-pr-gates-${{ github.ref }}
cancel-in-progress: true
# Explicit token scope (ersatztv#748) so the owner-level Actions default can move to Restricted
# (server-management#714). Declaring `permissions:` is EXHAUSTIVE, not additive: a unit omitted here
# 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".
# Holds no secrets at all and reads nothing from the Gitea API; the injected GITEA_TOKEN serves only
# its five `actions/checkout` steps.
permissions:
code: read
jobs:
# BLOCKING (ersatztv#390): the CI toolchain image pin in docker-build.yml must name the image that
# ci-image.yml actually last published — i.e. the short sha of the last commit to touch the image's
# sources. Without this detector, a PR that edits docker/ci/** publishes a NEW image but runs its own
# BLOCKING (ersatztv#390): the CI toolchain image pin in docker-build.yml must name the short sha of
# the last commit to touch the image's SOURCES (`docker/ci/**`). Read that as "the image ci-image.yml
# last published" only under the convention that every such commit is published — this job compares
# git shas and never queries the registry, so it cannot see a pin whose tag was never built or has
# been evicted. Existence is `toolchain-preflight`'s job, and the container jobs' pull is the backstop.
# Since ersatztv#744 publishing from a branch is a `workflow_dispatch`, so "was it published" is a
# human step this job does not observe.
#
# Without this detector, a PR that edits docker/ci/** ships a new image RECIPE while running its own
# jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's
# Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** —
# it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque
# `:<sha>` in `container.image`, so it would leave the pin behind every time.
#
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): push the
# Dockerfile change, let ci-image.yml publish `:<sha>`, then update the pin to that sha. Seconds-long
# git+grep -> keep it off the build runners.
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): get the
# Dockerfile change published as `:<sha>`, then update the pin to that sha. Since ersatztv#744 the
# publish half of that two-step is a `workflow_dispatch` on the branch rather than a side effect of
# the push — ci-image.yml's `push` trigger is now `branches: [main]`. Seconds-long git+grep -> keep
# it off the build runners.
ci-image-pin:
name: CI image pin matches docker/ci
runs-on: small
if: github.event_name == 'pull_request'
env:
CI_JOB_ROLE: guard
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
# need real history: `git log -- <path>` on a shallow clone can't find the last
# commit that touched the image sources
fetch-depth: 0
- name: Verify the pin matches the last-published image
- name: Verify the pin matches the image-source commit
run: |
set -euo pipefail
# ci-image.yml tags the image `git rev-parse --short HEAD` of the push that built it, and it
# only builds on pushes touching these paths — so the published image is named by the last
# commit to touch them.
# ci-image.yml tags the image `git rev-parse --short HEAD` of the run that built it. Only
# its filtered `push` clause requires a `docker/ci/**` change; the weekly `schedule` and a
# `workflow_dispatch` both build the selected ref's HEAD whatever it touched. So `expected`
# is not a model of every tag in the registry — it is the one tag a PR is REQUIRED to be
# pinned to: the last commit to change the image's sources.
#
# `.gitea/workflows/ci-image.yml` is deliberately NOT part of `expected` (ersatztv#744),
# and that is a DECIDED TRADEOFF, not a necessity. Keeping it is workable — dispatch the
# branch at the ci-image.yml commit, then pin it — but it prices every edit to that file,
# comments included, at a full ~2GB publish plus a five-pin bump, redone after every
# rebase. Dropping it prices the opposite risk: a change to HOW the image is built living
# ONLY in ci-image.yml (build-args, Dockerfile path, platforms) neither republishes nor
# invalidates the pin, so CI keeps running an image built by the previous recipe. The
# second was chosen because that file is edited far more often for triggers, comments and
# 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.
# 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, 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
# with the repo's object count, so the tag built in CI from a `fetch-depth: 1` shallow clone
# is 7 chars while `%h` here (full clone) is 8. Comparing those strings would fail always.
expected="$(git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml)"
expected="$(git log -1 --format=%H -- docker/ci)"
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
echo "Image sources last changed in: ${expected}"
echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)"
@@ -107,10 +151,10 @@ jobs:
# in-repo remedy in that state: relax this length check in the same PR and say why. Note
# that ci-image.yml still tags with a plain `--short` (auto-scaled), so "always 7" is an
# empirical property of today's shallow clone, not an enforced invariant. Making the
# publisher emit `--short=7` is tracked as ersatztv#597. It is not blocked, just out of
# scope here: editing ci-image.yml re-points `expected` (above) at that commit, so it needs
# the branch's own publish-then-pin two-step (docs/ci-cd.md -> 'CI toolchain image') —
# ci-image.yml's push trigger has no branches: filter, so a feature branch does publish.
# publisher emit `--short=7` is tracked as ersatztv#597. That is no longer blocked by this
# job at all: since ersatztv#744, editing ci-image.yml does NOT re-point `expected`, so a
# `--short=7` change lands like any other PR. It does need a deliberate republish to take
# effect — see the note on `expected` above.
if [ "${#pins[0]}" -ne 7 ]; then
echo "::error::CI toolchain image pin ersatztv-ci:${pins[0]} is ${#pins[0]} chars, but ci-image.yml publishes 7-char tags (it tags with 'git rev-parse --short HEAD' from a fetch-depth:1 clone). A differently-sized abbreviation still resolves to the right commit, so this would pass every other check here — but NO such tag exists in the registry, and all five container: jobs would fail at image-pull time with 'manifest unknown'. Pin exactly: ersatztv-ci:${expected:0:7} (locally: git rev-parse --short=7 HEAD). See docs/ci-cd.md -> 'CI toolchain image'."
exit 1
@@ -121,7 +165,7 @@ jobs:
exit 1
fi
if [ "$pin_full" != "$expected" ]; then
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Let ci-image.yml publish the new :<sha>, then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Publish the new :<sha> — push this commit as branch HEAD and dispatch ci-image.yml on the branch (a branch PUSH no longer publishes, ersatztv#744) — then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
exit 1
fi
echo "Pin is current: ersatztv-ci:${pins[0]} resolves to $pin_full = docker/ci's last change."
@@ -134,16 +178,32 @@ jobs:
name: Docs update reminder
runs-on: small # seconds-long git diff; keep it off the build runners
if: github.event_name == 'pull_request'
env:
CI_JOB_ROLE: report-only
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
# `continue-on-error` for the same reason the two steps below carry it: this whole job
# is a non-blocking nudge, and an advisory red still joins the combined status the merge gate
# reads. Unmasking the fetch (ersatztv#746) makes a broken base LOUD in the log; it must not
# also make a warn-only job merge-blocking. The three jobs that genuinely gate on this diff —
# api-docs, format, decisions lifecycle — do redden on a failed fetch, which is where that
# belongs.
- name: Warn when a screen/route change skips the parity doc
continue-on-error: true
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=100 origin "$base_ref" || true
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
if ! git fetch --no-tags origin "$base_ref"; then
echo "::error::git fetch of origin/${base_ref} failed, so this job cannot compute the changed-file set it derives its work from. That is a broken job, not an empty change set (ersatztv#746). Check the base branch still exists and that the runner can reach the repository."
exit 1
fi
if ! changed="$(git diff --name-only "origin/${base_ref}...HEAD")"; then
echo "::error::git diff against origin/${base_ref} failed, so the changed-file set could not be computed — do not read this as 'nothing changed' (ersatztv#746). If it reports no merge base, rebase this branch onto ${base_ref}."
exit 1
fi
echo "Changed files in this PR:"; printf '%s\n' "$changed"
screen_or_route=no
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
@@ -169,7 +229,8 @@ jobs:
# python-using job on it declares this. Without it a missing interpreter is exit 127 — a RED
# advisory job joining the combined status, which is the one thing this step must never be.
#
# Both steps carry `continue-on-error` because the SCRIPT exiting 0 is not the whole invariant:
# Both steps OF THIS CHECK (setup-python + the narrative step; the parity nudge above has its
# own) carry `continue-on-error` because the SCRIPT exiting 0 is not the whole invariant:
# a setup-python download failure reddens the job just as effectively as a hit would, and an
# advisory red still joins the combined status the merge gate reads (ersatztv#598). Scope,
# stated rather than implied: this covers the two steps that exist to run the check. A failed
@@ -186,7 +247,10 @@ jobs:
continue-on-error: true
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=100 origin "$base_ref" || true
if ! git fetch --no-tags origin "$base_ref"; then
echo "::error::git fetch of origin/${base_ref} failed, so this job cannot compute the changed-file set it derives its work from. That is a broken job, not an empty change set (ersatztv#746). Check the base branch still exists and that the runner can reach the repository."
exit 1
fi
python3 scripts/check-doc-narrative.py --diff "origin/${base_ref}"
# BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision-
@@ -200,10 +264,13 @@ jobs:
name: decisions lifecycle
runs-on: small
if: github.event_name == 'pull_request'
env:
CI_JOB_ROLE: guard
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
@@ -212,7 +279,10 @@ jobs:
- name: Validate decision lifecycle
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=200 origin "$base_ref" || true
if ! git fetch --no-tags origin "$base_ref"; then
echo "::error::git fetch of origin/${base_ref} failed, so this job cannot compute the changed-file set it derives its work from. That is a broken job, not an empty change set (ersatztv#746). Check the base branch still exists and that the runner can reach the repository."
exit 1
fi
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
- name: Active catalog in sync
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
@@ -236,21 +306,29 @@ 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
if: github.event_name == 'pull_request'
env:
CI_JOB_ROLE: guard
steps:
- name: Checkout
# Full history: prove-fix.sh reverts each commit against its PARENT, so a shallow
# clone would leave it unable to resolve `<sha>^` and it would refuse every commit.
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
@@ -362,9 +440,13 @@ jobs:
name: Script lint and tests (ruff + pytest)
runs-on: small
if: github.event_name == 'pull_request'
env:
CI_JOB_ROLE: guard
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
@@ -458,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
+14
View File
@@ -45,12 +45,26 @@ concurrency:
group: ersatztv-renovate
cancel-in-progress: false
# Explicit token scope (ersatztv#748) so the owner-level Actions default can move to Restricted
# (server-management#714). Declaring `permissions:` is EXHAUSTIVE, not additive: a unit omitted here
# 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".
# This workflow has no checkout step and never uses the injected GITEA_TOKEN for anything. Renovate's
# own branch/PR writes go through RENOVATE_TOKEN, a dedicated bot PAT the Actions default does not
# govern, and its container image comes from Docker Hub. Read-only is declared to STATE that the
# injected token is unused, not because any step needs it.
permissions:
code: read
jobs:
renovate:
name: Renovate
runs-on: ubuntu-latest
container:
image: renovate/renovate:43
env:
CI_JOB_ROLE: none
steps:
- name: Run Renovate
env:
File diff suppressed because it is too large Load Diff
+11
View File
@@ -95,3 +95,14 @@ web/playwright-report/
# plaintext Gitea credential and absolute /Users paths, so it is neither portable nor safe to
# commit. See ersatztv#711 for the related merge-gate gap.
.codex/
# serena's per-project state, written by `activate_project` (ersatztv#799): project.yml,
# project.local.yml, a language-server cache, and memories/.
#
# This deliberately rejects serena's own versioning model. Its nested .serena/.gitignore excludes
# only `cache` and `project.local.yml`, and project.local.yml says project.yml "is intended to be
# versioned" — but activation here is per DIRECTORY, and every worktree generates a project.yml
# whose project_name is that worktree's folder (e.g. `781-tooling`). A committed copy would name
# the wrong project in every checkout but the one that produced it. memories/ is ignored with it:
# it is serena's own written notes, and this repo's durable knowledge lives in docs/ instead.
.serena/
+17 -4
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|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.
- `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.
@@ -97,17 +97,30 @@ when finishing a task that closes an issue.
## Project Boundaries
**ersatztv OWNS**: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, and the **`ersatztv` skill** — whose canonical copy is `.claude/skills/ersatztv/SKILL.md` **here**; `~/server-management/.claude/skills/ersatztv` is a symlink to it (ersatztv#617). Edit it in this repo; never fork a second copy.
**ersatztv OWNS***developing the fork*: the ErsatzTV fork code (C#/.NET), the `/api/v1` REST
surface, M3U/XMLTV generation, the `ErsatzTV.Mcp` server, CI and releases, and the **`ersatztv`
skill** — whose canonical copy is `.claude/skills/ersatztv/SKILL.md` **here**. Both
`~/server-management/.claude/skills/ersatztv` and `~/media-management/.claude/skills/ersatztv` are
symlinks to it (ersatztv#617, #755). Edit it in this repo; never fork a second copy.
**The split that is easy to get wrong** (ersatztv#755, `process.ersatztv-owns-code-not-operations`):
channel/collection/schedule *code* is owned here; **channel OPERATIONS against the running instance
are not**. Creating and editing channels, lineups, collections, schedules, playouts, logos and
overlays on the live ErsatzTV belong to `media-management`. Driving prod from here is in scope only
as *verification of a change this repo is shipping* (live-E2E, a release smoke test) — not as
day-to-day channel work.
**ersatztv does NOT own**:
- Channel/collection/schedule/playout **operations** against a live instance → media-management
- Docker compose configs → server-management (`~/downloadswarm/stacks/ersatztv/`)
- NFS mounts, Ansible, DNS, networking → server-management
- Content sourcing (yt-dlp downloads, Sonarr/Radarr libraries) → media-management (planned)
- Content sourcing (yt-dlp downloads, Sonarr/Radarr libraries) → media-management
- Jellyfin skill → server-management. `.claude/skills/jellyfin` here is a **relative symlink** to `~/server-management/.claude/skills/jellyfin` (ersatztv#617 — it had silently become a stale divergent copy). It therefore resolves only in a checkout at `~/ersatztv`, not inside a git worktree; that is inherent to the cross-repo symlink pattern server-management already uses (`beets`, `radarr`, `sonarr`, …).
**For infrastructure changes** (Docker, NFS, ports, Authelia): open an issue in `timothy/server-management`.
**For content/media sourcing questions** (what goes into channels, yt-dlp pipelines): open an issue in `timothy/media-management` once it exists; for now, `timothy/server-management`.
**For content/media sourcing questions and channel operations** (what goes into channels, yt-dlp
pipelines, editing a live channel): open an issue in `timothy/media-management`.
**For plan/audit reviews**: open `~/adversarial-reviewer` before significant architecture changes.
+3 -3
View File
@@ -25,9 +25,9 @@
<PackageVersion Include="LanguageExt.Core" Version="4.4.9" />
<PackageVersion Include="LanguageExt.Transformers" Version="4.4.8" />
<PackageVersion Include="Lennox.NvEncSharp" Version="2.0.0" />
<PackageVersion Include="Lucene.Net" Version="4.8.0-beta00018" />
<PackageVersion Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00018" />
<PackageVersion Include="Lucene.Net.QueryParser" Version="4.8.0-beta00018" />
<PackageVersion Include="Lucene.Net" Version="4.8.0-beta00017" />
<PackageVersion Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" />
<PackageVersion Include="Lucene.Net.QueryParser" Version="4.8.0-beta00017" />
<PackageVersion Include="MediatR" Version="[12.5.0]" />
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.129" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
@@ -0,0 +1,44 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Streaming.Graphics;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// #732: the On Now / Next overlay is a default rather than an opt-in, so every newly created channel
/// gets the built-in element attached.
/// </summary>
/// <remarks>
/// This lives in one place because there is more than one channel-creation path and they diverged
/// once already: <c>CreateChannelHandler</c> had it and <c>CreateChannelFromLineupHandler</c> -- the
/// SPA's primary "Add Channel" flow, and the one Auto-Tune bulk-creates through -- did not. Any new
/// site that persists a <c>Channel</c> must call this. The third site, <c>DbInitializer</c>'s default
/// channel, needs no call: it runs before <c>AttachOnNowNextByDefault</c> in the same startup, so the
/// backfill covers it.
/// </remarks>
public static class ChannelGraphicsDefaults
{
public static async Task Attach(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
{
// 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 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;
}
Option<int> maybeElementId =
await GraphicsElementSeeder.GetBuiltInElementId(dbContext, cancellationToken);
foreach (int elementId in maybeElementId)
{
// Add rather than assign: a future create path that carries graphics ids would otherwise
// be silently discarded here.
channel.ChannelGraphicsElements ??= [];
channel.ChannelGraphicsElements.Add(new ChannelGraphicsElement { GraphicsElementId = elementId });
}
}
}
@@ -85,6 +85,7 @@ public class CreateChannelFromLineupHandler(
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
await ChannelGraphicsDefaults.Attach(dbContext, prepared.Channel, cancellationToken);
dbContext.Channels.Add(prepared.Channel);
if (prepared.Playlist is not null)
{
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Streaming.Graphics;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Channels.ChannelValidations;
@@ -35,7 +36,8 @@ public class CreateChannelHandler(
Right: async logoPath =>
{
ApplyResolvedLogo(request, channel, logoPath);
return Right<BaseError, CreateChannelResult>(await PersistChannel(dbContext, channel));
return Right<BaseError, CreateChannelResult>(
await PersistChannel(dbContext, channel, cancellationToken));
},
Left: e => Task.FromResult(Left<BaseError, CreateChannelResult>(e)));
},
@@ -75,8 +77,12 @@ public class CreateChannelHandler(
}
}
private async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
private async Task<CreateChannelResult> PersistChannel(
TvContext dbContext,
Channel channel,
CancellationToken cancellationToken)
{
await ChannelGraphicsDefaults.Attach(dbContext, channel, cancellationToken);
await dbContext.Channels.AddAsync(channel);
await dbContext.SaveChangesAsync();
searchTargets.SearchTargetsChanged();
@@ -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,
@@ -35,4 +35,6 @@ public record CreateFFmpegProfile(
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo,
bool QsvPreferNativeDecoder) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
bool QsvPreferNativeDecoder,
double? ReadRate,
double? ReadRateCatchup) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
@@ -50,8 +50,12 @@ public class CreateFFmpegProfileHandler :
private static Validation<BaseError, FFmpegProfile> Validate(
CreateFFmpegProfile request,
int resolutionId) =>
(ValidateName(request), ValidateThreadCount(request))
.Apply((name, threadCount) =>
(ValidateName(request),
ValidateThreadCount(request),
FFmpegProfileBounds.ValidateQsvExtraHardwareFrames(request.QsvExtraHardwareFrames, stored: null),
FFmpegProfileBounds.ValidateReadRate(request.ReadRate),
FFmpegProfileBounds.ValidateReadRateCatchup(request.ReadRateCatchup, request.ReadRate))
.Apply((name, threadCount, _, _, _) =>
{
var hwAccel = request.NormalizeVideo
? request.HardwareAcceleration
@@ -68,11 +72,9 @@ public class CreateFFmpegProfileHandler :
HardwareAcceleration = hwAccel,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
// store what the pipeline will actually use, never a pool size FFmpegState would
// floor away at render time (ersatztv#529)
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames is { } frames
? Math.Max(frames, FFmpegState.MinimumQsvExtraHardwareFrames)
: null,
// stored exactly as submitted: an out-of-range value was already rejected with a
// 422 naming the bound, so there is nothing left to silently rewrite (ersatztv#735)
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
ResolutionId = resolutionId,
ScalingBehavior = request.ScalingBehavior,
@@ -111,7 +113,9 @@ public class CreateFFmpegProfileHandler :
NormalizeFramerate = request.NormalizeFramerate,
NormalizeColors = request.NormalizeColors,
DeinterlaceVideo = request.DeinterlaceVideo,
QsvPreferNativeDecoder = request.QsvPreferNativeDecoder
QsvPreferNativeDecoder = request.QsvPreferNativeDecoder,
ReadRate = request.ReadRate,
ReadRateCatchup = request.ReadRateCatchup
};
});
@@ -36,4 +36,6 @@ public record UpdateFFmpegProfile(
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo,
bool QsvPreferNativeDecoder) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
bool QsvPreferNativeDecoder,
double? ReadRate,
double? ReadRateCatchup) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
@@ -55,11 +55,10 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
p.VaapiDisplay = update.VaapiDisplay;
p.VaapiDriver = update.VaapiDriver;
p.VaapiDevice = update.VaapiDevice;
// store what the pipeline will actually use, so a profile doesn't keep displaying a pool
// size that FFmpegState floors away at render time (ersatztv#529)
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames is { } frames
? Math.Max(frames, FFmpegState.MinimumQsvExtraHardwareFrames)
: null;
// stored exactly as submitted: an out-of-range NEW value was already rejected with a 422
// naming the bound. an unchanged value that predates that validation is written back as-is
// rather than rewritten, and FFmpegState floors it at render time (ersatztv#735)
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
p.ResolutionId = update.ResolutionId;
p.ScalingBehavior = update.ScalingBehavior;
p.PadMode = update.PadMode;
@@ -108,6 +107,8 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
p.NormalizeColors = update.NormalizeColors;
p.DeinterlaceVideo = update.DeinterlaceVideo;
p.QsvPreferNativeDecoder = update.QsvPreferNativeDecoder;
p.ReadRate = update.ReadRate;
p.ReadRateCatchup = update.ReadRateCatchup;
// don't save invalid preset
ICollection<string> presets = FFmpegLibraryHelper.PresetsForFFmpegProfile(
@@ -131,8 +132,14 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
TvContext dbContext,
UpdateFFmpegProfile request,
FFmpegProfile profile) =>
(await ValidateName(dbContext, request), ValidateThreadCount(request))
.Apply((_, _) => profile);
(await ValidateName(dbContext, request),
ValidateThreadCount(request),
FFmpegProfileBounds.ValidateQsvExtraHardwareFrames(
request.QsvExtraHardwareFrames,
profile.QsvExtraHardwareFrames),
FFmpegProfileBounds.ValidateReadRate(request.ReadRate),
FFmpegProfileBounds.ValidateReadRateCatchup(request.ReadRateCatchup, request.ReadRate))
.Apply((_, _, _, _, _) => profile);
private static Task<Option<FFmpegProfile>> FFmpegProfileMustExist(
TvContext dbContext,
@@ -0,0 +1,79 @@
using ErsatzTV.Core;
using ErsatzTV.FFmpeg;
namespace ErsatzTV.Application.FFmpegProfiles;
/// <summary>
/// Write-path bounds for the consequential numeric FFmpeg profile fields.
/// A submitted value outside its documented range is REJECTED, naming the bound, rather than
/// accepted and silently rewritten to something the caller never sent (ersatztv#735). The
/// render-time clamps in <see cref="FFmpegState" /> stay as they are: they cover rows that
/// predate this validation or were written out of band, which is what keeps the fix
/// migration-free.
/// </summary>
internal static class FFmpegProfileBounds
{
internal static Validation<BaseError, Unit> ValidateQsvExtraHardwareFrames(int? requested, int? stored)
{
// a row stored before this validation existed may hold anything, and the SPA sends the whole
// profile back on every edit — so rejecting an UNCHANGED legacy value would make an old
// profile uneditable over a field the operator never touched (and cannot even see unless
// hardware acceleration is QSV). only a NEWLY submitted out-of-range value is rejected;
// FFmpegState.QsvExtraHardwareFrames still floors the legacy one at render time
if (requested is null || requested == stored)
{
return Success<BaseError, Unit>(Unit.Default);
}
return requested < FFmpegState.MinimumQsvExtraHardwareFrames
? BaseError.New(
$"QSV extra hardware frames must be at least {FFmpegState.MinimumQsvExtraHardwareFrames}; " +
$"{requested} leaves the QSV upload pool with too little headroom and the transcode writes nothing at all")
: Success<BaseError, Unit>(Unit.Default);
}
internal static Validation<BaseError, Unit> ValidateReadRate(double? requested)
{
if (requested is null)
{
return Success<BaseError, Unit>(Unit.Default);
}
return requested is < FFmpegState.MinimumReadRate or > FFmpegState.MaximumReadRate
? BaseError.New(
$"Read rate must be between {Format(FFmpegState.MinimumReadRate)} and {Format(FFmpegState.MaximumReadRate)}; " +
"below realtime the channel stalls, and above this the input is no longer meaningfully paced")
: Success<BaseError, Unit>(Unit.Default);
}
internal static Validation<BaseError, Unit> ValidateReadRateCatchup(double? requested, double? requestedReadRate)
{
if (requested is null)
{
return Success<BaseError, Unit>(Unit.Default);
}
if (requested is < FFmpegState.MinimumReadRateCatchup or > FFmpegState.MaximumReadRateCatchup)
{
return BaseError.New(
$"Read rate catchup must be between {Format(FFmpegState.MinimumReadRateCatchup)} and " +
$"{Format(FFmpegState.MaximumReadRateCatchup)}");
}
// catchup is the rate a LAGGING input may read at until it is level again, so a value at or
// below the base rate cannot let it recover: EQUAL is rejected too, because a catchup with
// zero headroom is functionally no catchup while still reading as configured. compared
// against the transcode default rather than the stream-copy one because that is the higher
// of the two: a value that clears it clears both, without this check having to know the
// profile's video format
double effectiveReadRate = requestedReadRate ?? FFmpegState.DefaultReadRate;
return requested <= effectiveReadRate
? BaseError.New(
$"Read rate catchup ({Format(requested.Value)}) must be greater than the read rate " +
$"({Format(effectiveReadRate)}); a lagging input cannot catch up at a rate it is already paced at")
: Success<BaseError, Unit>(Unit.Default);
}
private static string Format(double value) =>
value.ToString("0.0####", System.Globalization.CultureInfo.InvariantCulture);
}
@@ -36,4 +36,6 @@ public record FFmpegProfileViewModel(
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo,
bool QsvPreferNativeDecoder);
bool QsvPreferNativeDecoder,
double? ReadRate,
double? ReadRateCatchup);
@@ -38,7 +38,9 @@ internal static class Mapper
profile.NormalizeFramerate,
profile.NormalizeColors,
profile.DeinterlaceVideo == true,
profile.QsvPreferNativeDecoder != false);
profile.QsvPreferNativeDecoder != false,
profile.ReadRate,
profile.ReadRateCatchup);
internal static FFmpegProfileResponseModel ProjectToResponseModel(FFmpegProfile ffmpegProfile) =>
new(
@@ -82,5 +84,7 @@ internal static class Mapper
ffmpegProfile.NormalizeFramerate,
ffmpegProfile.NormalizeColors,
ffmpegProfile.DeinterlaceVideo == true,
ffmpegProfile.QsvPreferNativeDecoder != false);
ffmpegProfile.QsvPreferNativeDecoder != false,
ffmpegProfile.ReadRate,
ffmpegProfile.ReadRateCatchup);
}
@@ -1,4 +1,5 @@
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Filler.Mapper;
@@ -12,9 +13,13 @@ public class GetPagedFillerPresetsHandler(IDbContextFactory<TvContext> dbContext
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.FillerPresets.CountAsync(cancellationToken);
List<FillerPresetViewModel> page = await dbContext.FillerPresets
.AsNoTracking()
// no filter today, but count and page are still derived from ONE query so that adding one
// cannot leave the count behind (api.paged-count-matches-page-query)
IQueryable<FillerPreset> query = dbContext.FillerPresets.AsNoTracking();
int count = await query.CountAsync(cancellationToken);
List<FillerPresetViewModel> page = await query
.OrderBy(f => f.Name)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
@@ -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,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@@ -13,8 +13,6 @@ public class GetPagedCollectionsHandler(IDbContextFactory<TvContext> dbContextFa
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.Collections.CountAsync(cancellationToken);
IQueryable<Collection> query = dbContext.Collections.AsNoTracking();
if (!string.IsNullOrWhiteSpace(request.Query))
@@ -22,6 +20,9 @@ public class GetPagedCollectionsHandler(IDbContextFactory<TvContext> dbContextFa
query = query.Where(c => EF.Functions.Like(c.Name, $"%{request.Query}%"));
}
// count the SAME query the page is taken from, so the two cannot drift (issues #690, #758)
int count = await query.CountAsync(cancellationToken);
List<MediaCollectionViewModel> page = await query
.OrderBy(c => c.Name)
.Skip(request.PageNum * request.PageSize)
@@ -13,9 +13,6 @@ public class GetPagedMultiCollectionsHandler(IDbContextFactory<TvContext> dbCont
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.MultiCollections
.CountAsync(mc => mc.OwnedByChannelId == null, cancellationToken);
IQueryable<MultiCollection> query = dbContext.MultiCollections
.AsNoTracking()
.Where(mc => mc.OwnedByChannelId == null);
@@ -25,6 +22,9 @@ public class GetPagedMultiCollectionsHandler(IDbContextFactory<TvContext> dbCont
query = query.Where(mc => EF.Functions.Like(mc.Name, $"%{request.Query}%"));
}
// count the SAME query the page is taken from, so the two cannot drift (issues #690, #758)
int count = await query.CountAsync(cancellationToken);
List<MultiCollectionViewModel> page = await query
.OrderBy(mc => mc.Name)
.Skip(request.PageNum * request.PageSize)
@@ -13,18 +13,21 @@ public class GetPagedRerunCollectionsHandler(IDbContextFactory<TvContext> dbCont
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.RerunCollections.CountAsync(cancellationToken);
IQueryable<RerunCollection> query = dbContext.RerunCollections.AsNoTracking().IncludeSelectionDetails();
IQueryable<RerunCollection> query = dbContext.RerunCollections.AsNoTracking();
if (!string.IsNullOrWhiteSpace(request.Query))
{
query = query.Where(rc => EF.Functions.Like(rc.Name, $"%{request.Query}%"));
}
// count the SAME query the page is taken from, so the two cannot drift (issues #690, #758).
// The includes belong to the page chain only — a COUNT does not materialize the graph.
int count = await query.CountAsync(cancellationToken);
// EF applies the includes to the paged subquery, so the selection graph is loaded for at most
// PageSize rows — the per-request cost is bounded by the page, not by the table (issue #671).
List<RerunCollectionViewModel> page = await query
.IncludeSelectionDetails()
.OrderBy(rc => rc.Name)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
@@ -13,9 +13,6 @@ public class GetPagedSmartCollectionsHandler(IDbContextFactory<TvContext> dbCont
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.SmartCollections
.CountAsync(sc => sc.OwnedByChannelId == null, cancellationToken);
IQueryable<SmartCollection> query = dbContext.SmartCollections
.AsNoTracking()
.Where(sc => sc.OwnedByChannelId == null);
@@ -25,6 +22,9 @@ public class GetPagedSmartCollectionsHandler(IDbContextFactory<TvContext> dbCont
query = query.Where(sc => EF.Functions.Like(sc.Name, $"%{request.Query}%"));
}
// count the SAME query the page is taken from, so the two cannot drift (issues #690, #758)
int count = await query.CountAsync(cancellationToken);
List<SmartCollectionViewModel> page = await query
.OrderBy(s => s.Name)
.Skip(request.PageNum * request.PageSize)
@@ -1,4 +1,5 @@
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@@ -12,9 +13,13 @@ public class GetPagedTraktListsHandler(IDbContextFactory<TvContext> dbContextFac
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.TraktLists.CountAsync(cancellationToken);
List<TraktListViewModel> page = await dbContext.TraktLists
.AsNoTracking()
// no filter today, but count and page are still derived from ONE query so that adding one
// cannot leave the count behind (api.paged-count-matches-page-query)
IQueryable<TraktList> query = dbContext.TraktLists.AsNoTracking();
int count = await query.CountAsync(cancellationToken);
List<TraktListViewModel> page = await query
.OrderBy(l => l.Name)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
@@ -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();
+10 -3
View File
@@ -1,5 +1,6 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.Playouts;
@@ -40,9 +41,15 @@ internal static class Mapper
programScheduleAlternate.Id,
programScheduleAlternate.Index,
programScheduleAlternate.ProgramScheduleId,
programScheduleAlternate.DaysOfWeek,
programScheduleAlternate.DaysOfMonth,
programScheduleAlternate.MonthsOfYear,
// ersatztv#823: these three are NULLABLE columns and a legacy row can hold NULL. Substitute the
// SAME unrestricted defaults AlternateScheduleSelector.GetScheduleForDate reads, so the DTO the
// SPA renders agrees with what actually gets scheduled -- web/src/screens/playoutTemplateCalendar.ts
// `appliesToDate` is an exact port of that method, and it would otherwise both mispreview and
// throw (`[...template.daysOfMonth]` on a null is a TypeError). Never assigned back onto the
// entity (`media.nullable-primitive-collection-mutation`).
programScheduleAlternate.DaysOfWeek ?? AlternateScheduleSelector.AllDaysOfWeek(),
programScheduleAlternate.DaysOfMonth ?? AlternateScheduleSelector.AllDaysOfMonth(),
programScheduleAlternate.MonthsOfYear ?? AlternateScheduleSelector.AllMonthsOfYear(),
programScheduleAlternate.LimitToDateRange,
programScheduleAlternate.StartMonth,
programScheduleAlternate.StartDay,
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Playouts.Mapper;
@@ -13,13 +13,8 @@ public class GetPagedPlayoutsHandler(IDbContextFactory<TvContext> dbContextFacto
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.Playouts.CountAsync(cancellationToken);
IQueryable<Playout> query = dbContext.Playouts
.AsNoTracking()
.Include(p => p.Channel)
.Include(p => p.ProgramSchedule)
.Include(p => p.BuildStatus)
.Filter(p => p.Channel != null);
if (!string.IsNullOrWhiteSpace(request.Query))
@@ -27,7 +22,15 @@ public class GetPagedPlayoutsHandler(IDbContextFactory<TvContext> dbContextFacto
query = query.Where(p => EF.Functions.Like(p.Channel.Name, $"%{request.Query}%"));
}
// count the SAME query the page is taken from, so the two cannot drift (issues #690, #758).
// This is also what makes the `Channel != null` filter count, which the old unfiltered
// CountAsync over the whole DbSet did not.
int count = await query.CountAsync(cancellationToken);
List<PlayoutNameViewModel> page = await query
.Include(p => p.Channel)
.Include(p => p.ProgramSchedule)
.Include(p => p.BuildStatus)
.OrderBy(p => p.Channel.SortNumber)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.ProgramSchedules.Mapper;
@@ -13,8 +13,6 @@ public class GetPagedProgramSchedulesHandler(IDbContextFactory<TvContext> dbCont
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.ProgramSchedules.CountAsync(cancellationToken);
IQueryable<ProgramSchedule> query = dbContext.ProgramSchedules.AsNoTracking();
if (!string.IsNullOrWhiteSpace(request.Query))
@@ -22,6 +20,9 @@ public class GetPagedProgramSchedulesHandler(IDbContextFactory<TvContext> dbCont
query = query.Where(ps => EF.Functions.Like(ps.Name, $"%{request.Query}%"));
}
// count the SAME query the page is taken from, so the two cannot drift (issues #690, #758)
int count = await query.CountAsync(cancellationToken);
List<ProgramScheduleViewModel> page = await query
.OrderBy(ps => ps.Name)
.Skip(request.PageNum * request.PageSize)
@@ -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,
+11 -4
View File
@@ -1,6 +1,7 @@
using ErsatzTV.Application.Tree;
using ErsatzTV.Application.Tree;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.Scheduling;
@@ -190,9 +191,15 @@ internal static class Mapper
ProjectToViewModel(playoutTemplate.Template),
ProjectToViewModel(playoutTemplate.DecoTemplate),
playoutTemplate.Index,
playoutTemplate.DaysOfWeek,
playoutTemplate.DaysOfMonth,
playoutTemplate.MonthsOfYear,
// ersatztv#823: these three are NULLABLE columns and a legacy row can hold NULL. Substitute the
// SAME unrestricted defaults AlternateScheduleSelector.GetScheduleForDate reads, so the DTO the
// SPA renders agrees with what actually gets scheduled -- web/src/screens/playoutTemplateCalendar.ts
// `appliesToDate` is an exact port of that method, and it would otherwise both mispreview and
// throw (`[...template.daysOfMonth]` on a null is a TypeError). Never assigned back onto the
// entity (`media.nullable-primitive-collection-mutation`).
playoutTemplate.DaysOfWeek ?? AlternateScheduleSelector.AllDaysOfWeek(),
playoutTemplate.DaysOfMonth ?? AlternateScheduleSelector.AllDaysOfMonth(),
playoutTemplate.MonthsOfYear ?? AlternateScheduleSelector.AllMonthsOfYear(),
playoutTemplate.LimitToDateRange,
playoutTemplate.StartMonth,
playoutTemplate.StartDay,
@@ -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)";
}
@@ -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()
@@ -1,3 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
using NUnit.Framework;
@@ -864,4 +865,241 @@ public static class AlternateScheduleSelectorTests
result.IsNone.ShouldBeFalse();
}
}
/// <summary>
/// ersatztv#823. <c>DaysOfWeek</c>, <c>DaysOfMonth</c> and <c>MonthsOfYear</c> on
/// <see cref="PlayoutTemplate" /> and <see cref="ProgramScheduleAlternate" /> are six
/// single-column primitive collections whose columns are <c>nullable: true</c> on both providers.
/// A NULL column materializes as CLR <c>null</c> — EF does not invoke the value converter for a
/// NULL at all — so unguarded, each <c>.Contains</c> in
/// <see cref="AlternateScheduleSelector.GetScheduleForDate{T}" /> throws
/// <see cref="NullReferenceException" />. These tests are RED without the read-site guard.
/// <para>
/// A null reads as UNRESTRICTED (the <c>All*()</c> sets), not as empty. The deciding case is
/// SQLite's <c>20240113140741_Add_PlayoutTemplate_DaysOfMonth</c>, which adds the column
/// <c>nullable: true</c> with NO default: a row inserted before it had no day-of-month
/// restriction, so reading its NULL as empty would INVERT its meaning and silently stop the
/// template applying. That is the one NULL reachable without any code writing one.
/// </para>
/// <para>
/// Reachability itself is pinned by
/// <c>ErsatzTV.Tests.Integration.SchedulingCollectionColumnNullTests</c> against a real
/// <c>TvContext</c>; these tests pin what the selector does once the null is there.
/// </para>
/// </summary>
[TestFixture]
public class GetScheduleForDate_NullCollections
{
private static readonly TimeSpan Offset = TimeSpan.FromHours(-5);
// A Wednesday in March, so no All*() member is coincidentally excluded — and deliberately the
// 20th rather than the 6th. With a day <= 12 a CROSS-WIRED substitution survives the whole
// fixture: `DaysOfMonth ?? AllMonthsOfYear()` hands back 1..12, which still contains day 6, so
// every assertion here passes while the guard substitutes the wrong set. Day 20 is outside 1..12
// and kills it.
private static readonly DateTimeOffset AnyDate = new(2024, 3, 20, 0, 0, 0, Offset);
private static PlayoutTemplate Unrestricted() =>
new()
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear()
};
private static Option<PlayoutTemplate> Select(params PlayoutTemplate[] templates) =>
AlternateScheduleSelector.GetScheduleForDate(templates.ToList(), AnyDate);
[Test]
public void Null_DaysOfWeek_Reads_As_Unrestricted()
{
PlayoutTemplate template = Unrestricted();
template.DaysOfWeek = null!;
Select(template).IsSome.ShouldBeTrue(
"a NULL DaysOfWeek means no weekday restriction was recorded, so the template still applies");
}
[Test]
public void Null_DaysOfMonth_Reads_As_Unrestricted()
{
PlayoutTemplate template = Unrestricted();
template.DaysOfMonth = null!;
Select(template).IsSome.ShouldBeTrue();
}
[Test]
public void Null_MonthsOfYear_Reads_As_Unrestricted()
{
PlayoutTemplate template = Unrestricted();
template.MonthsOfYear = null!;
Select(template).IsSome.ShouldBeTrue();
}
[Test]
public void All_Three_Null_On_ProgramScheduleAlternate_Reads_As_Unrestricted()
{
var alternate = new ProgramScheduleAlternate
{
DaysOfWeek = null!,
DaysOfMonth = null!,
MonthsOfYear = null!
};
AlternateScheduleSelector.GetScheduleForDate(
new List<ProgramScheduleAlternate> { alternate },
AnyDate)
.IsSome.ShouldBeTrue();
}
/// <summary>
/// THE DISCRIMINATING CONTROL. Every test above sets a NULL and expects the item to be selected,
/// so all of them pass equally under "NULL means unrestricted" and under the much broader
/// "any NULL makes this item match unconditionally" — a refactor that short-circuits the whole
/// date check when any dimension is null keeps them green. Here the nulled dimension is paired
/// with a RESTRICTIVE non-null one that the date fails, so only the narrow reading passes.
/// </summary>
[Test]
public void A_Null_Dimension_Does_Not_Relax_The_Other_Dimensions()
{
PlayoutTemplate template = Unrestricted();
template.DaysOfWeek = null!;
// AnyDate is in MARCH; restrict to January only.
template.MonthsOfYear = [1];
Select(template).IsNone.ShouldBeTrue(
"a NULL DaysOfWeek relaxes ONLY the weekday dimension — the January restriction still "
+ "excludes a March date");
}
/// <summary>
/// One arrangement is not enough: with only the <c>DaysOfWeek</c> case above, a PER-DIMENSION
/// mutant survives the whole fixture — e.g. <c>if (item.MonthsOfYear is null) return item;</c>
/// placed ahead of the checks is never reached by that test, because its <c>MonthsOfYear</c> is
/// non-null. So each of the three dimensions is nulled in turn against a restriction on a
/// DIFFERENT dimension.
/// </summary>
[Test]
public void A_Null_MonthsOfYear_Does_Not_Relax_The_Other_Dimensions()
{
PlayoutTemplate template = Unrestricted();
template.MonthsOfYear = null!;
// AnyDate is a WEDNESDAY; restrict to Monday only.
template.DaysOfWeek = [DayOfWeek.Monday];
Select(template).IsNone.ShouldBeTrue(
"a NULL MonthsOfYear relaxes ONLY the month dimension — the Monday restriction still "
+ "excludes a Wednesday");
}
/// <summary>
/// The third of the per-dimension controls — see
/// <see cref="A_Null_MonthsOfYear_Does_Not_Relax_The_Other_Dimensions" /> for why one
/// arrangement is not enough. Here the nulled dimension is <c>DaysOfMonth</c> and the
/// restriction that must still bite is on <c>MonthsOfYear</c>.
/// </summary>
[Test]
public void A_Null_DaysOfMonth_Does_Not_Relax_The_Other_Dimensions()
{
PlayoutTemplate template = Unrestricted();
template.DaysOfMonth = null!;
// AnyDate is in MARCH; restrict to January only.
template.MonthsOfYear = [1];
Select(template).IsNone.ShouldBeTrue(
"a NULL DaysOfMonth relaxes ONLY the day-of-month dimension — the January restriction "
+ "still excludes a March date");
}
/// <summary>
/// A null must not be confused with an explicitly EMPTY collection. Empty is a legal, reachable
/// state meaning "matches no day", and it keeps that meaning — which is exactly why a NULL
/// cannot be normalized to it.
/// </summary>
[Test]
public void An_Explicitly_Empty_Collection_Still_Matches_Nothing()
{
PlayoutTemplate template = Unrestricted();
template.DaysOfWeek = [];
Select(template).IsNone.ShouldBeTrue(
"an empty DaysOfWeek is a recorded restriction of NO days, unlike a NULL");
}
/// <summary>
/// The guard resolves PER ITEM: a null on the first item must not decide the second. Making the
/// nulled item genuinely non-matching is what measures that — with an unrestricted nulled item
/// at index 0 it simply wins on ordering and the second item is never evaluated, so the
/// invariant would go unmeasured while the test passed.
/// </summary>
[Test]
public void A_Null_On_One_Item_Does_Not_Decide_A_Later_Item()
{
PlayoutTemplate nulled = Unrestricted();
nulled.DaysOfWeek = null!;
nulled.MonthsOfYear = [1]; // AnyDate is in March, so this item must NOT match
nulled.Index = 0;
PlayoutTemplate second = Unrestricted();
second.Index = 1;
foreach (PlayoutTemplate selected in Select(nulled, second))
{
selected.ShouldBeSameAs(second);
return;
}
Assert.Fail("the loop stopped at the null-collection item instead of continuing to the next");
}
/// <summary>
/// ...and when the nulled item IS unrestricted it legitimately wins on ordering. Paired with the
/// test above so "index 0 wins" and "the loop continues past a non-matching null item" are
/// separately pinned.
/// </summary>
[Test]
public void An_Unrestricted_Null_Item_Wins_On_Index_Order()
{
PlayoutTemplate nulled = Unrestricted();
nulled.DaysOfWeek = null!;
nulled.Index = 0;
PlayoutTemplate second = Unrestricted();
second.Index = 1;
foreach (PlayoutTemplate selected in Select(nulled, second))
{
selected.ShouldBeSameAs(nulled);
return;
}
Assert.Fail("the null-collection item was skipped instead of read as unrestricted");
}
/// <summary>
/// The read-site guard must not be written BACK onto the item. These are single-column
/// primitive collections, so assigning the guard would flip a tracked entity to
/// <c>Modified</c> and the next <c>SaveChanges</c> would persist the substituted collection
/// over the NULL — the mechanism recorded as <c>media.nullable-primitive-collection-mutation</c>.
/// </summary>
[Test]
public void Guard_Must_Not_Be_Written_Back_Onto_The_Item()
{
PlayoutTemplate template = Unrestricted();
template.DaysOfWeek = null!;
template.DaysOfMonth = null!;
template.MonthsOfYear = null!;
Select(template);
template.DaysOfWeek.ShouldBeNull();
template.DaysOfMonth.ShouldBeNull();
template.MonthsOfYear.ShouldBeNull();
}
}
}
@@ -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);
}
@@ -37,4 +37,6 @@ public record FFmpegFullProfileResponseModel(
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo,
bool QsvPreferNativeDecoder);
bool QsvPreferNativeDecoder,
double? ReadRate,
double? ReadRateCatchup);
+3
View File
@@ -26,6 +26,9 @@ public class ConfigElementKey
public static ConfigElementKey ChannelTemplatesDefaultTemplateId => new("channel_templates.default_template_id");
public static ConfigElementKey WatermarkChannelBugSeeded => new("watermark.channel_bug_seeded");
public static ConfigElementKey GraphicsOnNowNextSeeded => new("graphics.on_now_next_seeded");
public static ConfigElementKey GraphicsOnNowNextDefaultAttached =>
new("graphics.on_now_next_default_attached");
public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds");
public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
public static ConfigElementKey FFmpegInitialSegmentCount => new("ffmpeg.segmenter.initial_segment_count");
+2
View File
@@ -14,6 +14,8 @@ public record FFmpegProfile
public VaapiDriver VaapiDriver { get; set; }
public string VaapiDevice { get; set; }
public int? QsvExtraHardwareFrames { get; set; }
public double? ReadRate { get; set; }
public double? ReadRateCatchup { get; set; }
public bool? QsvPreferNativeDecoder { get; set; }
public int ResolutionId { get; set; }
public Resolution Resolution { get; set; }
@@ -610,7 +610,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
false,
GetTonemapAlgorithm(playbackSettings),
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
channel.FFmpegProfile.QsvPreferNativeDecoder != false);
channel.FFmpegProfile.QsvPreferNativeDecoder != false,
Optional(channel.FFmpegProfile.ReadRate),
Optional(channel.FFmpegProfile.ReadRateCatchup));
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
@@ -827,7 +829,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
false,
false,
GetTonemapAlgorithm(playbackSettings),
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
MaybeReadRate: Optional(channel.FFmpegProfile.ReadRate),
MaybeReadRateCatchup: Optional(channel.FFmpegProfile.ReadRateCatchup));
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video);
@@ -968,7 +972,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
false,
false,
GetTonemapAlgorithm(playbackSettings),
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
MaybeReadRate: Optional(channel.FFmpegProfile.ReadRate),
MaybeReadRateCatchup: Optional(channel.FFmpegProfile.ReadRateCatchup));
var audioInputFile = new NullAudioInputFile(audioState);
@@ -1,7 +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 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);
}
@@ -31,6 +31,28 @@ public class TextGraphicsElement : BaseGraphicsElement
[YamlMember(Alias = "z_index", ApplyNamingConventions = false)]
public int? ZIndex { get; set; }
// Background box (ersatztv#732). Element-level, not per-style: the graphics engine renders one
// TextBlock into one bitmap, so a single box behind the whole element is the only shape the
// renderer can express. Unset background_color means no FILL; a border_color alone still draws
// an outlined box. With neither there is no box and no insets -- the pre-#732 geometry.
[YamlMember(Alias = "background_color", ApplyNamingConventions = false)]
public string BackgroundColor { get; set; }
[YamlMember(Alias = "background_opacity_percent", ApplyNamingConventions = false)]
public int? BackgroundOpacityPercent { get; set; }
[YamlMember(Alias = "background_padding", ApplyNamingConventions = false)]
public double? BackgroundPadding { get; set; }
[YamlMember(Alias = "background_corner_radius", ApplyNamingConventions = false)]
public double? BackgroundCornerRadius { get; set; }
[YamlMember(Alias = "border_color", ApplyNamingConventions = false)]
public string BorderColor { get; set; }
[YamlMember(Alias = "border_width", ApplyNamingConventions = false)]
public double? BorderWidth { get; set; }
public List<StyleDefinition> Styles { get; set; } = [];
[YamlMember(Alias = "base_style", ApplyNamingConventions = false)]
@@ -85,19 +85,46 @@ public static class AlternateScheduleSelector
}
}
bool daysOfWeek = item.DaysOfWeek.Contains(date.DayOfWeek);
// These three are NULLABLE single-column primitive collections, and a runtime null IS
// reachable (ersatztv#823, measured against a real TvContext on SQLite and MySQL 8.4): EF does
// NOT invoke the value converter for a NULL column, so it materializes as CLR null rather than
// through IntCollectionValueConverter's null-to-empty branch, which never runs on this path.
// Unguarded, each .Contains below throws NullReferenceException.
//
// A NULL reads as UNRESTRICTED -- the All*() sets -- NOT as empty. This is the whole semantic
// question and it is decided by the one NULL that is reachable WITHOUT any code writing one:
// Sqlite's 20240113140741_Add_PlayoutTemplate_DaysOfMonth adds DaysOfMonth with
// `nullable: true` and NO defaultValue, so a PlayoutTemplate row inserted before it holds NULL
// and, by construction, had NO day-of-month restriction. Reading that as empty would INVERT
// the row's meaning and silently stop the template applying at all. All*() preserves it, and
// it is how "no restriction recorded" is already represented elsewhere in this domain
// (GetPlayoutAlternateSchedulesHandler, PreviewBlockPlayoutHandler). Note what does NOT decide
// it: the API request records normalize an omitted field with `?? []`, but that is a client
// omitting a field on a WRITE and says nothing about what a legacy database NULL meant.
//
// Guarded at the READ SITE, into locals, and NEVER assigned back onto `item`: the property IS
// the column value, so writing the guard back would flip a tracked entry to Modified and
// persist the substituted collection over the NULL
// (`media.nullable-primitive-collection-mutation`). The matching substitution happens at the
// entity->DTO boundary in the two Mapper.ProjectToViewModel overloads, so the SPA's
// appliesToDate -- an exact port of this method -- previews what this actually schedules.
ICollection<DayOfWeek> itemDaysOfWeek = item.DaysOfWeek ?? AllDaysOfWeek();
ICollection<int> itemDaysOfMonth = item.DaysOfMonth ?? AllDaysOfMonth();
ICollection<int> itemMonthsOfYear = item.MonthsOfYear ?? AllMonthsOfYear();
bool daysOfWeek = itemDaysOfWeek.Contains(date.DayOfWeek);
if (!daysOfWeek)
{
continue;
}
bool daysOfMonth = item.DaysOfMonth.Contains(date.Day);
bool daysOfMonth = itemDaysOfMonth.Contains(date.Day);
if (!daysOfMonth)
{
continue;
}
bool monthOfYear = item.MonthsOfYear.Contains(date.Month);
bool monthOfYear = itemMonthsOfYear.Contains(date.Month);
if (monthOfYear)
{
return item;
@@ -680,10 +680,56 @@ public class PipelineBuilderBaseTests
command.ShouldContain("-readrate 1.05 -readrate_initial_burst 8 -readrate_catchup 6.0 -i /tmp/whatever.mkv");
}
// ersatztv#735: the pacing values became operator-tunable profile fields. these pin that a
// configured value actually reaches the command line -- the defaults above are the OTHER half
// of the same guard, and they are what an unset profile still gets
[Test]
public void Realtime_Input_Should_Use_A_Configured_ReadRate_And_Catchup()
{
string command = BuildRealtimeCommand(
new CatchupCapableFFmpegCapabilities(),
readRate: 1.5,
readRateCatchup: 4.0);
command.ShouldContain("-readrate 1.5 -readrate_initial_burst 8 -readrate_catchup 4.0 -i /tmp/whatever.mkv");
command.ShouldNotContain("-readrate 1.05");
command.ShouldNotContain("-readrate_catchup 6.0");
}
// the write path rejects an out-of-range value with a 422, so this only fires for a row written
// out of band -- but FFmpeg must never see the unbounded value either way
[Test]
public void Realtime_Input_Should_Clamp_An_Out_Of_Range_ReadRate()
{
string command = BuildRealtimeCommand(
new CatchupCapableFFmpegCapabilities(),
readRate: 9.0,
readRateCatchup: 0.1);
// 9.0 clamps to the 2.0 ceiling, and 0.1 is raised to the resolved base rate, because a
// catchup below it could never let a lagging input recover
command.ShouldContain("-readrate 2.0 -readrate_initial_burst 8 -readrate_catchup 2.0 -i /tmp/whatever.mkv");
}
// ...and the catchup CEILING isolated from the base rate, which the case above cannot show:
// there both clamps land on the same 2.0, so either one alone would satisfy it
[Test]
public void Realtime_Input_Should_Clamp_An_Out_Of_Range_ReadRateCatchup()
{
string command = BuildRealtimeCommand(
new CatchupCapableFFmpegCapabilities(),
readRate: 1.2,
readRateCatchup: 15.0);
command.ShouldContain("-readrate 1.2 -readrate_initial_burst 8 -readrate_catchup 10.0 -i /tmp/whatever.mkv");
}
private string BuildRealtimeCommand(
IFFmpegCapabilities capabilities,
bool stillImage = false,
bool imageSubtitle = false)
bool imageSubtitle = false,
Option<double> readRate = default,
Option<double> readRateCatchup = default)
{
var videoInputFile = new VideoInputFile(
"/tmp/whatever.mkv",
@@ -748,7 +794,9 @@ public class PipelineBuilderBaseTests
false,
false,
"clip",
false);
false,
MaybeReadRate: readRate,
MaybeReadRateCatchup: readRateCatchup);
// a *separate* audio input matters here: for a still image the video input takes no readrate
// at all, so only a distinct audio input can prove the burst was suppressed (this is the
+46 -1
View File
@@ -28,7 +28,9 @@ public record FFmpegState(
bool IsHdrTonemap,
string TonemapAlgorithm,
bool IsTroubleshooting,
bool QsvPreferNativeDecoder = false)
bool QsvPreferNativeDecoder = false,
Option<double> MaybeReadRate = default,
Option<double> MaybeReadRateCatchup = default)
{
// the QSV upload pool needs headroom for the frames in flight through the filter graph.
// extra_hw_frames=0 leaves none, so any input that is not throttled exhausts it: the graph
@@ -42,6 +44,49 @@ public record FFmpegState(
public int QsvExtraHardwareFrames =>
Math.Max(MaybeQsvExtraHardwareFrames.IfNone(MinimumQsvExtraHardwareFrames), MinimumQsvExtraHardwareFrames);
// realtime pacing. an unset profile keeps the values these constants name, which are the ones
// the pipeline hardcoded before they became configurable (ersatztv#735)
public const double DefaultReadRate = 1.05;
public const double DefaultStreamCopyReadRate = 1.0;
// how fast a LAGGING realtime input may read until it is level again. measured on the #726
// repro (embedded dvd_subtitle -> overlay, QSV encode): 1.05 alone sustains 0.53x, catchup 2.0
// reaches 0.711x, and 6.0 restores the full 1.067x that the same pipeline achieves with no
// subtitle at all. 20.0 also measures 1.067x — i.e. the value is not a throughput dial above
// the point where the input catches up, so 6.0 is chosen as the smallest measured-sufficient
// ceiling rather than the largest that works (ersatztv#726)
public const double DefaultReadRateCatchup = 6.0;
// below realtime the process reads slower than a live client consumes and the channel stalls;
// ersatztv#726 is that failure, measured at an effective 0.53x. the ceiling is a CHOSEN bound,
// not a measured cliff: it exists so the field cannot be used to effectively disable pacing,
// which is the configuration ersatztv#529 measured to produce zero segments on a QSV pipeline
public const double MinimumReadRate = 1.0;
public const double MaximumReadRate = 2.0;
// catchup is a ceiling that applies only WHILE an input is behind, so it is bounded more
// loosely than the base rate; the same chosen-not-measured caveat applies to the ceiling.
// the FLOOR is only a write-path bound: at render time the resolved base rate is always at
// least MinimumReadRate, so Math.Max below already dominates it
public const double MinimumReadRateCatchup = 1.0;
public const double MaximumReadRateCatchup = 10.0;
// clamped for the same reason QsvExtraHardwareFrames is: a row written out of band (or before
// the write path validated the field) must not reach FFmpeg unbounded. the write path rejects
// an out-of-range value with a 422 naming the bound, so this is belt-and-braces, not the
// primary guard (ersatztv#735)
public double ReadRateFor(bool isStreamCopy) =>
MaybeReadRate.Match(
configured => Math.Clamp(configured, MinimumReadRate, MaximumReadRate),
() => isStreamCopy ? DefaultStreamCopyReadRate : DefaultReadRate);
// a catchup rate below the base rate cannot let a lagging input recover, so the resolved base
// rate is its real floor — no separate lower clamp, which would be unreachable behind this Max
public double ReadRateCatchupFor(bool isStreamCopy) =>
Math.Max(
Math.Min(MaybeReadRateCatchup.IfNone(DefaultReadRateCatchup), MaximumReadRateCatchup),
ReadRateFor(isStreamCopy));
public static FFmpegState Concat(bool saveReport, string channelName) =>
new(
saveReport,
@@ -22,14 +22,6 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
// an operator who raises that setting above 2 gets less of the benefit (ersatztv#350)
private const int InitialBurstSeconds = OutputFormatHls.SegmentSeconds * 2;
// how fast a LAGGING realtime input may read until it is level again. measured on the #726
// repro (embedded dvd_subtitle -> overlay, QSV encode): 1.05 alone sustains 0.53x, catchup 2.0
// reaches 0.711x, and 6.0 restores the full 1.067x that the same pipeline achieves with no
// subtitle at all. 20.0 also measures 1.067x — i.e. the value is not a throughput dial above
// the point where the input catches up, so 6.0 is chosen as the smallest measured-sufficient
// ceiling rather than the largest that works (ersatztv#726)
private const double CatchupReadRate = 6.0;
private readonly Option<AudioInputFile> _audioInputFile;
private readonly Option<ConcatInputFile> _concatInputFile;
private readonly IFFmpegCapabilities _ffmpegCapabilities;
@@ -660,7 +652,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
}
//SetStillImageInfiniteLoop(videoInputFile, videoStream, ffmpegState);
SetRealtimeInput(videoInputFile, desiredState);
SetRealtimeInput(videoInputFile, ffmpegState, desiredState);
SetInfiniteLoop(videoInputFile, videoStream, ffmpegState, desiredState);
SetFrameRateOutput(desiredState, pipelineSteps);
SetVideoTrackTimescaleOutput(desiredState, pipelineSteps);
@@ -855,14 +847,17 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
}
}
private void SetRealtimeInput(VideoInputFile videoInputFile, FrameState desiredState)
private void SetRealtimeInput(VideoInputFile videoInputFile, FFmpegState ffmpegState, FrameState desiredState)
{
if (videoInputFile.StreamInputKind is StreamInputKind.Live || !desiredState.Realtime)
{
return;
}
double readRate = desiredState.VideoFormat == VideoFormat.Copy ? 1.0 : 1.05;
// both defaults and both bounds live on FFmpegState, beside the profile fields that
// override them, so the pacing contract is readable in one place (ersatztv#735)
bool isStreamCopy = desiredState.VideoFormat == VideoFormat.Copy;
double readRate = ffmpegState.ReadRateFor(isStreamCopy);
// without a burst, the readrate throttle applies from the very first read, so the first
// segment cannot be written faster than ~realtime and every start pays a multi-second wait.
@@ -894,7 +889,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
// subtitle always rides the video path, so this shape cannot suffer the starvation anyway
Option<double> catchupReadRate =
!isStillImage && _ffmpegCapabilities.HasOption(FFmpegKnownOption.ReadrateCatchup)
? CatchupReadRate
? ffmpegState.ReadRateCatchupFor(isStreamCopy)
: Option<double>.None;
_audioInputFile.Iter(a => a.AddOption(new ReadrateInputOption(readRate, initialBurstSeconds, catchupReadRate)));
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_FFmpegProfile_ReadRatePacing : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "ReadRate",
table: "FFmpegProfile",
type: "double",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "ReadRateCatchup",
table: "FFmpegProfile",
type: "double",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ReadRate",
table: "FFmpegProfile");
migrationBuilder.DropColumn(
name: "ReadRateCatchup",
table: "FFmpegProfile");
}
}
}
@@ -929,6 +929,12 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<double?>("ReadRate")
.HasColumnType("double");
b.Property<double?>("ReadRateCatchup")
.HasColumnType("double");
b.Property<int>("ResolutionId")
.HasColumnType("int");
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_FFmpegProfile_ReadRatePacing : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "ReadRate",
table: "FFmpegProfile",
type: "REAL",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "ReadRateCatchup",
table: "FFmpegProfile",
type: "REAL",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ReadRate",
table: "FFmpegProfile");
migrationBuilder.DropColumn(
name: "ReadRateCatchup",
table: "FFmpegProfile");
}
}
}
@@ -896,6 +896,12 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<double?>("ReadRate")
.HasColumnType("REAL");
b.Property<double?>("ReadRateCatchup")
.HasColumnType("REAL");
b.Property<int>("ResolutionId")
.HasColumnType("INTEGER");
@@ -1,4 +1,4 @@
using Dapper;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -171,8 +171,15 @@ public class MusicVideoRepository : IMusicVideoRepository
public async Task<int> GetMusicVideoCount(int artistId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
// count the same population GetPagedMusicVideos pages — MusicVideoMetadata, not MusicVideo.
// A music video whose metadata row is missing (a scanner failure; FindOrphanPaths models
// exactly that state) is not pageable, so counting the item table over-reports
// (api.paged-count-matches-page-query, #832).
return await dbContext.Connection.QuerySingleAsync<int>(
@"SELECT COUNT(*) FROM MusicVideo WHERE ArtistId = @ArtistId",
@"SELECT COUNT(*)
FROM MusicVideoMetadata MVM
INNER JOIN MusicVideo M on MVM.MusicVideoId = M.Id
WHERE M.ArtistId = @ArtistId",
new { ArtistId = artistId });
}
@@ -1,4 +1,4 @@
using Dapper;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
@@ -134,9 +134,26 @@ public class TelevisionRepository : ITelevisionRepository
public async Task<int> GetSeasonCount(int showId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Seasons
.AsNoTracking()
.CountAsync(s => s.ShowId == showId);
// GetPagedSeasons expands the requested show to EVERY show sharing its Title+Year (the same
// show present in two libraries) and pages the union, so the count must expand identically
// or it under-reports (api.paged-count-matches-page-query, #832).
Option<ShowMetadata> maybeShowMetadata = await dbContext.ShowMetadata
.SelectOneAsync(sm => sm.Id, sm => sm.ShowId == showId, CancellationToken.None);
foreach (ShowMetadata showMetadata in maybeShowMetadata)
{
List<int> showIds = await dbContext.ShowMetadata
.Filter(sm => sm.Title == showMetadata.Title && sm.Year == showMetadata.Year)
.Map(sm => sm.ShowId)
.ToListAsync();
return await dbContext.Seasons
.AsNoTracking()
.CountAsync(s => showIds.Contains(s.ShowId));
}
// no metadata for the requested show: GetPagedSeasons returns nothing, so neither does this
return 0;
}
public async Task<List<Season>> GetPagedSeasons(
@@ -179,9 +196,12 @@ public class TelevisionRepository : ITelevisionRepository
public async Task<int> GetEpisodeCount(int seasonId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Episodes
// count the same population GetPagedEpisodes pages — EpisodeMetadata, not Episode. An
// episode whose metadata row is missing is not pageable, so counting the item table
// over-reports (api.paged-count-matches-page-query, #832).
return await dbContext.EpisodeMetadata
.AsNoTracking()
.CountAsync(e => e.SeasonId == seasonId);
.CountAsync(em => em.Episode.SeasonId == seasonId);
}
public async Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize)
@@ -4,12 +4,16 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
public static class GraphicsElementSeeder
{
private const string OnNowNextYaml =
// The pre-#732 default, kept verbatim so an installation still carrying it byte-for-byte can
// be recognised as unmodified and upgraded. Never edit an entry here -- it is a fingerprint of
// what we shipped, not a template. Add a new entry when the current default changes again.
private const string OnNowNextYamlV1 =
"""
name: On Now / Next
epg_entries: 2
@@ -51,18 +55,88 @@ public static class GraphicsElementSeeder
{{ if (array.size Epg) > 1 }}[next]NEXT {{ Epg[1].Title }}[/next]{{ end }}
""";
public static async Task SeedOnNowNext(TvContext context, IFileSystem fileSystem, CancellationToken cancellationToken)
{
string seededKey = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
bool alreadySeeded = await context.ConfigElements.AnyAsync(c => c.Key == seededKey, cancellationToken);
if (alreadySeeded)
{
return;
}
private const string OnNowNextYaml =
"""
name: On Now / Next
epg_entries: 2
location: BottomLeft
horizontal_margin_percent: 4
vertical_margin_percent: 8
width_percent: 42
text_fit: Wrap
text_align: Left
z_index: 100
# transparent until 4s in, fade in 1s, hold 6s, fade out 1s
opacity_expression: "LinearFadeDuration(content_seconds, 4, 1, 6)"
# #732: a translucent box carries legibility over both bright and dark content. The halo is
# cut from 2 to 1 rather than dropped -- the box is translucent, so bright content still
# shows through behind the glyphs, but 2px of halo ON TOP of a box over-darkens the text.
background_color: "#000000"
background_opacity_percent: 65
background_padding: 14
background_corner_radius: 8
# A translucent black box vanishes into dark content, so the box needs an edge of its own.
# Low-alpha white reads as a hairline on dark frames without becoming a hard line on bright ones.
border_color: "#59FFFFFF"
border_width: 1
base_style: now
styles:
- name: now
font_family: "Noto Sans"
font_size: 30
font_weight: 700
text_color: "#FFFFFF"
halo_color: "#000000"
halo_width: 1
- name: sub
font_family: "Noto Sans"
font_size: 22
font_weight: 400
text_color: "#DDDDDD"
halo_color: "#000000"
halo_width: 1
- name: next
font_family: "Noto Sans"
font_size: 22
font_weight: 400
text_color: "#DDDDDD"
halo_color: "#000000"
halo_width: 1
text: |
[now]NOW {{ Epg[0].Title }}[/now]
{{ if Epg[0].SubTitle }}[sub]{{ Epg[0].SubTitle }}[/sub]{{ end }}
{{ if (array.size Epg) > 1 }}[next]NEXT {{ Epg[1].Title }}[/next]{{ end }}
""";
// Every default we have ever shipped, most recent first. A file matching one of these was
// written by us and never touched, so replacing it is an upgrade rather than a clobber.
private static readonly string[] SupersededDefaults = [OnNowNextYamlV1];
public static async Task SeedOnNowNext(
TvContext context,
IFileSystem fileSystem,
ILogger logger,
CancellationToken cancellationToken)
{
string folder = FileSystemLayout.GraphicsElementsTextTemplatesFolder;
string target = fileSystem.Path.Combine(folder, GraphicsElementDefaults.OnNowNextFileName);
string seededKey = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
bool alreadySeeded = await context.ConfigElements.AnyAsync(c => c.Key == seededKey, cancellationToken);
if (alreadySeeded)
{
// Already-seeded installations never revisit the file, so a change to the default would
// otherwise reach new databases only. Upgrade the ones still carrying an untouched
// earlier default; anything an operator edited no longer matches and is left alone.
//
// Deliberately no CreateDirectory on this branch: before #732 it touched the filesystem
// not at all, so an already-seeded install stays bootable on a read-only /config.
await UpgradeUnmodifiedTemplate(fileSystem, target, logger, cancellationToken);
await EnsureBuiltInElementRow(context, fileSystem, target, cancellationToken);
return;
}
if (!fileSystem.Directory.Exists(folder))
{
fileSystem.Directory.CreateDirectory(folder);
@@ -78,5 +152,242 @@ public static class GraphicsElementSeeder
new ConfigElement { Key = seededKey, Value = "true" },
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
await EnsureBuiltInElementRow(context, fileSystem, target, cancellationToken);
}
/// <summary>
/// `RefreshGraphicsElements` is what normally turns a template file into a `GraphicsElement` row,
/// but it runs on the scheduler/stream-start path -- long after startup. Creating the row here
/// removes that ordering dependency, so `AttachOnNowNextByDefault` below can never mark itself
/// done against an element that simply had not been discovered yet.
/// </summary>
private static async Task EnsureBuiltInElementRow(
TvContext context,
IFileSystem fileSystem,
string target,
CancellationToken cancellationToken)
{
if (!fileSystem.File.Exists(target))
{
return;
}
// "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 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
{
Path = target,
Kind = GraphicsElementKind.Text,
Name = GraphicsElementDefaults.OnNowNextName
},
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// #732: the On Now / Next overlay is a default, not an opt-in. Existing channels predate that
/// decision, so attach the built-in element to them once.
/// </summary>
/// <remarks>
/// The marker is written only once the built-in element RESOLVES, so an install whose row does
/// not exist yet is retried on the next startup rather than stranded permanently. Once written,
/// no channel is ever re-attached. While still armed the backfill cannot tell a deliberately
/// cleared channel from an untouched one -- a single global flag cannot express both
/// properties; see <c>graphics.on-now-next-on-by-default</c> for why that trade is made this
/// way. Every channel created after the marker gets the element from
/// <c>ChannelGraphicsDefaults.Attach</c> instead, which BOTH create paths call.
/// </remarks>
public static async Task AttachOnNowNextByDefault(TvContext context, CancellationToken cancellationToken)
{
string key = ConfigElementKey.GraphicsOnNowNextDefaultAttached.Key;
if (await context.ConfigElements.AnyAsync(c => c.Key == key, cancellationToken))
{
return;
}
Option<int> maybeElementId = await GetBuiltInElementId(context, cancellationToken);
if (maybeElementId.IsNone)
{
// Nothing to attach TO. Writing the marker here would strand every channel permanently
// on the one population this exists for, so stay armed and try again next startup.
return;
}
foreach (int elementId in maybeElementId)
{
// HLS Direct has no frame pipeline to draw into, so an attachment there would be inert
// while still reading as "on" in the editor.
List<int> channelIds = await context.Channels
.Where(c => c.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
.Where(c => c.ChannelGraphicsElements.All(cge => cge.GraphicsElementId != elementId))
.Select(c => c.Id)
.ToListAsync(cancellationToken);
foreach (int channelId in channelIds)
{
await context.AddAsync(
new ChannelGraphicsElement { ChannelId = channelId, GraphicsElementId = elementId },
cancellationToken);
}
}
await context.ConfigElements.AddAsync(
new ConfigElement { Key = key, Value = "true" },
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// 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, 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, r.Kind)).ToList());
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 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(
IFileSystem fileSystem,
string target,
ILogger logger,
CancellationToken cancellationToken)
{
// This runs inside the blocking database-startup path, ahead of DatabaseIsReady(). Before
// #732 the already-seeded branch never touched the filesystem at all, so an unreadable or
// read-only template is a state that used to boot fine -- it must not become a failure to
// start. Cosmetic upgrade, best effort.
try
{
if (!fileSystem.File.Exists(target))
{
return;
}
string existing = await fileSystem.File.ReadAllTextAsync(target, cancellationToken);
if (!SupersededDefaults.Any(d => IsSameTemplate(existing, d)))
{
return;
}
// Write-then-move, never write in place. WriteAllTextAsync truncates first, so an
// interrupted write (disk full, IO fault, cancellation) would leave a partial file that
// matches no fingerprint and is therefore never repaired on a later boot -- the overlay
// would just be gone, permanently, on every channel carrying it.
//
// The temp name is random per call. A fixed one is shared by two containers on the same
// config volume, where one can truncate it while the other is mid-write and then rename
// the partial file over the live template. The process id is NOT good enough here: the
// image uses an exec-form ENTRYPOINT, so every container's PID namespace makes this
// process 1 and every container computes the same name. A random name also means a temp
// left by a crashed earlier boot is never reused. Only ever delete the path this call
// created.
string temp = $"{target}.{fileSystem.Path.GetRandomFileName()}.upgrade.tmp";
try
{
await fileSystem.File.WriteAllTextAsync(temp, OnNowNextYaml, cancellationToken);
// Deliberately NO in-place fallback when this throws. rename(2) onto a mountpoint
// is EBUSY, so a single-file bind mount of this template will not be upgraded --
// accepted, because reaching that case needs a pinned file that is ALSO byte-identical
// to a shipped default, and the alternative is reintroducing the truncation this
// whole dance exists to prevent, on every IO fault rather than just that one.
fileSystem.File.Move(temp, target, true);
}
finally
{
// Cleanup must never REPLACE the exception that brought us here. Without this inner
// catch, a delete that throws while unwinding a cancellation swaps the
// OperationCanceledException for an IOException, which the outer filter then
// swallows -- so a real shutdown would be silently downgraded to a warning.
try
{
if (fileSystem.File.Exists(temp))
{
fileSystem.File.Delete(temp);
}
}
catch (Exception cleanupEx)
{
logger.LogDebug(cleanupEx, "Could not remove the temporary upgrade file {Path}", temp);
}
}
}
// Recoverable filesystem faults only. Catching everything would swallow genuinely fatal
// runtime failures (OutOfMemory and friends) and continue booting a compromised process;
// letting IO escape would turn a file permission into a restart loop. Cancellation
// propagates so shutdown is not swallowed.
// OperationCanceledException is deliberately absent from this list so a real shutdown
// propagates -- but only a real one: an OCE raised while the token is NOT cancelled is just
// another faulty read, and letting it escape is the restart loop this catch exists to stop.
catch (Exception ex) when ((ex is IOException
or UnauthorizedAccessException
or NotSupportedException
or System.Security.SecurityException)
|| (ex is OperationCanceledException
&& !cancellationToken.IsCancellationRequested))
{
logger.LogWarning(
ex,
"Could not upgrade the built-in graphics template at {Path}; leaving it as-is",
target);
}
}
// Compare on content, ignoring the line endings and trailing whitespace an editor or a volume
// mount may rewrite. This is a fingerprint check, not a parse: anything that is not one of our
// own shipped defaults must fall through untouched.
private static bool IsSameTemplate(string left, string right) =>
string.Equals(Normalize(left), Normalize(right), StringComparison.Ordinal);
private static string Normalize(string value) =>
value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd();
}
@@ -14,6 +14,10 @@ public partial class TextElement(
ILogger logger)
: GraphicsElement, IDisposable
{
// Far larger than any sane overlay on an 8K frame, and small enough that every downstream
// int cast stays well inside range.
private const float MaxBoxDimension = 10_000f;
private static readonly Regex StylePattern = StyleRegex();
private SKBitmap _image;
private SKPointI _location;
@@ -62,30 +66,96 @@ public partial class TextElement(
}
}
BackgroundBox box = BuildBackgroundBox();
RichTextKit.TextBlock textBlock = BuildTextBlock(textElement.Text);
if (textElement.WidthPercent.HasValue)
// Padding and border sit OUTSIDE the laid-out text on every side, so they shrink the
// space the text may occupy and grow the bitmap that holds it. Zero when there is no
// box, which reproduces the pre-#732 geometry exactly.
//
// Round ONCE, here, and use the same integer on both sides: the bitmap grows by
// 2 * insetPixels, so subtracting the unrounded inset from the wrap budget would let a
// fractional padding push the finished box a pixel past width_percent.
var insetPixels = (int)Math.Ceiling(box?.Inset ?? 0f);
// Bound the inset against the FRAME even when there is no width_percent. Sanitize caps
// each field individually, but padding and border add up, and without a budget nothing
// else clamps them -- a two-field fat-finger would otherwise allocate a bitmap far
// larger than the frame it is drawn onto. Pre-#732 no config value could inflate the
// bitmap independently of the measured text.
int frameInsetCap = Math.Max(0, Math.Min(context.FrameSize.Width, context.FrameSize.Height) / 2);
if (insetPixels > frameInsetCap)
{
logger.LogWarning(
"Background padding/border of {Inset}px exceeds the frame; clamping to {Clamped}px",
insetPixels,
frameInsetCap);
insetPixels = frameInsetCap;
box = box?.ClampedTo(frameInsetCap);
}
// A width_percent of 1e300 makes maxWidth Infinity, and every int cast below it is then
// unspecified. Treat a non-finite budget as "no budget", which is what an absent
// width_percent already means.
if (textElement.WidthPercent.HasValue
&& float.IsFinite((float)(textElement.WidthPercent.Value / 100.0 * context.FrameSize.Width)))
{
var maxWidth = (float)Math.Round(textElement.WidthPercent.Value / 100.0 * context.FrameSize.Width);
// A padding wider than the budget itself cannot be honoured AND stay inside it.
// Clamp the inset rather than squeezing the text to 1px: an unclamped floor turns a
// fat-fingered background_padding into a box several times the requested width.
int maxInset = Math.Max(0, (int)Math.Floor((maxWidth - 1) / 2));
if (insetPixels > maxInset)
{
logger.LogWarning(
"Background padding/border of {Inset}px does not fit within width_percent "
+ "({MaxWidth}px); clamping to {Clamped}px",
insetPixels,
maxWidth,
maxInset);
// Clamp the BOX, not just the bitmap's inset. Shrinking insetPixels alone leaves
// DrawBackgroundBox stroking at the original border width, which is centred on a
// rect that no longer has room for it -- the stroke then floods the element.
insetPixels = maxInset;
box = box?.ClampedTo(maxInset);
}
// width_percent bounds the ELEMENT, so the text gets what is left after the insets.
// With no box the budget is passed through untouched -- not through Math.Max -- so a
// width_percent that rounds to 0 keeps its exact pre-#732 behavior.
float textMaxWidth = insetPixels == 0
? maxWidth
: Math.Max(1f, maxWidth - (2 * insetPixels));
switch (textElement.Fit)
{
case TextFit.Wrap:
textBlock.MaxWidth = maxWidth;
textBlock.MaxWidth = textMaxWidth;
break;
case TextFit.Scale:
FitTextBlock(textBlock, maxWidth);
FitTextBlock(textBlock, textMaxWidth);
break;
}
}
_image = new SKBitmap(
(int)Math.Ceiling(textBlock.MeasuredWidth),
(int)Math.Ceiling(textBlock.MeasuredHeight));
(int)Math.Ceiling(textBlock.MeasuredWidth) + (2 * insetPixels),
(int)Math.Ceiling(textBlock.MeasuredHeight) + (2 * insetPixels));
using (var canvas = new SKCanvas(_image))
{
canvas.Clear(SKColors.Transparent);
textBlock.Paint(canvas, new SKPoint(0, 0));
if (box is not null)
{
DrawBackgroundBox(canvas, box, _image.Width, _image.Height);
}
textBlock.Paint(canvas, new SKPoint(insetPixels, insetPixels));
}
var horizontalMargin =
@@ -134,6 +204,158 @@ public partial class TextElement(
: new ValueTask<Option<PreparedElementImage>>(new PreparedElementImage(_image, _location, opacity, ZIndex, false));
}
// A background box is drawn only when a colour actually parses. An unparseable colour is
// warned about and skipped rather than substituted, so a typo never silently changes the
// look into something that appears deliberate.
private BackgroundBox BuildBackgroundBox()
{
SKColor? fill = ParseOptionalColor(textElement.BackgroundColor, "background_color");
if (fill.HasValue)
{
fill = ApplyOpacityPercent(fill.Value, textElement.BackgroundOpacityPercent);
}
SKColor? border = ParseOptionalColor(textElement.BorderColor, "border_color");
// A border colour with no explicit width means a hairline border, not an invisible one:
// "border_color set, nothing drawn" is the more confusing of the two readings.
float borderWidth = Sanitize(textElement.BorderWidth ?? 1, "border_width");
if (!border.HasValue)
{
borderWidth = 0;
}
if (!fill.HasValue && borderWidth <= 0)
{
return null;
}
return new BackgroundBox(
fill,
border,
borderWidth,
Sanitize(textElement.BackgroundCornerRadius, "background_corner_radius"),
Sanitize(textElement.BackgroundPadding, "background_padding"));
}
// YAML happily yields 1e100 or NaN. Cast to float those become Infinity/NaN, and
// (int)Math.Ceiling(Infinity) is an unspecified value -- in practice int.MinValue, which sails
// straight past every `> maxInset` clamp and can wrap 2 * inset back to zero. Sanitize at the
// boundary so no downstream arithmetic ever sees a non-finite value.
private float Sanitize(double? value, string fieldName)
{
if (value is not { } raw)
{
return 0f;
}
if (double.IsNaN(raw) || raw < 0)
{
logger.LogWarning("Ignoring out-of-range {Field} value {Value}", fieldName, raw);
return 0f;
}
if (raw > MaxBoxDimension)
{
logger.LogWarning(
"Clamping {Field} value {Value} to {Max}",
fieldName,
raw,
MaxBoxDimension);
return MaxBoxDimension;
}
return (float)raw;
}
private SKColor? ParseOptionalColor(string value, string fieldName)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (SKColor.TryParse(value, out SKColor parsed))
{
return parsed;
}
logger.LogWarning(
"Unable to parse {Field} value {Value}; that part of the background box will not be drawn",
fieldName,
value);
return null;
}
private static SKColor ApplyOpacityPercent(SKColor color, int? opacityPercent)
{
if (opacityPercent is not { } percent)
{
return color;
}
int clamped = Math.Clamp(percent, 0, 100);
return color.WithAlpha((byte)Math.Round(color.Alpha * clamped / 100.0));
}
private static void DrawBackgroundBox(SKCanvas canvas, BackgroundBox box, int width, int height)
{
// Skia strokes centred on the path, so half the border would fall outside the bitmap and
// be clipped. Inset the rect by half the width to keep the whole border visible.
float half = box.BorderWidth / 2f;
var rect = new SKRect(half, half, width - half, height - half);
// A radius larger than half the shorter side is not expressible as a rounded rect.
float radius = Math.Min(box.CornerRadius, Math.Min(rect.Width, rect.Height) / 2f);
radius = Math.Max(0, radius);
if (box.Fill is { } fill)
{
using var fillPaint = new SKPaint
{
Color = fill,
Style = SKPaintStyle.Fill,
IsAntialias = true
};
canvas.DrawRoundRect(rect, radius, radius, fillPaint);
}
if (box.Border is { } border && box.BorderWidth > 0)
{
using var borderPaint = new SKPaint
{
Color = border,
Style = SKPaintStyle.Stroke,
StrokeWidth = box.BorderWidth,
IsAntialias = true
};
canvas.DrawRoundRect(rect, radius, radius, borderPaint);
}
}
private sealed record BackgroundBox(
SKColor? Fill,
SKColor? Border,
float BorderWidth,
float CornerRadius,
float Padding)
{
public float Inset => Padding + BorderWidth;
// Border first, then whatever is left goes to padding: a border that cannot be drawn inside
// the bitmap is worse than a thin one, and padding degrades gracefully to zero.
public BackgroundBox ClampedTo(float maxInset)
{
float borderWidth = Math.Min(BorderWidth, maxInset);
float padding = Math.Max(0, maxInset - borderWidth);
return this with { BorderWidth = borderWidth, Padding = padding };
}
}
private RichTextKit.TextBlock BuildTextBlock(string textToRender)
{
var textBlock = new RichTextKit.TextBlock
@@ -211,6 +433,17 @@ public partial class TextElement(
finalStyle.TextColor = parsedColor;
}
// Halo is per-style in the schema and was being dropped here, so a non-base style's
// halo_* silently inherited the base style's. The seeded template only looked correct
// because all three of its styles declare the same halo.
finalStyle.HaloWidth = s.HaloWidth ?? finalStyle.HaloWidth;
finalStyle.HaloBlur = s.HaloBlur ?? finalStyle.HaloBlur;
if (s.HaloColor != null && SKColor.TryParse(s.HaloColor, out SKColor parsedHalo))
{
finalStyle.HaloColor = parsedHalo;
}
styles[s.Name] = finalStyle;
}
@@ -286,6 +519,11 @@ public partial class TextElement(
foreach ((string text, RichTextKit.IStyle style) in originalContent)
{
// Carry across every property the YAML schema can set, not just the ones the scale
// needs (the rest are RichTextKit defaults we never touch). Halo and
// line height were being dropped here, which only mattered once #732 gave the box
// insets that can push a previously-fitting element into the Scale path: adding a
// background would then silently remove the halo it sits behind.
var newStyle = new RichTextKit.Style
{
FontFamily = style.FontFamily,
@@ -294,7 +532,11 @@ public partial class TextElement(
FontWidth = style.FontWidth,
FontWeight = style.FontWeight,
LetterSpacing = style.LetterSpacing,
TextColor = style.TextColor
LineHeight = style.LineHeight,
TextColor = style.TextColor,
HaloColor = style.HaloColor,
HaloWidth = style.HaloWidth,
HaloBlur = style.HaloBlur
};
float newSize = newStyle.FontSize * scale;
@@ -0,0 +1,92 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
/// <summary>
/// #732: the On Now / Next overlay is a default rather than an opt-in, so a channel created after
/// that decision gets the built-in element without the operator toggling anything.
/// </summary>
[TestFixture]
public class CreateChannelDefaultGraphicsElementTests : ChannelHandlerTestBase
{
private CreateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets, RemoteLogoCacher);
private async Task<int> SeedBuiltInElement()
{
await using TvContext context = Db.CreateContext();
var element = new GraphicsElement
{
Path = GraphicsElementDefaults.OnNowNextSeededPath,
Kind = GraphicsElementKind.Text
};
context.GraphicsElements.Add(element);
await context.SaveChangesAsync();
return element.Id;
}
private async Task<List<int>> AttachedElementIds(int channelId)
{
await using TvContext context = Db.CreateContext();
Channel reloaded = await context.Channels
.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == channelId);
return reloaded.ChannelGraphicsElements.Select(x => x.GraphicsElementId).ToList();
}
[Test]
public async Task Attaches_The_Built_In_Element_To_A_New_Channel()
{
await SeedFFmpegProfile();
int elementId = await SeedBuiltInElement();
Either<BaseError, CreateChannelResult> result =
await MakeHandler().Handle(MakeCreate(), CancellationToken.None);
result.IsRight.ShouldBeTrue();
int channelId = result.RightToSeq().Head().ChannelId;
(await AttachedElementIds(channelId)).ShouldBe([elementId]);
}
[Test]
public async Task Leaves_An_Hls_Direct_Channel_Alone_Because_Nothing_Can_Render_There()
{
await SeedFFmpegProfile();
await SeedBuiltInElement();
Either<BaseError, CreateChannelResult> result = await MakeHandler().Handle(
MakeCreate(streamingMode: StreamingMode.HttpLiveStreamingDirect),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
int channelId = result.RightToSeq().Head().ChannelId;
(await AttachedElementIds(channelId)).ShouldBeEmpty();
}
[Test]
public async Task Creates_The_Channel_Even_When_The_Built_In_Element_Does_Not_Exist()
{
await SeedFFmpegProfile();
Either<BaseError, CreateChannelResult> result =
await MakeHandler().Handle(MakeCreate(), CancellationToken.None);
result.IsRight.ShouldBeTrue();
int channelId = result.RightToSeq().Head().ChannelId;
(await AttachedElementIds(channelId)).ShouldBeEmpty();
}
}
@@ -9,9 +9,11 @@ using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
@@ -462,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.
@@ -964,4 +966,66 @@ public class CreateChannelFromLineupHandlerTests
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => throw new AssertionException($"Expected a Right result, got {e.Value}"), Right: r => r);
// #732: this is the SPA's primary "Add Channel" flow and the one Auto-Tune bulk-creates through.
// It was the channel-creation site the default attach originally missed, so a channel made here
// would silently never get the overlay once the one-time backfill marker had landed.
[Test]
public async Task Should_Attach_The_Built_In_On_Now_Next_Element()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
int elementId = await SeedBuiltInGraphicsElement();
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(), CancellationToken.None);
CreateChannelFromLineupResponseModel response = RightOf(result);
await using TvContext context = _db.CreateContext();
DomainChannel channel = await context.Channels
.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == response.ChannelId);
channel.ChannelGraphicsElements.Select(x => x.GraphicsElementId).ShouldBe([elementId]);
}
[Test]
public async Task Should_Not_Attach_The_Overlay_To_An_Hls_Direct_Channel()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await SeedBuiltInGraphicsElement();
Either<BaseError, CreateChannelFromLineupResponseModel> result = await MakeHandler().Handle(
MakeRequest(advanced: new CreateChannelFromLineupAdvancedOptions(
PlaybackOrder.Shuffle,
StreamingMode: StreamingMode.HttpLiveStreamingDirect)),
CancellationToken.None);
CreateChannelFromLineupResponseModel response = RightOf(result);
await using TvContext context = _db.CreateContext();
DomainChannel channel = await context.Channels
.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == response.ChannelId);
channel.ChannelGraphicsElements.ShouldBeEmpty();
}
private async Task<int> SeedBuiltInGraphicsElement()
{
await using TvContext context = _db.CreateContext();
var element = new GraphicsElement
{
Path = GraphicsElementDefaults.OnNowNextSeededPath,
Kind = GraphicsElementKind.Text
};
context.GraphicsElements.Add(element);
await context.SaveChangesAsync();
return element.Id;
}
}
@@ -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));
}
}
@@ -5,6 +5,9 @@ using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.OutputFormat;
using Microsoft.EntityFrameworkCore;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
@@ -134,15 +137,30 @@ public class FFmpegProfileHandlerTests
persisted.QsvPreferNativeDecoder.ShouldBe(false);
}
// ersatztv#529: a stored 0 reached ffmpeg as hwupload=extra_hw_frames=0, leaving the QSV pool no
// headroom; FFmpegState floors it at render time, and these pin that the stored row converges too
// so the profile never keeps displaying a value the pipeline would override.
[TestCase(0, 64)]
[TestCase(-8, 64)]
[TestCase(63, 64)]
[TestCase(64, 64)]
[TestCase(128, 128)]
public async Task Create_Should_Floor_QsvExtraHardwareFrames(int configured, int expected)
// ersatztv#735: the write path used to accept an out-of-range pool size and store the floored
// value instead, so a client that PUT 0 got a 200 and read back 64. it is now rejected, naming
// the bound; FFmpegState still floors at render time for rows that predate this.
[TestCase(0)]
[TestCase(-8)]
[TestCase(63)]
public async Task Create_Should_Reject_QsvExtraHardwareFrames_Below_Minimum(int configured)
{
await SeedResolution(1);
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
MakeCreate(1, qsvExtraHardwareFrames: configured),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("at least 64");
await using TvContext context = _db.CreateContext();
(await context.FFmpegProfiles.CountAsync()).ShouldBe(0);
}
[TestCase(64)]
[TestCase(128)]
public async Task Create_Should_Store_QsvExtraHardwareFrames_Exactly_As_Submitted(int configured)
{
await SeedResolution(1);
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
@@ -155,15 +173,15 @@ public class FFmpegProfileHandlerTests
await using TvContext context = _db.CreateContext();
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId);
persisted.QsvExtraHardwareFrames.ShouldBe(expected);
persisted.QsvExtraHardwareFrames.ShouldBe(configured);
}
[TestCase(0, 64)]
[TestCase(-8, 64)]
[TestCase(128, 128)]
public async Task Update_Should_Floor_QsvExtraHardwareFrames(int configured, int expected)
[TestCase(0)]
[TestCase(-8)]
[TestCase(63)]
public async Task Update_Should_Reject_A_Newly_Submitted_QsvExtraHardwareFrames_Below_Minimum(int configured)
{
await SeedProfile(1);
await SeedProfile(1, qsvExtraHardwareFrames: 128);
await SeedResolution(1);
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
@@ -171,11 +189,59 @@ public class FFmpegProfileHandlerTests
MakeUpdate(1, qsvExtraHardwareFrames: configured),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("at least 64");
await using TvContext context = _db.CreateContext();
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
persisted.QsvExtraHardwareFrames.ShouldBe(128);
}
// the other half of the same rule: the SPA sends the whole profile back on every edit, so a row
// stored before this validation existed must stay editable over fields the operator did touch.
// an UNCHANGED out-of-range value is written back as-is and floored at render time instead
[Test]
public async Task Update_Should_Accept_An_Unchanged_Legacy_QsvExtraHardwareFrames()
{
await SeedProfile(1, qsvExtraHardwareFrames: 0);
await SeedResolution(1);
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, UpdateFFmpegProfileResult> result = await handler.Handle(
MakeUpdate(1, qsvExtraHardwareFrames: 0),
CancellationToken.None);
RightOf(result);
await using TvContext context = _db.CreateContext();
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
persisted.QsvExtraHardwareFrames.ShouldBe(expected);
persisted.QsvExtraHardwareFrames.ShouldBe(0);
new FFmpegState(
false,
HardwareAccelerationMode.None,
HardwareAccelerationMode.None,
None,
None,
None,
None,
false,
None,
None,
None,
None,
None,
OutputFormatKind.MpegTs,
None,
None,
None,
None,
TimeSpan.Zero,
None,
Optional(persisted.QsvExtraHardwareFrames),
false,
false,
"linear",
false)
.QsvExtraHardwareFrames.ShouldBe(FFmpegState.MinimumQsvExtraHardwareFrames);
}
// null means "unconfigured" and FFmpegState already resolves it to the same 64; it must stay
@@ -196,6 +262,133 @@ public class FFmpegProfileHandlerTests
persisted.QsvExtraHardwareFrames.ShouldBeNull();
}
// ersatztv#735: readrate pacing is an operator-tunable bounded field. out of band it is a dead
// channel either way — below realtime the client starves, above the ceiling the input is no
// longer meaningfully paced (which is the unthrottled read #529 measured to write no segments)
[TestCase(0.9)]
[TestCase(0.0)]
[TestCase(2.5)]
public async Task Create_Should_Reject_ReadRate_Outside_Bounds(double configured)
{
await SeedResolution(1);
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
MakeCreate(1, readRate: configured),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("Read rate must be between 1.0 and 2.0");
}
[TestCase(0.9)]
[TestCase(10.5)]
public async Task Create_Should_Reject_ReadRateCatchup_Outside_Bounds(double configured)
{
await SeedResolution(1);
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
MakeCreate(1, readRateCatchup: configured),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("Read rate catchup must be between 1.0 and 10.0");
}
// a catchup rate inside its own band can still be at or below the base rate, where it cannot
// let a lagging input recover — the cross-field bound is the one a per-field check cannot see.
// the EQUAL cases matter: zero headroom is functionally no catchup, while still reading as a
// configured one
[TestCase(null, 1.0)]
[TestCase(null, 1.05)]
[TestCase(1.5, 1.2)]
[TestCase(1.5, 1.5)]
[TestCase(2.0, 2.0)]
public async Task Create_Should_Reject_ReadRateCatchup_At_Or_Below_The_ReadRate(double? readRate, double catchup)
{
await SeedResolution(1);
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
MakeCreate(1, readRate: readRate, readRateCatchup: catchup),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("must be greater than the read rate");
}
[Test]
public async Task Create_Should_Persist_ReadRate_Pacing()
{
await SeedResolution(1);
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
MakeCreate(1, readRate: 1.2, readRateCatchup: 4.0),
CancellationToken.None);
CreateFFmpegProfileResult created = RightOf(result);
await using TvContext context = _db.CreateContext();
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId);
persisted.ReadRate.ShouldBe(1.2);
persisted.ReadRateCatchup.ShouldBe(4.0);
}
// unset is the default posture and must stay null: FFmpegState resolves null to the values the
// pipeline used before the fields existed, so an untouched profile paces exactly as it did
[Test]
public async Task Create_Should_Leave_Unset_ReadRate_Pacing_Null()
{
await SeedResolution(1);
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, CreateFFmpegProfileResult> result =
await handler.Handle(MakeCreate(1), CancellationToken.None);
CreateFFmpegProfileResult created = RightOf(result);
await using TvContext context = _db.CreateContext();
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId);
persisted.ReadRate.ShouldBeNull();
persisted.ReadRateCatchup.ShouldBeNull();
}
[Test]
public async Task Update_Should_Reject_ReadRate_Outside_Bounds()
{
await SeedProfile(1);
await SeedResolution(1);
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, UpdateFFmpegProfileResult> result = await handler.Handle(
MakeUpdate(1, readRate: 3.0),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("Read rate must be between 1.0 and 2.0");
await using TvContext context = _db.CreateContext();
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
persisted.ReadRate.ShouldBeNull();
}
[Test]
public async Task Update_Should_Persist_ReadRate_Pacing()
{
await SeedProfile(1);
await SeedResolution(1);
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, UpdateFFmpegProfileResult> result = await handler.Handle(
MakeUpdate(1, readRate: 1.5, readRateCatchup: 8.0),
CancellationToken.None);
RightOf(result);
await using TvContext context = _db.CreateContext();
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
persisted.ReadRate.ShouldBe(1.5);
persisted.ReadRateCatchup.ShouldBe(8.0);
}
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => throw new AssertionException($"Expected a Right result, got {e}"), Right: r => r);
@@ -209,12 +402,13 @@ public class FFmpegProfileHandlerTests
await context.SaveChangesAsync();
}
private async Task SeedProfile(int id)
private async Task SeedProfile(int id, int? qsvExtraHardwareFrames = null)
{
await using TvContext context = _db.CreateContext();
context.FFmpegProfiles.Add(new FFmpegProfile
{
Id = id,
QsvExtraHardwareFrames = qsvExtraHardwareFrames,
Name = "Default",
ThreadCount = 1,
NormalizeAudio = true,
@@ -249,7 +443,9 @@ public class FFmpegProfileHandlerTests
private static CreateFFmpegProfile MakeCreate(
int resolutionId,
bool qsvPreferNativeDecoder = true,
int? qsvExtraHardwareFrames = null) =>
int? qsvExtraHardwareFrames = null,
double? readRate = null,
double? readRateCatchup = null) =>
new(
"Default",
1,
@@ -281,13 +477,17 @@ public class FFmpegProfileHandlerTests
false,
false,
false,
qsvPreferNativeDecoder);
qsvPreferNativeDecoder,
readRate,
readRateCatchup);
private static UpdateFFmpegProfile MakeUpdate(
int id,
int resolutionId = 1,
bool qsvPreferNativeDecoder = true,
int? qsvExtraHardwareFrames = null) =>
int? qsvExtraHardwareFrames = null,
double? readRate = null,
double? readRateCatchup = null) =>
new(
id,
"Default",
@@ -320,5 +520,7 @@ public class FFmpegProfileHandlerTests
false,
false,
false,
qsvPreferNativeDecoder);
qsvPreferNativeDecoder,
readRate,
readRateCatchup);
}
@@ -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();
@@ -0,0 +1,166 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Tests.Support;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Paging;
/// <summary>
/// The MediaCards count/page pairs live in two separate repository methods rather than in one
/// handler, so `api.paged-count-matches-page-query` cannot be satisfied structurally there — the
/// two must be kept in agreement and pinned by a test instead. Each case below constructs the
/// divergence the count used to miss and asserts count == pageable rows.
/// Expected values are pinned literals, never re-derived from the method's own predicate.
/// </summary>
[TestFixture]
public class MediaCardsCountMatchesPageTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private TelevisionRepository TelevisionRepo =>
new(_db.Factory, NullLogger<TelevisionRepository>.Instance);
[Test]
public async Task GetSeasonCount_Should_Expand_To_The_Same_Shows_GetPagedSeasons_Pages()
{
// the same show present in two libraries: same Title+Year, different Show rows.
// GetPagedSeasons pages the union (2 + 3), so the count must be 5, not 2.
await using (TvContext context = _db.CreateContext())
{
context.Shows.Add(new Show { Id = 1 });
context.Shows.Add(new Show { Id = 2 });
context.ShowMetadata.Add(new ShowMetadata { Id = 201, ShowId = 1, Title = "Star Trek", Year = 1966 });
context.ShowMetadata.Add(new ShowMetadata { Id = 202, ShowId = 2, Title = "Star Trek", Year = 1966 });
// an unrelated show that must NOT be swept in
context.Shows.Add(new Show { Id = 3 });
context.ShowMetadata.Add(new ShowMetadata { Id = 203, ShowId = 3, Title = "Star Trek", Year = 1987 });
context.Seasons.Add(new Season { Id = 19, ShowId = 3, SeasonNumber = 1 });
context.Seasons.Add(new Season { Id = 11, ShowId = 1, SeasonNumber = 1 });
context.Seasons.Add(new Season { Id = 12, ShowId = 1, SeasonNumber = 2 });
context.Seasons.Add(new Season { Id = 13, ShowId = 2, SeasonNumber = 1 });
context.Seasons.Add(new Season { Id = 14, ShowId = 2, SeasonNumber = 2 });
context.Seasons.Add(new Season { Id = 15, ShowId = 2, SeasonNumber = 3 });
await context.SaveChangesAsync();
}
int count = await TelevisionRepo.GetSeasonCount(1);
List<Season> page = await TelevisionRepo.GetPagedSeasons(1, 1, 50, CancellationToken.None);
count.ShouldBe(5);
page.Count.ShouldBe(5);
count.ShouldBe(page.Count);
// pin WHICH rows, not just how many — a count and a page can agree on the wrong set
page.Select(s => s.Id).OrderBy(id => id).ShouldBe([11, 12, 13, 14, 15]);
}
[Test]
public async Task GetSeasonCount_Should_Be_Zero_When_The_Show_Has_No_Metadata()
{
// GetPagedSeasons returns nothing without a ShowMetadata row to expand from, so the count
// must agree rather than reporting the show's seasons
await using (TvContext context = _db.CreateContext())
{
context.Shows.Add(new Show { Id = 1 });
context.Seasons.Add(new Season { Id = 11, ShowId = 1, SeasonNumber = 1 });
await context.SaveChangesAsync();
}
int count = await TelevisionRepo.GetSeasonCount(1);
List<Season> page = await TelevisionRepo.GetPagedSeasons(1, 1, 50, CancellationToken.None);
count.ShouldBe(0);
page.ShouldBeEmpty();
}
[Test]
public async Task GetEpisodeCount_Should_Count_Episodes_That_Have_Metadata()
{
// 3 episodes, one of which lost its metadata row to a scanner failure. GetPagedEpisodes
// pages EpisodeMetadata, so only 2 are reachable and the count must say 2.
await using (TvContext context = _db.CreateContext())
{
// GetPagedEpisodes's include chain reaches Episode -> Season -> Show through REQUIRED
// reference navs, which EF emits as INNER JOINs, so a missing Season or Show row drops
// every row and would make the page 0 for a reason unrelated to the count under test.
// The ShowMetadata leg is a COLLECTION nav (LEFT JOIN) and drops nothing — the row below
// is incidental, seeded only to keep the graph realistic.
context.Shows.Add(new Show { Id = 1 });
context.ShowMetadata.Add(new ShowMetadata { Id = 201, ShowId = 1, Title = "Show", Year = 2000 });
context.Seasons.Add(new Season { Id = 11, ShowId = 1, SeasonNumber = 1 });
for (var i = 21; i <= 23; i++)
{
context.Episodes.Add(new Episode { Id = i, SeasonId = 11 });
}
context.EpisodeMetadata.Add(new EpisodeMetadata { Id = 221, EpisodeId = 21, EpisodeNumber = 1 });
context.EpisodeMetadata.Add(new EpisodeMetadata { Id = 222, EpisodeId = 22, EpisodeNumber = 2 });
// an episode in a different season must not be swept in
context.Seasons.Add(new Season { Id = 12, ShowId = 1, SeasonNumber = 2 });
context.Episodes.Add(new Episode { Id = 29, SeasonId = 12 });
context.EpisodeMetadata.Add(new EpisodeMetadata { Id = 229, EpisodeId = 29, EpisodeNumber = 1 });
await context.SaveChangesAsync();
}
int count = await TelevisionRepo.GetEpisodeCount(11);
List<EpisodeMetadata> page = await TelevisionRepo.GetPagedEpisodes(11, 1, 50);
count.ShouldBe(2);
page.Count.ShouldBe(2);
count.ShouldBe(page.Count);
// the two episodes WITH metadata, and not the other season's
page.Select(em => em.EpisodeId).OrderBy(id => id).ShouldBe([21, 22]);
}
[Test]
public async Task GetMusicVideoCount_Should_Count_Music_Videos_That_Have_Metadata()
{
// 3 music videos for the artist, one without a metadata row; GetPagedMusicVideos pages
// MusicVideoMetadata, so the count must be 2
await using (TvContext context = _db.CreateContext())
{
context.Artists.Add(new Artist { Id = 41 });
for (var i = 31; i <= 33; i++)
{
context.MusicVideos.Add(new MusicVideo { Id = i, ArtistId = 41 });
}
context.MusicVideoMetadata.Add(new MusicVideoMetadata { Id = 231, MusicVideoId = 31, Title = "A" });
context.MusicVideoMetadata.Add(new MusicVideoMetadata { Id = 232, MusicVideoId = 32, Title = "B" });
// another artist's video must not be swept in
context.Artists.Add(new Artist { Id = 42 });
context.MusicVideos.Add(new MusicVideo { Id = 39, ArtistId = 42 });
context.MusicVideoMetadata.Add(new MusicVideoMetadata { Id = 239, MusicVideoId = 39, Title = "C" });
await context.SaveChangesAsync();
}
var repo = new MusicVideoRepository(_db.Factory);
int count = await repo.GetMusicVideoCount(41);
List<MusicVideoMetadata> page = await repo.GetPagedMusicVideos(41, 1, 50);
count.ShouldBe(2);
page.Count.ShouldBe(2);
count.ShouldBe(page.Count);
// this artist's two videos with metadata, and not the other artist's
page.Select(m => m.Title).OrderBy(x => x).ShouldBe(["A", "B"]);
}
}
@@ -0,0 +1,286 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NUnit.Framework;
using Shouldly;
using DomainChannel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Tests.Application.Paging;
/// <summary>
/// Every paged handler whose page query applies a filter must compute its TotalCount from the SAME
/// query, or a filtered page reports the unfiltered total and the SPA paginates to pages that can
/// never contain anything (issues #690, #758).
/// Expected counts here are PINNED LITERALS derived from the seeded set by hand — never recomputed
/// by re-applying the handler's own predicate, which would pass whatever the handler happens to do.
/// </summary>
[TestFixture]
public class PagedQueryTotalCountTests
{
// 5 seeded rows, of which exactly these 2 contain "Alpha"
private const int SeededRows = 5;
private const int MatchingAlpha = 2;
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private static readonly string[] Names =
["Alpha One", "Beta", "Alpha Two", "Gamma", "Delta"];
[Test]
public async Task GetPagedCollections_Filtered_Count_Should_Match_Filter()
{
await using (TvContext context = _db.CreateContext())
{
for (var i = 0; i < Names.Length; i++)
{
context.Collections.Add(new Collection { Id = i + 1, Name = Names[i], MediaItems = [] });
}
await context.SaveChangesAsync();
}
var handler = new GetPagedCollectionsHandler(_db.Factory);
PagedMediaCollectionsViewModel unfiltered =
await handler.Handle(new GetPagedCollections(string.Empty, 0, 10), CancellationToken.None);
unfiltered.TotalCount.ShouldBe(SeededRows);
PagedMediaCollectionsViewModel filtered =
await handler.Handle(new GetPagedCollections("Alpha", 0, 10), CancellationToken.None);
filtered.TotalCount.ShouldBe(MatchingAlpha);
filtered.Page.Select(c => c.Name).ShouldBe(["Alpha One", "Alpha Two"]);
}
[Test]
public async Task GetPagedMultiCollections_Filtered_Count_Should_Match_Filter()
{
await using (TvContext context = _db.CreateContext())
{
for (var i = 0; i < Names.Length; i++)
{
context.MultiCollections.Add(new MultiCollection { Id = i + 1, Name = Names[i] });
}
// channel-owned rows are excluded from BOTH the page and the count, filter or no filter
context.MultiCollections.Add(
new MultiCollection { Id = 99, Name = "Alpha Owned", OwnedByChannelId = 7 });
await context.SaveChangesAsync();
}
var handler = new GetPagedMultiCollectionsHandler(_db.Factory);
PagedMultiCollectionsViewModel unfiltered =
await handler.Handle(new GetPagedMultiCollections(string.Empty, 0, 10), CancellationToken.None);
unfiltered.TotalCount.ShouldBe(SeededRows);
PagedMultiCollectionsViewModel filtered =
await handler.Handle(new GetPagedMultiCollections("Alpha", 0, 10), CancellationToken.None);
filtered.TotalCount.ShouldBe(MatchingAlpha);
filtered.Page.Select(mc => mc.Name).ShouldBe(["Alpha One", "Alpha Two"]);
}
[Test]
public async Task GetPagedSmartCollections_Filtered_Count_Should_Match_Filter()
{
await using (TvContext context = _db.CreateContext())
{
for (var i = 0; i < Names.Length; i++)
{
context.SmartCollections.Add(
new SmartCollection { Id = i + 1, Name = Names[i], Query = "tag:family" });
}
context.SmartCollections.Add(
new SmartCollection
{
Id = 99,
Name = "Alpha Owned",
Query = "tag:family",
OwnedByChannelId = 7
});
await context.SaveChangesAsync();
}
var handler = new GetPagedSmartCollectionsHandler(_db.Factory);
PagedSmartCollectionsViewModel unfiltered =
await handler.Handle(new GetPagedSmartCollections(string.Empty, 0, 10), CancellationToken.None);
unfiltered.TotalCount.ShouldBe(SeededRows);
PagedSmartCollectionsViewModel filtered =
await handler.Handle(new GetPagedSmartCollections("Alpha", 0, 10), CancellationToken.None);
filtered.TotalCount.ShouldBe(MatchingAlpha);
filtered.Page.Select(sc => sc.Name).ShouldBe(["Alpha One", "Alpha Two"]);
}
[Test]
public async Task GetPagedRerunCollections_Filtered_Count_Should_Match_Filter()
{
await using (TvContext context = _db.CreateContext())
{
for (var i = 0; i < Names.Length; i++)
{
context.RerunCollections.Add(
new RerunCollection
{
Id = i + 1,
Name = Names[i],
CollectionType = CollectionType.Collection,
CollectionId = i + 1
});
context.Collections.Add(new Collection { Id = i + 1, Name = Names[i], MediaItems = [] });
}
await context.SaveChangesAsync();
}
var handler = new GetPagedRerunCollectionsHandler(_db.Factory);
PagedRerunCollectionsViewModel unfiltered =
await handler.Handle(new GetPagedRerunCollections(string.Empty, 0, 10), CancellationToken.None);
unfiltered.TotalCount.ShouldBe(SeededRows);
PagedRerunCollectionsViewModel filtered =
await handler.Handle(new GetPagedRerunCollections("Alpha", 0, 10), CancellationToken.None);
filtered.TotalCount.ShouldBe(MatchingAlpha);
filtered.Page.Select(rc => rc.Name).ShouldBe(["Alpha One", "Alpha Two"]);
// the selection graph still loads for the page — moving IncludeSelectionDetails off the
// counted query must not stop the page from projecting it (issue #671)
filtered.Page.Select(rc => rc.Collection?.Name).ShouldBe(["Alpha One", "Alpha Two"]);
}
[Test]
public async Task GetPagedProgramSchedules_Filtered_Count_Should_Match_Filter()
{
await using (TvContext context = _db.CreateContext())
{
for (var i = 0; i < Names.Length; i++)
{
context.ProgramSchedules.Add(new ProgramSchedule { Id = i + 1, Name = Names[i] });
}
await context.SaveChangesAsync();
}
var handler = new GetPagedProgramSchedulesHandler(_db.Factory);
PagedProgramSchedulesViewModel unfiltered =
await handler.Handle(new GetPagedProgramSchedules(string.Empty, 0, 10), CancellationToken.None);
unfiltered.TotalCount.ShouldBe(SeededRows);
PagedProgramSchedulesViewModel filtered =
await handler.Handle(new GetPagedProgramSchedules("Alpha", 0, 10), CancellationToken.None);
filtered.TotalCount.ShouldBe(MatchingAlpha);
filtered.Page.Select(ps => ps.Name).ShouldBe(["Alpha One", "Alpha Two"]);
}
[Test]
public async Task GetPagedPlayouts_Filtered_Count_Should_Match_Filter()
{
await using (TvContext context = _db.CreateContext())
{
for (var i = 0; i < Names.Length; i++)
{
context.Channels.Add(NewChannel(i + 1, $"{i + 1}", Names[i]));
context.Playouts.Add(
new Playout
{
Id = i + 1,
ChannelId = i + 1,
ScheduleKind = PlayoutScheduleKind.Classic
});
}
// a playout whose channel row does not exist: excluded from the page by the
// `Channel != null` filter, so it must be excluded from the count too
context.Playouts.Add(
new Playout { Id = 99, ChannelId = 4242, ScheduleKind = PlayoutScheduleKind.Classic });
await context.SaveChangesAsync();
}
var handler = new GetPagedPlayoutsHandler(_db.Factory);
PagedPlayoutsViewModel unfiltered =
await handler.Handle(new GetPagedPlayouts(string.Empty, 0, 10), CancellationToken.None);
// 5, NOT 6 — the orphaned playout is filtered out of the page, so it is not part of the total
unfiltered.TotalCount.ShouldBe(SeededRows);
unfiltered.Page.Count.ShouldBe(SeededRows);
PagedPlayoutsViewModel filtered =
await handler.Handle(new GetPagedPlayouts("Alpha", 0, 10), CancellationToken.None);
filtered.TotalCount.ShouldBe(MatchingAlpha);
filtered.Page.Select(p => p.ChannelName).ShouldBe(["Alpha One", "Alpha Two"]);
}
[Test]
public async Task Filtered_Count_Should_Drive_A_Second_Page()
{
await using (TvContext context = _db.CreateContext())
{
for (var i = 0; i < Names.Length; i++)
{
context.Collections.Add(new Collection { Id = i + 1, Name = Names[i], MediaItems = [] });
}
await context.SaveChangesAsync();
}
var handler = new GetPagedCollectionsHandler(_db.Factory);
// pageSize 1 over the 2 matching rows: this is the property the issues are about — the count
// is what tells the client a SECOND page exists, and each page holds exactly its own row
PagedMediaCollectionsViewModel first =
await handler.Handle(new GetPagedCollections("Alpha", 0, 1), CancellationToken.None);
first.TotalCount.ShouldBe(MatchingAlpha);
first.Page.Select(c => c.Name).ShouldBe(["Alpha One"]);
PagedMediaCollectionsViewModel second =
await handler.Handle(new GetPagedCollections("Alpha", 1, 1), CancellationToken.None);
second.TotalCount.ShouldBe(MatchingAlpha);
second.Page.Select(c => c.Name).ShouldBe(["Alpha Two"]);
// and the page AFTER the last matching row is empty — with the pre-fix count of 5 the client
// would have been told to fetch three more pages that can never contain anything
PagedMediaCollectionsViewModel past =
await handler.Handle(new GetPagedCollections("Alpha", 2, 1), CancellationToken.None);
past.TotalCount.ShouldBe(MatchingAlpha);
past.Page.ShouldBeEmpty();
}
private static DomainChannel NewChannel(int id, string number, string name) =>
new(Guid.NewGuid())
{
Id = id,
Number = number,
SortNumber = id,
Name = name,
Group = "ErsatzTV",
Categories = string.Empty,
FFmpegProfileId = 1,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PreferredSubtitleLanguageCode = string.Empty,
MusicVideoCreditsTemplate = string.Empty,
StreamingMode = StreamingMode.TransportStreamHybrid,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous
};
}
@@ -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,151 @@
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
using NUnit.Framework;
using Shouldly;
using PlayoutsMapper = ErsatzTV.Application.Playouts.Mapper;
using SchedulingMapper = ErsatzTV.Application.Scheduling.Mapper;
namespace ErsatzTV.Tests.Application.Scheduling;
/// <summary>
/// ersatztv#823. Guarding <see cref="AlternateScheduleSelector" /> alone would have left the OTHER read
/// of the same six columns unguarded — the entity→view-model mappers, which feed
/// <c>PlayoutController</c>'s response models and therefore the SPA.
/// <para>
/// Two things break without the guard, and neither is a C# exception, which is why the selector
/// tests cannot see them. <c>web/src/screens/PlayoutScheduleEditors.tsx</c> spreads the collection
/// (<c>daysOfMonth: [...template.daysOfMonth]</c>) and throws <c>TypeError: not iterable</c> on a
/// JSON <c>null</c>; and <c>web/src/screens/playoutTemplateCalendar.ts</c>'s <c>appliesToDate</c> —
/// an exact TypeScript port of <see cref="AlternateScheduleSelector.GetScheduleForDate{T}" /> —
/// calls <c>.includes</c> on it.
/// </para>
/// <para>
/// So the mappers substitute the SAME unrestricted defaults the selector reads. That agreement is
/// the point: a DTO that said "empty" while the selector scheduled "unrestricted" would make the
/// preview calendar disagree with the playout it is previewing.
/// </para>
/// </summary>
[TestFixture]
public class RecurrenceLimitsMapperNullTests
{
private static Template MinimalTemplate() =>
new()
{
Id = 7,
Name = "T",
TemplateGroupId = 1,
TemplateGroup = new TemplateGroup { Name = "G" },
Items = []
};
[Test]
public void ProgramScheduleAlternate_Null_Collections_Map_To_Unrestricted()
{
var alternate = new ProgramScheduleAlternate
{
Id = 1,
Index = 0,
ProgramScheduleId = 2,
DaysOfWeek = null!,
DaysOfMonth = null!,
MonthsOfYear = null!
};
PlayoutAlternateScheduleViewModel vm = PlayoutsMapper.ProjectToViewModel(alternate);
vm.DaysOfWeek.ShouldBe(AlternateScheduleSelector.AllDaysOfWeek());
vm.DaysOfMonth.ShouldBe(AlternateScheduleSelector.AllDaysOfMonth());
vm.MonthsOfYear.ShouldBe(AlternateScheduleSelector.AllMonthsOfYear());
// Never assigned back: these are single-column primitive collections, so writing the guard onto a
// tracked entity would persist the substituted set over the NULL
// (media.nullable-primitive-collection-mutation).
alternate.DaysOfWeek.ShouldBeNull();
alternate.DaysOfMonth.ShouldBeNull();
alternate.MonthsOfYear.ShouldBeNull();
}
[Test]
public void PlayoutTemplate_Null_Collections_Map_To_Unrestricted()
{
var template = new PlayoutTemplate
{
Id = 1,
Index = 0,
Template = MinimalTemplate(),
DecoTemplate = null,
DaysOfWeek = null!,
DaysOfMonth = null!,
MonthsOfYear = null!
};
PlayoutTemplateViewModel vm = SchedulingMapper.ProjectToViewModel(template);
vm.DaysOfWeek.ShouldBe(AlternateScheduleSelector.AllDaysOfWeek());
vm.DaysOfMonth.ShouldBe(AlternateScheduleSelector.AllDaysOfMonth());
vm.MonthsOfYear.ShouldBe(AlternateScheduleSelector.AllMonthsOfYear());
template.DaysOfWeek.ShouldBeNull();
template.DaysOfMonth.ShouldBeNull();
template.MonthsOfYear.ShouldBeNull();
}
/// <summary>
/// An explicitly EMPTY collection is a recorded restriction of no days and must survive the mapper
/// unchanged. Without this, a guard written as "empty or null becomes All*" would pass the two tests
/// above while silently rewriting real user data on the way out.
/// <para>
/// There is one of these per MAPPER, not one in total. The two overloads are byte-identical
/// triples in different files, so a defensive edit to one alone is exactly the "one helper, two
/// callers" shape this repo has been bitten by: covering only the Playouts mapper would leave
/// the PlayoutTemplate one free to acquire an `empty-or-null` guard with the suite still green.
/// </para>
/// </summary>
[Test]
public void An_Explicitly_Empty_Collection_Is_Not_Rewritten_By_The_PlayoutTemplate_Mapper()
{
var template = new PlayoutTemplate
{
Id = 1,
Index = 0,
Template = MinimalTemplate(),
DecoTemplate = null,
DaysOfWeek = [],
DaysOfMonth = [],
MonthsOfYear = []
};
PlayoutTemplateViewModel vm = SchedulingMapper.ProjectToViewModel(template);
vm.DaysOfWeek.ShouldBeEmpty();
vm.DaysOfMonth.ShouldBeEmpty();
vm.MonthsOfYear.ShouldBeEmpty();
}
/// <summary>
/// The <c>ProgramScheduleAlternate</c> half of the same pair — see the PlayoutTemplate one above
/// for why there is one per MAPPER rather than one in total.
/// </summary>
[Test]
public void An_Explicitly_Empty_Collection_Is_Not_Rewritten()
{
var alternate = new ProgramScheduleAlternate
{
Id = 1,
Index = 0,
ProgramScheduleId = 2,
DaysOfWeek = [],
DaysOfMonth = [],
MonthsOfYear = []
};
PlayoutAlternateScheduleViewModel vm = PlayoutsMapper.ProjectToViewModel(alternate);
vm.DaysOfWeek.ShouldBeEmpty();
vm.DaysOfMonth.ShouldBeEmpty();
vm.MonthsOfYear.ShouldBeEmpty();
}
}
@@ -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.
@@ -217,7 +217,9 @@ public class FFmpegProfileControllerTests
false,
true,
false,
true);
true,
null,
null);
private static CreateFFmpegProfileRequest MakeCreateRequest() =>
new(
@@ -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)
}
]
};
}
@@ -0,0 +1,674 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Streaming.Graphics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using Shouldly;
using LanguageExt;
using SkiaSharp;
namespace ErsatzTV.Tests.Infrastructure.Graphics;
/// <summary>
/// Pixel-level cover for the #732 background box. These assert the RENDERED BITMAP rather than the
/// parsed model, because every failure mode this feature has is a silent no-op: the YAML parses, the
/// element initializes, and nothing is drawn. Geometry assertions are all RELATIVE to a no-box
/// baseline so they do not depend on which typeface the host resolves.
/// </summary>
[TestFixture]
public class TextElementBackgroundBoxTests
{
private const int FrameWidth = 1920;
// Derived from the documented rule, not a hardcoded 1080p result: hardcoding it makes correct
// production code fail the moment the test frame size changes.
private static int FrameInsetCap => Math.Min(FrameWidth, FrameHeight) / 2;
private const int FrameHeight = 1080;
private static TextGraphicsElement BaseElement() =>
new()
{
Name = "test",
Location = WatermarkLocation.BottomLeft,
BaseStyle = "body",
Styles =
[
new StyleDefinition
{
Name = "body",
// Required: a null font_family makes CustomFontMapper throw on a null dictionary
// key, which TextElement swallows into "disable for this content" (the #570 trap).
// The family need not resolve -- an unknown one falls back to Skia's default.
FontFamily = "Roboto",
FontSize = 40,
TextColor = "#FFFFFF"
}
],
Text = "Hello"
};
// TextElement swallows every initialization failure into a logged warning, so a broken render
// would otherwise surface as a null bitmap with no explanation. Capture the warning and rethrow.
private sealed class ThrowingLogger : ILogger
{
public IDisposable BeginScope<TState>(TState state) where TState : notnull => null!;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception exception,
Func<TState, Exception, string> formatter)
{
if (logLevel >= LogLevel.Warning && exception is not null)
{
throw new InvalidOperationException(formatter(state, exception), exception);
}
}
}
private static SKBitmap Render(TextGraphicsElement element)
{
var fonts = new GraphicsEngineFonts(new CustomFontMapper(NullLogger<CustomFontMapper>.Instance));
var textElement = new TextElement(fonts, element, new ThrowingLogger());
var context = new GraphicsEngineContext(
"1",
null,
[],
new Dictionary<string, object>(),
new Resolution { Width = FrameWidth, Height = FrameHeight },
new Resolution { Width = FrameWidth, Height = FrameHeight },
new FrameRate("30"),
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch,
TimeSpan.Zero,
TimeSpan.FromMinutes(1),
TimeSpan.FromMinutes(1));
textElement.InitializeAsync(context, CancellationToken.None).GetAwaiter().GetResult();
Option<PreparedElementImage> maybeImage = textElement
.PrepareImage(TimeSpan.Zero, TimeSpan.Zero, TimeSpan.FromMinutes(1), TimeSpan.Zero, CancellationToken.None)
.AsTask().GetAwaiter().GetResult();
PreparedElementImage prepared = maybeImage.IfNone(() => throw new InvalidOperationException(
"the element produced no image; initialization failed"));
return prepared.Image;
}
// The whole suite is meaningless if the host cannot lay out any text at all, so prove the
// baseline is non-degenerate rather than letting a 0x0 bitmap pass every relative assertion.
[Test]
public void Baseline_Renders_A_Non_Empty_Bitmap()
{
SKBitmap baseline = Render(BaseElement());
baseline.Width.ShouldBeGreaterThan(0);
baseline.Height.ShouldBeGreaterThan(0);
}
[Test]
public void No_Background_Fields_Leaves_Corner_Transparent()
{
SKBitmap baseline = Render(BaseElement());
baseline.GetPixel(0, 0).Alpha.ShouldBe((byte)0);
baseline.GetPixel(baseline.Width - 1, baseline.Height - 1).Alpha.ShouldBe((byte)0);
}
[Test]
public void Background_Color_Fills_The_Box()
{
TextGraphicsElement element = BaseElement();
element.BackgroundColor = "#FF0000";
SKBitmap rendered = Render(element);
SKColor corner = rendered.GetPixel(0, 0);
corner.Alpha.ShouldBe((byte)255);
corner.Red.ShouldBe((byte)255);
corner.Green.ShouldBe((byte)0);
corner.Blue.ShouldBe((byte)0);
}
[Test]
public void Background_Padding_Grows_The_Bitmap_On_Every_Side()
{
const int Padding = 12;
SKBitmap baseline = Render(BaseElement());
TextGraphicsElement element = BaseElement();
element.BackgroundColor = "#000000";
element.BackgroundPadding = Padding;
SKBitmap padded = Render(element);
padded.Width.ShouldBe(baseline.Width + (2 * Padding));
padded.Height.ShouldBe(baseline.Height + (2 * Padding));
}
// Growing the bitmap is not the same as moving the text into it. If the text were still painted
// at (0,0) the geometry assertions above would all still hold while the glyphs sat on the box
// edge, so pin the padding band itself as pure background.
[Test]
public void Background_Padding_Actually_Insets_The_Text()
{
const int Padding = 16;
TextGraphicsElement element = BaseElement();
element.BackgroundColor = "#FF0000";
element.BackgroundPadding = Padding;
SKBitmap rendered = Render(element);
// A full-width band inside the top padding must contain nothing but the fill colour.
for (var x = 0; x < rendered.Width; x++)
{
SKColor pixel = rendered.GetPixel(x, Padding / 2);
pixel.Red.ShouldBe((byte)255, $"pixel at x={x} in the padding band is not the fill colour");
pixel.Green.ShouldBe((byte)0, $"pixel at x={x} in the padding band is not the fill colour");
pixel.Blue.ShouldBe((byte)0, $"pixel at x={x} in the padding band is not the fill colour");
}
}
[Test]
public void Background_Opacity_Percent_Scales_The_Alpha()
{
TextGraphicsElement element = BaseElement();
element.BackgroundColor = "#FF0000";
element.BackgroundOpacityPercent = 50;
SKBitmap rendered = Render(element);
// 255 * 0.5, rounded. Skia stores premultiplied alpha, so assert the alpha channel only.
rendered.GetPixel(0, 0).Alpha.ShouldBe((byte)128);
}
[Test]
public void Corner_Radius_Rounds_The_Corner_Away()
{
TextGraphicsElement element = BaseElement();
element.BackgroundColor = "#FF0000";
element.BackgroundPadding = 20;
element.BackgroundCornerRadius = 20;
SKBitmap rendered = Render(element);
// The extreme corner falls outside a 20px radius, the middle of the left edge does not.
rendered.GetPixel(0, 0).Alpha.ShouldBe((byte)0);
rendered.GetPixel(0, rendered.Height / 2).Alpha.ShouldBe((byte)255);
}
[Test]
public void Border_Color_Draws_A_Border_Distinct_From_The_Fill()
{
TextGraphicsElement element = BaseElement();
element.BackgroundColor = "#FF0000";
element.BackgroundPadding = 20;
element.BorderColor = "#00FF00";
element.BorderWidth = 4;
SKBitmap rendered = Render(element);
// On the border ring...
SKColor edge = rendered.GetPixel(1, rendered.Height / 2);
edge.Green.ShouldBeGreaterThan((byte)200);
edge.Red.ShouldBeLessThan((byte)100);
// ...and inside it, still the fill.
SKColor inside = rendered.GetPixel(10, rendered.Height / 2);
inside.Red.ShouldBe((byte)255);
inside.Green.ShouldBe((byte)0);
}
[Test]
public void Border_Width_Is_Included_In_The_Bitmap_So_The_Border_Is_Not_Clipped()
{
const int BorderWidth = 6;
SKBitmap baseline = Render(BaseElement());
TextGraphicsElement element = BaseElement();
element.BorderColor = "#00FF00";
element.BorderWidth = BorderWidth;
SKBitmap bordered = Render(element);
bordered.Width.ShouldBe(baseline.Width + (2 * BorderWidth));
bordered.Height.ShouldBe(baseline.Height + (2 * BorderWidth));
}
[Test]
public void Unparseable_Background_Color_Draws_No_Box_Rather_Than_Substituting_One()
{
SKBitmap baseline = Render(BaseElement());
TextGraphicsElement element = BaseElement();
element.BackgroundColor = "not-a-color";
element.BackgroundPadding = 25;
SKBitmap rendered = Render(element);
// No box means no padding either: the geometry is the untouched baseline.
rendered.Width.ShouldBe(baseline.Width);
rendered.Height.ShouldBe(baseline.Height);
rendered.GetPixel(0, 0).Alpha.ShouldBe((byte)0);
}
[Test]
public void Border_Color_Without_An_Explicit_Width_Draws_A_Hairline()
{
SKBitmap baseline = Render(BaseElement());
TextGraphicsElement element = BaseElement();
element.BorderColor = "#00FF00";
SKBitmap rendered = Render(element);
rendered.Width.ShouldBe(baseline.Width + 2);
rendered.GetPixel(0, rendered.Height / 2).Green.ShouldBeGreaterThan((byte)200);
}
// A box with no padding or border must not move anything: this is the seam where "adding a
// background" could silently change an existing overlay's geometry.
[Test]
public void A_Fill_With_No_Padding_Or_Border_Leaves_The_Geometry_Untouched()
{
SKBitmap baseline = Render(BaseElement());
TextGraphicsElement element = BaseElement();
element.BackgroundColor = "#FF0000";
SKBitmap filled = Render(element);
filled.Width.ShouldBe(baseline.Width);
filled.Height.ShouldBe(baseline.Height);
}
// The inset is rounded up to a whole pixel and the SAME integer is used on both sides. Subtracting
// the unrounded value from the wrap budget while adding its ceiling to the bitmap overflows
// width_percent by a rounding remainder, which an integer padding cannot expose.
[Test]
public void Fractional_Padding_Still_Respects_Width_Percent()
{
const double WidthPercent = 20;
foreach (double padding in new[] { 12.3, 14.0, 29.7, 0.5 })
{
TextGraphicsElement element = BaseElement();
element.Text = "The quick brown fox jumps over the lazy dog and keeps running past the edge";
element.Fit = TextFit.Wrap;
element.WidthPercent = WidthPercent;
element.BackgroundColor = "#000000";
element.BackgroundPadding = padding;
SKBitmap rendered = Render(element);
var budget = (int)Math.Round(WidthPercent / 100.0 * FrameWidth);
rendered.Width.ShouldBeLessThanOrEqualTo(budget, $"padding {padding} overflowed the budget");
}
}
// An inset wider than the budget cannot be honoured AND stay inside it, so the INSET is clamped
// rather than the text being squeezed to nothing. Asserting a width bound here would be wrong:
// wrapping cannot break below one glyph, so a narrow width_percent overflows with or without a
// box (pre-existing). What the clamp guarantees is that the box's own contribution stops growing
// at the budget -- so a runaway padding renders identically to the largest one that fits.
[Test]
public void An_Oversized_Padding_Is_Clamped_To_The_Largest_That_Fits()
{
const double WidthPercent = 20;
var budget = (int)Math.Round(WidthPercent / 100.0 * FrameWidth);
int maxInset = (budget - 1) / 2;
static TextGraphicsElement WithPadding(double padding)
{
TextGraphicsElement element = BaseElement();
element.Text = "The quick brown fox jumps over the lazy dog";
element.Fit = TextFit.Wrap;
element.WidthPercent = WidthPercent;
element.BackgroundColor = "#000000";
element.BackgroundPadding = padding;
return element;
}
SKBitmap runaway = Render(WithPadding(400));
SKBitmap clamped = Render(WithPadding(maxInset));
runaway.Width.ShouldBe(clamped.Width);
runaway.Height.ShouldBe(clamped.Height);
// and the clamp actually bit -- an unclamped 400px padding would add 800px of box
runaway.Width.ShouldBeLessThan(budget + (2 * maxInset));
}
// FitTextBlock rebuilds every style from scratch. It used to drop the halo, which only became
// reachable once a box's insets could push a previously-fitting element into the Scale path --
// adding a background would then silently remove the halo behind the text.
[Test]
public void The_Scale_Path_Preserves_The_Halo()
{
static TextGraphicsElement Scaled(bool withHalo)
{
TextGraphicsElement element = BaseElement();
element.Text = "The quick brown fox jumps over the lazy dog";
element.Fit = TextFit.Scale;
element.WidthPercent = 15;
element.BackgroundColor = "#000000";
element.BackgroundPadding = 12;
element.Styles[0].HaloColor = withHalo ? "#00FF00" : null;
element.Styles[0].HaloWidth = withHalo ? 3 : null;
return element;
}
SKBitmap withHalo = Render(Scaled(true));
SKBitmap withoutHalo = Render(Scaled(false));
withHalo.Width.ShouldBe(withoutHalo.Width);
withHalo.Height.ShouldBe(withoutHalo.Height);
var differing = 0;
for (var x = 0; x < withHalo.Width; x++)
{
for (var y = 0; y < withHalo.Height; y++)
{
if (withHalo.GetPixel(x, y) != withoutHalo.GetPixel(x, y))
{
differing++;
}
}
}
// If the Scale path dropped the halo, the two renders would be pixel-identical.
differing.ShouldBeGreaterThan(0, "the halo made no difference through the Scale path");
}
// width_percent bounds the ELEMENT. If the insets were not subtracted from the wrap width the
// box would overflow the budget by 2*inset, which is exactly the bug this pins.
[Test]
public void Width_Percent_Bounds_The_Whole_Box_Including_Padding()
{
const double WidthPercent = 20;
const int Padding = 30;
TextGraphicsElement element = BaseElement();
element.Text = "The quick brown fox jumps over the lazy dog and keeps on running well past the edge";
element.Fit = TextFit.Wrap;
element.WidthPercent = WidthPercent;
element.BackgroundColor = "#000000";
element.BackgroundPadding = Padding;
SKBitmap rendered = Render(element);
var budget = (int)Math.Round(WidthPercent / 100.0 * FrameWidth);
rendered.Width.ShouldBeLessThanOrEqualTo(budget);
}
// YAML yields doubles, so 1e100 and NaN are reachable from a config file. Cast to float they
// become Infinity/NaN, and (int)Math.Ceiling of those is an unspecified value that sails past
// every clamp and can wrap the doubled inset back to zero.
[Test]
public void Non_Finite_And_Absurd_Box_Values_Do_Not_Corrupt_The_Geometry()
{
SKBitmap baseline = Render(BaseElement());
foreach (double bad in new[] { 1e100, double.NaN, double.PositiveInfinity, -5.0 })
{
TextGraphicsElement element = BaseElement();
element.BackgroundColor = "#FF0000";
element.BackgroundPadding = bad;
SKBitmap rendered = Render(element);
rendered.Width.ShouldBeGreaterThan(0, $"padding {bad} produced a degenerate width");
rendered.Height.ShouldBeGreaterThan(0, $"padding {bad} produced a degenerate height");
rendered.Width.ShouldBeGreaterThanOrEqualTo(baseline.Width, $"padding {bad} shrank the element");
rendered.Width.ShouldBeLessThanOrEqualTo(baseline.Width + (2 * FrameInsetCap), $"padding {bad} was not clamped");
}
}
[Test]
public void A_Non_Finite_Width_Percent_Is_Treated_As_No_Budget()
{
SKBitmap baseline = Render(BaseElement());
// This pins the OUTCOME -- a non-finite budget behaves like no budget -- not the
// float.IsFinite guard, which no mutation can distinguish because .NET saturates float-to-int
// conversion. See the "Not covered by any mutation" note in docs/graphics-elements.md.
const int Padding = 10;
TextGraphicsElement element = BaseElement();
element.Fit = TextFit.Wrap;
element.WidthPercent = 1e300;
element.BackgroundColor = "#FF0000";
element.BackgroundPadding = Padding;
SKBitmap rendered = Render(element);
rendered.Width.ShouldBe(baseline.Width + (2 * Padding));
rendered.Height.ShouldBe(baseline.Height + (2 * Padding));
}
// Clamping insetPixels alone leaves DrawBackgroundBox stroking at the ORIGINAL border width,
// centred on a rect that no longer has room for it -- the stroke then floods the whole element
// and paints over the interior. The box's own fields must be clamped too.
[Test]
public void An_Oversized_Border_Is_Clamped_And_Does_Not_Flood_The_Element()
{
TextGraphicsElement element = BaseElement();
element.Text = "The quick brown fox jumps over the lazy dog";
element.Fit = TextFit.Wrap;
element.WidthPercent = 20;
element.BackgroundColor = "#0000FF";
element.BorderColor = "#FF0000";
element.BorderWidth = 400;
SKBitmap rendered = Render(element);
// Somewhere inside the element there must still be fill, not solid border.
var fillPixels = 0;
for (var x = 0; x < rendered.Width; x++)
{
for (var y = 0; y < rendered.Height; y++)
{
SKColor px = rendered.GetPixel(x, y);
if (px.Blue > 200 && px.Red < 100)
{
fillPixels++;
}
}
}
fillPixels.ShouldBeGreaterThan(0, "the border flooded the element; no fill survived");
}
// Sanitize bounds each field on its own, but padding and border ADD UP, and with no
// width_percent nothing else bounded them -- two fields could allocate a bitmap far larger than
// the frame it is drawn onto. This pins the frame cap itself; the non-finite test above cannot,
// because its bound is looser than the growth this clause prevents.
[Test]
public void The_Inset_Is_Capped_Against_The_Frame_Even_With_No_Width_Percent()
{
SKBitmap baseline = Render(BaseElement());
TextGraphicsElement element = BaseElement();
element.BackgroundColor = "#FF0000";
element.BackgroundPadding = 4000;
element.BorderColor = "#00FF00";
element.BorderWidth = 4000;
SKBitmap rendered = Render(element);
// 8000px of requested inset collapses to the frame cap, not to 16000px of bitmap growth.
rendered.Width.ShouldBe(baseline.Width + (2 * FrameInsetCap));
rendered.Height.ShouldBe(baseline.Height + (2 * FrameInsetCap));
}
// halo_* is documented as a per-style field. The merge loop used to rebuild each style from the
// base and then override only font/colour, so a non-base style's halo silently inherited the
// base one -- invisible in the seeded template, whose three styles declare identical halos.
[Test]
public void A_Non_Base_Style_Uses_Its_Own_Halo_Not_The_Base_Styles()
{
static TextGraphicsElement WithSubHalo(float haloWidth)
{
TextGraphicsElement element = BaseElement();
element.Styles.Add(new StyleDefinition
{
Name = "sub",
FontFamily = "Roboto",
FontSize = 40,
TextColor = "#FFFFFF",
HaloColor = "#00FF00",
HaloWidth = haloWidth
});
element.Text = "[sub]Hello[/sub]";
return element;
}
SKBitmap thin = Render(WithSubHalo(1));
SKBitmap thick = Render(WithSubHalo(6));
static int HaloPixels(SKBitmap b)
{
var n = 0;
for (var x = 0; x < b.Width; x++)
{
for (var y = 0; y < b.Height; y++)
{
SKColor px = b.GetPixel(x, y);
if (px.Green > 150 && px.Red < 120)
{
n++;
}
}
}
return n;
}
// If the style's own halo were ignored, both renders would use the base style's (none) and
// neither would contain halo pixels.
HaloPixels(thin).ShouldBeGreaterThan(0, "the style's own halo was not applied");
HaloPixels(thick).ShouldBeGreaterThan(HaloPixels(thin), "halo_width had no effect per style");
}
private static int PixelsMatching(SKBitmap b, Func<SKColor, bool> predicate)
{
var n = 0;
for (var x = 0; x < b.Width; x++)
{
for (var y = 0; y < b.Height; y++)
{
if (predicate(b.GetPixel(x, y)))
{
n++;
}
}
}
return n;
}
// halo_blur is a per-style field too, and the merge loop drops it just as silently as halo_color
// did. Blur spreads the halo over more pixels at lower alpha, so a blurred halo covers more area.
[Test]
public void A_Non_Base_Style_Uses_Its_Own_Halo_Blur()
{
static TextGraphicsElement WithSubBlur(float blur)
{
TextGraphicsElement element = BaseElement();
element.Styles.Add(new StyleDefinition
{
Name = "sub",
FontFamily = "Roboto",
FontSize = 40,
TextColor = "#FFFFFF",
HaloColor = "#00FF00",
HaloWidth = 3,
HaloBlur = blur
});
element.Text = "[sub]Hello[/sub]";
return element;
}
SKBitmap sharp = Render(WithSubBlur(0));
SKBitmap blurred = Render(WithSubBlur(5));
static bool Greenish(SKColor px) => px.Green > 60 && px.Red < 140 && px.Alpha > 0;
PixelsMatching(blurred, Greenish)
.ShouldNotBe(PixelsMatching(sharp, Greenish), "halo_blur had no effect per style");
}
// FitTextBlock rebuilds every style from scratch on the Scale path. line_height and halo_blur are
// carried across there; without them a scaled element silently loses line spacing and halo blur.
[Test]
public void The_Scale_Path_Preserves_Line_Height()
{
static TextGraphicsElement Scaled(float lineHeight)
{
TextGraphicsElement element = BaseElement();
element.Text = "The quick brown fox jumps over the lazy dog";
element.Fit = TextFit.Scale;
element.WidthPercent = 15;
element.Styles[0].LineHeight = lineHeight;
return element;
}
SKBitmap tight = Render(Scaled(1.0f));
SKBitmap loose = Render(Scaled(2.5f));
loose.Height.ShouldBeGreaterThan(tight.Height, "line_height was dropped by the Scale path");
}
[Test]
public void The_Scale_Path_Preserves_Halo_Blur()
{
static TextGraphicsElement Scaled(float blur)
{
TextGraphicsElement element = BaseElement();
element.Text = "The quick brown fox jumps over the lazy dog";
element.Fit = TextFit.Scale;
element.WidthPercent = 15;
element.Styles[0].HaloColor = "#00FF00";
element.Styles[0].HaloWidth = 3;
element.Styles[0].HaloBlur = blur;
return element;
}
SKBitmap sharp = Render(Scaled(0));
SKBitmap blurred = Render(Scaled(5));
static bool Greenish(SKColor px) => px.Green > 60 && px.Red < 140 && px.Alpha > 0;
PixelsMatching(blurred, Greenish)
.ShouldNotBe(PixelsMatching(sharp, Greenish), "halo_blur was dropped by the Scale path");
}
// The documented range is 0-100. The sole opacity test used 50, so Math.Clamp could be removed
// with everything green while an out-of-range value wrapped to an unrelated alpha.
[Test]
public void Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range()
{
static SKColor CornerAt(int percent)
{
TextGraphicsElement element = BaseElement();
element.BackgroundColor = "#FF0000";
element.BackgroundOpacityPercent = percent;
return Render(element).GetPixel(0, 0);
}
CornerAt(-50).Alpha.ShouldBe((byte)0, "a negative opacity did not clamp to 0");
CornerAt(400).Alpha.ShouldBe((byte)255, "an opacity over 100 did not clamp to 100");
}
}
@@ -0,0 +1,366 @@
using ErsatzTV.Core.Domain;
using GraphicsElement = ErsatzTV.Core.Domain.GraphicsElement;
using ErsatzTV.Core.Graphics;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Streaming.Graphics;
using ErsatzTV.Tests.Support;
using Microsoft.EntityFrameworkCore;
using System.IO.Abstractions;
using Microsoft.Extensions.Logging.Abstractions;
using Testably.Abstractions.Testing;
using ErsatzTV.Core;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Infrastructure;
/// <summary>
/// #732 part 2: the On Now / Next overlay is a default rather than an opt-in. Channels that predate
/// that decision are backfilled once -- and only once, so a channel an operator deliberately clears
/// is never silently re-attached on the next restart.
/// </summary>
[TestFixture]
public class GraphicsElementDefaultAttachTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private static async Task<int> SeedBuiltInElement(TvContext context)
{
var element = new GraphicsElement
{
Path = GraphicsElementDefaults.OnNowNextSeededPath,
Name = "On Now / Next",
Kind = GraphicsElementKind.Text
};
await context.GraphicsElements.AddAsync(element);
await context.SaveChangesAsync();
return element.Id;
}
private static async Task<Channel> SeedChannel(
TvContext context,
string number,
StreamingMode mode = StreamingMode.HttpLiveStreamingSegmenter)
{
var channel = new Channel(Guid.NewGuid())
{
Name = $"Channel {number}",
Number = number,
StreamingMode = mode,
ChannelGraphicsElements = []
};
await context.Channels.AddAsync(channel);
await context.SaveChangesAsync();
return channel;
}
private static async Task<List<int>> AttachedElementIds(TvContext context, int channelId) =>
await context.Set<ChannelGraphicsElement>()
.AsNoTracking()
.Where(cge => cge.ChannelId == channelId)
.Select(cge => cge.GraphicsElementId)
.ToListAsync();
[Test]
public async Task Attaches_The_Built_In_Element_To_Existing_Channels()
{
await using TvContext context = _db.CreateContext();
int elementId = await SeedBuiltInElement(context);
Channel one = await SeedChannel(context, "1");
Channel two = await SeedChannel(context, "2");
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
(await AttachedElementIds(context, one.Id)).ShouldBe([elementId]);
(await AttachedElementIds(context, two.Id)).ShouldBe([elementId]);
}
[Test]
public async Task Skips_Hls_Direct_Channels_Where_The_Overlay_Cannot_Render()
{
await using TvContext context = _db.CreateContext();
await SeedBuiltInElement(context);
Channel direct = await SeedChannel(context, "1", StreamingMode.HttpLiveStreamingDirect);
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
(await AttachedElementIds(context, direct.Id)).ShouldBeEmpty();
}
[Test]
public async Task Does_Not_Duplicate_An_Existing_Attachment()
{
await using TvContext context = _db.CreateContext();
int elementId = await SeedBuiltInElement(context);
Channel channel = await SeedChannel(context, "1");
await context.AddAsync(
new ChannelGraphicsElement { ChannelId = channel.Id, GraphicsElementId = elementId });
await context.SaveChangesAsync();
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
(await AttachedElementIds(context, channel.Id)).Count.ShouldBe(1);
}
// The load-bearing property: a default must not fight the operator.
[Test]
public async Task Does_Not_Re_Attach_After_An_Operator_Clears_It()
{
await using TvContext context = _db.CreateContext();
await SeedBuiltInElement(context);
Channel channel = await SeedChannel(context, "1");
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
(await AttachedElementIds(context, channel.Id)).Count.ShouldBe(1);
// operator turns the overlay off for this channel
context.Set<ChannelGraphicsElement>()
.RemoveRange(context.Set<ChannelGraphicsElement>().Where(cge => cge.ChannelId == channel.Id));
await context.SaveChangesAsync();
// ...and the app restarts
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
(await AttachedElementIds(context, channel.Id)).ShouldBeEmpty();
}
// The upgrade population this backfill exists for -- an install seeding the template for the
// first time on this boot -- must not be stranded. The GraphicsElement row is normally created
// by RefreshGraphicsElements, which runs long after startup, so the seeder ensures it itself.
// Without that, the marker would be written against an unresolved element and every pre-existing
// channel would go permanently unattached.
[Test]
public async Task Backfills_On_The_Same_Boot_That_First_Seeds_The_Template()
{
var fs = new MockFileSystem();
await using TvContext context = _db.CreateContext();
Channel channel = await SeedChannel(context, "1");
// no ConfigElement markers and no GraphicsElement row: a pre-#74 install meeting #732
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
(await AttachedElementIds(context, channel.Id)).Count.ShouldBe(1);
}
[Test]
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 = System.IO.Path.Combine(
"/config/graphics-elements/text/some-subfolder",
GraphicsElementDefaults.OnNowNextFileName),
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: 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_Case_Variant_Of_The_Seeded_Path()
{
await using TvContext context = _db.CreateContext();
await context.GraphicsElements.AddAsync(
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");
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
(await AttachedElementIds(context, channel.Id)).ShouldBeEmpty();
}
// The marker is permanent, so writing it with nothing resolved would strand every channel. Stay
// armed instead and pick the work up once the element exists.
[Test]
public async Task Stays_Armed_When_There_Is_No_Built_In_Element_To_Attach()
{
await using TvContext context = _db.CreateContext();
Channel channel = await SeedChannel(context, "1");
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
(await context.ConfigElements
.AnyAsync(c => c.Key == ConfigElementKey.GraphicsOnNowNextDefaultAttached.Key))
.ShouldBeFalse("the marker was written with nothing to attach");
// the element turns up later; the backfill must still do its job
await SeedBuiltInElement(context);
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
(await AttachedElementIds(context, channel.Id)).Count.ShouldBe(1);
}
// The already-seeded branch of SeedOnNowNext has its own EnsureBuiltInElementRow call. Removing
// it reddened nothing until this test existed -- the other tests all exercise a FRESH seed.
[Test]
public async Task An_Already_Seeded_Install_Missing_Its_Element_Row_Gets_One()
{
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.SaveChangesAsync();
(await context.GraphicsElements.CountAsync()).ShouldBe(0);
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
// Assert the ROW, not just that one exists: a lookup-only assertion is satisfied by a row
// with a null Name, which sorts the built-in element into the unnamed bucket in the SPA.
List<GraphicsElement> rows = await context.GraphicsElements.ToListAsync();
rows.Count.ShouldBe(1);
rows[0].Path.ShouldBe(target);
rows[0].Kind.ShouldBe(GraphicsElementKind.Text);
rows[0].Name.ShouldBe(GraphicsElementDefaults.OnNowNextName);
(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
// the half we accept. See graphics.on-now-next-on-by-default.
[Test]
public async Task While_Armed_A_Restored_Element_Is_Attached_To_Every_Eligible_Channel()
{
await using TvContext context = _db.CreateContext();
Channel kept = await SeedChannel(context, "1");
Channel cleared = await SeedChannel(context, "2");
// nothing to resolve yet -> stays armed, no marker
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
(await context.ConfigElements
.AnyAsync(c => c.Key == ConfigElementKey.GraphicsOnNowNextDefaultAttached.Key))
.ShouldBeFalse();
// The element reappears and is attached to BOTH channels; the operator then clears `cleared`.
// Doing the attach-then-remove for real matters: seeding `cleared` with no join at all would
// only prove an untouched channel gets backfilled, which is not the claim.
int elementId = await SeedBuiltInElement(context);
await context.AddAsync(new ChannelGraphicsElement { ChannelId = kept.Id, GraphicsElementId = elementId });
await context.AddAsync(new ChannelGraphicsElement { ChannelId = cleared.Id, GraphicsElementId = elementId });
await context.SaveChangesAsync();
(await AttachedElementIds(context, cleared.Id)).Count.ShouldBe(1);
context.Set<ChannelGraphicsElement>()
.RemoveRange(context.Set<ChannelGraphicsElement>().Where(cge => cge.ChannelId == cleared.Id));
await context.SaveChangesAsync();
(await AttachedElementIds(context, cleared.Id)).ShouldBeEmpty();
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
// documented consequence: the still-armed backfill cannot see that deliberate clear
(await AttachedElementIds(context, kept.Id)).Count.ShouldBe(1);
(await AttachedElementIds(context, cleared.Id)).Count.ShouldBe(1);
// ...but it is now marked, so it never fires again
(await context.ConfigElements
.AnyAsync(c => c.Key == ConfigElementKey.GraphicsOnNowNextDefaultAttached.Key))
.ShouldBeTrue();
}
}
@@ -6,6 +6,7 @@ using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Streaming.Graphics;
using ErsatzTV.Tests.Support;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using Shouldly;
using YamlDotNet.Serialization;
@@ -36,7 +37,7 @@ public class GraphicsElementSeederTests
var fs = new MockFileSystem();
await using TvContext context = _db.CreateContext();
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
fs.File.Exists(_seededPath).ShouldBeTrue();
fs.File.ReadAllText(_seededPath).ShouldContain("epg_entries: 2");
@@ -51,7 +52,7 @@ public class GraphicsElementSeederTests
await fs.File.WriteAllTextAsync(_seededPath, "name: Operator Custom\nepg_entries: 2\n");
await using TvContext context = _db.CreateContext();
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
fs.File.ReadAllText(_seededPath).ShouldContain("Operator Custom");
}
@@ -62,9 +63,9 @@ public class GraphicsElementSeederTests
var fs = new MockFileSystem();
await using TvContext context = _db.CreateContext();
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
fs.File.Delete(_seededPath);
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
fs.File.Exists(_seededPath).ShouldBeFalse();
}
@@ -77,7 +78,7 @@ public class GraphicsElementSeederTests
{
var fs = new MockFileSystem();
await using TvContext context = _db.CreateContext();
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
string yaml = await fs.File.ReadAllTextAsync(_seededPath);
IDeserializer deserializer = new DeserializerBuilder()
@@ -0,0 +1,365 @@
using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Streaming.Graphics;
using ErsatzTV.Tests.Support;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
using Testably.Abstractions.Testing;
using Testably.Abstractions.Testing.FileSystem;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace ErsatzTV.Tests.Infrastructure;
/// <summary>
/// #732: the seeder writes the On Now / Next template once and never revisits it, so a change to the
/// shipped default would reach new databases only. These pin the upgrade path that fixes that, and
/// the boundary that keeps it from clobbering an operator's edits.
/// </summary>
[TestFixture]
public class GraphicsElementSeederUpgradeTests
{
// Byte-for-byte the default shipped before #732. Verified 2026-08-26 against the live prod
// install at 192.168.1.29 (md5 ef9afc088cf6dba252f725babbf3334f), so this is a real
// fingerprint rather than a copy of the constant it is meant to detect.
private const string OnNowNextYamlV1 =
"""
name: On Now / Next
epg_entries: 2
location: BottomLeft
horizontal_margin_percent: 4
vertical_margin_percent: 8
width_percent: 42
text_fit: Wrap
text_align: Left
z_index: 100
# transparent until 4s in, fade in 1s, hold 6s, fade out 1s
opacity_expression: "LinearFadeDuration(content_seconds, 4, 1, 6)"
base_style: now
styles:
- name: now
font_family: "Noto Sans"
font_size: 30
font_weight: 700
text_color: "#FFFFFF"
halo_color: "#000000"
halo_width: 2
- name: sub
font_family: "Noto Sans"
font_size: 22
font_weight: 400
text_color: "#DDDDDD"
halo_color: "#000000"
halo_width: 2
- name: next
font_family: "Noto Sans"
font_size: 22
font_weight: 400
text_color: "#DDDDDD"
halo_color: "#000000"
halo_width: 2
text: |
[now]NOW {{ Epg[0].Title }}[/now]
{{ if Epg[0].SubTitle }}[sub]{{ Epg[0].SubTitle }}[/sub]{{ end }}
{{ if (array.size Epg) > 1 }}[next]NEXT {{ Epg[1].Title }}[/next]{{ end }}
""";
private InMemoryTvContext _db = null!;
private string _target = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_target = Path.Combine(
FileSystemLayout.GraphicsElementsTextTemplatesFolder,
GraphicsElementDefaults.OnNowNextFileName);
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private async Task<MockFileSystem> RunSeederOverAlreadySeededDatabase(string existingContent)
{
var fs = new MockFileSystem();
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
if (existingContent is not null)
{
await fs.File.WriteAllTextAsync(_target, existingContent);
}
await using TvContext context = _db.CreateContext();
// The fixture DB is shared across calls within a test, so only seed the marker once.
string key = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
if (!context.ConfigElements.Any(c => c.Key == key))
{
context.ConfigElements.Add(new ConfigElement { Key = key, Value = "true" });
await context.SaveChangesAsync();
}
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
return fs;
}
[Test]
public async Task Upgrades_An_Untouched_Previous_Default()
{
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(OnNowNextYamlV1);
string result = await fs.File.ReadAllTextAsync(_target);
result.ShouldNotBe(OnNowNextYamlV1);
result.ShouldContain("background_color");
result.ShouldContain("background_padding");
}
[Test]
public async Task Upgrades_An_Untouched_Previous_Default_With_Windows_Line_Endings()
{
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(
OnNowNextYamlV1.Replace("\n", "\r\n"));
string result = await fs.File.ReadAllTextAsync(_target);
result.ShouldContain("background_color");
}
[Test]
public async Task Leaves_An_Operator_Modified_File_Alone()
{
// One changed value is enough to stop matching the fingerprint.
string edited = OnNowNextYamlV1.Replace("width_percent: 42", "width_percent: 30");
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(edited);
string result = await fs.File.ReadAllTextAsync(_target);
result.ShouldBe(edited);
result.ShouldNotContain("background_color");
}
[Test]
public async Task Leaves_The_Current_Default_Alone_So_The_Upgrade_Is_Idempotent()
{
MockFileSystem first = await RunSeederOverAlreadySeededDatabase(OnNowNextYamlV1);
string upgraded = await first.File.ReadAllTextAsync(_target);
MockFileSystem second = await RunSeederOverAlreadySeededDatabase(upgraded);
string again = await second.File.ReadAllTextAsync(_target);
again.ShouldBe(upgraded);
}
// The upgrade runs inside DatabaseMigratorService, ahead of DatabaseIsReady(). Before #732 the
// already-seeded branch touched the filesystem not at all, so a template the app cannot read --
// e.g. edited as root via `docker exec` while the app runs as PUID/PGID -- used to boot fine.
// It must not become a failure to start.
[Test]
public async Task An_Unwritable_Template_Does_Not_Fail_Startup()
{
var fs = new MockFileSystem();
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
// the file matches a shipped default, so the upgrade WILL try to rewrite it -- and that write
// is denied, standing in for a root-owned or read-only template
var intercepted = 0;
fs.Intercept.Changing(
FileSystemTypes.File,
_ =>
{
intercepted++;
throw new UnauthorizedAccessException("simulated permission denial");
});
await using TvContext context = _db.CreateContext();
string key = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
context.ConfigElements.Add(new ConfigElement { Key = key, Value = "true" });
await context.SaveChangesAsync();
await Should.NotThrowAsync(
() => GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None));
// Without this the test would pass just as happily if the upgrade never reached the write.
intercepted.ShouldBeGreaterThan(0, "the write interceptor never fired");
// and the original template survives the denied write
(await fs.File.ReadAllTextAsync(_target)).ShouldBe(OnNowNextYamlV1);
}
// The write path is not the only one that can fault. A template the app cannot READ used to be
// harmless on an already-seeded install; it must stay that way. An exclusive lock produces a
// genuine ReadAllTextAsync failure rather than an intercepted write dressed up as one.
[Test]
public async Task A_Read_Failure_On_The_Template_Does_Not_Fail_Startup()
{
var fs = new MockFileSystem();
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
await using TvContext context = _db.CreateContext();
context.ConfigElements.Add(
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
await context.SaveChangesAsync();
await using Stream exclusive = fs.File.Open(_target, FileMode.Open, FileAccess.Read, FileShare.None);
// prove the lock actually denies a read, so the test cannot pass by never hitting one
Should.Throw<IOException>(() => fs.File.ReadAllText(_target));
await Should.NotThrowAsync(
() => GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None));
}
[Test]
public async Task Does_Not_Create_The_File_When_It_Is_Absent()
{
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(null);
fs.File.Exists(_target).ShouldBeFalse();
}
[Test]
public async Task The_Upgraded_Template_Still_Deserializes_With_A_Resolvable_Base_Style()
{
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(OnNowNextYamlV1);
string yaml = await fs.File.ReadAllTextAsync(_target);
IDeserializer deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
var element = deserializer.Deserialize<TextGraphicsElement>(yaml);
element.ShouldNotBeNull();
element.BackgroundColor.ShouldBe("#000000");
element.BackgroundOpacityPercent.ShouldBe(65);
element.BackgroundPadding.ShouldBe(14);
element.BackgroundCornerRadius.ShouldBe(8);
// The border is what makes the box visible over dark content; without it the translucent
// black fill is indistinguishable from the frame behind it.
element.BorderColor.ShouldBe("#59FFFFFF");
element.BorderWidth.ShouldBe(1);
// #570: every style needs a font_family, and base_style must resolve.
element.Styles.ShouldNotBeEmpty();
element.Styles.ShouldAllBe(s => s.FontFamily != null);
element.Styles.ShouldContain(s => s.Name == element.BaseStyle);
}
// Without the duplicate guard in EnsureBuiltInElementRow the already-seeded branch inserts a
// fresh row on EVERY boot: RefreshGraphicsElements will neither reap them (the file exists) nor
// dedupe them, so the row set grows without bound. Idempotence of the FILE is not idempotence
// of the ROW, and the existing idempotence test only looks at the file.
[Test]
public async Task Repeated_Seeding_Does_Not_Accumulate_Element_Rows()
{
var fs = new MockFileSystem();
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
await using TvContext context = _db.CreateContext();
context.ConfigElements.Add(
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
await context.SaveChangesAsync();
for (var i = 0; i < 3; i++)
{
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
}
(await context.GraphicsElements.ToListAsync()).Count.ShouldBe(1);
}
// A failed write must not leave a truncated template behind: it would match no fingerprint, so
// the upgrade could never repair it, and the loader rejects malformed YAML outright.
[Test]
public async Task A_Failed_Write_Leaves_The_Original_Template_Intact()
{
var fs = new MockFileSystem();
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
// Capture WHICH path the write targets. Asserting only "the original survived" cannot tell
// an atomic write from an in-place one here: Testably raises the interception BEFORE it
// truncates, so a plain WriteAllTextAsync(target) would leave the file intact too -- on a
// real filesystem it would not. The path is what actually distinguishes them.
var writtenPaths = new List<string>();
fs.Intercept.Changing(
FileSystemTypes.File,
c =>
{
writtenPaths.Add(c.Path);
throw new IOException("simulated disk full");
});
await using TvContext context = _db.CreateContext();
context.ConfigElements.Add(
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
await context.SaveChangesAsync();
await Should.NotThrowAsync(
() => GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None));
writtenPaths.ShouldNotBeEmpty("the write interceptor never fired");
writtenPaths.ShouldAllBe(path => path.EndsWith(".upgrade.tmp"), "the upgrade wrote the live template in place instead of a temp file");
(await fs.File.ReadAllTextAsync(_target)).ShouldBe(OnNowNextYamlV1);
fs.Directory.GetFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder, "*.upgrade.tmp")
.ShouldBeEmpty("a temp file was left behind");
}
// The sibling test faults on the FIRST write, so it never reaches File.Move or the cleanup. Fault
// the replace instead, after a complete temp write: that is the path where a non-atomic
// implementation would already have truncated the live template.
[Test]
public async Task A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact()
{
var fs = new MockFileSystem();
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
var seenPaths = new List<string>();
fs.Intercept.Event(
c =>
{
seenPaths.Add($"{c.ChangeType}:{c.Path}");
// let the temp file be written in full; fail only when the live template is touched
if (c.Path == _target)
{
throw new IOException("simulated replace failure");
}
},
_ => true);
await using TvContext context = _db.CreateContext();
context.ConfigElements.Add(
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
await context.SaveChangesAsync();
await Should.NotThrowAsync(
() => GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None));
seenPaths.ShouldContain(path => path.EndsWith(".upgrade.tmp"), "no temp file was ever written");
// Assert the replace is a RENAME, not merely "the target was touched after the temp was".
// A File.Copy(temp, target, true) also touches both in that order and would leave the
// original intact under this mock (interception runs before the change), so path ordering
// alone cannot tell an atomic replace from a truncating one -- the change TYPE can.
seenPaths.ShouldContain($"Renamed:{_target}", "the replace was not an atomic rename");
// The whole point of write-then-move: the live template is untouched by a failed replace.
(await fs.File.ReadAllTextAsync(_target)).ShouldBe(OnNowNextYamlV1);
// and nothing this call created is left behind
fs.Directory.GetFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder, "*.upgrade.tmp")
.ShouldBeEmpty("a temp file was left behind");
}
}

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