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
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
182 changed files with 11869 additions and 1653 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
+17 -22
View File
@@ -9,8 +9,8 @@
# "re-review the fix commit, not just the initial PR diff").
#
# EVERY ONE OF THOSE IS A SNAPSHOT, taken when the merge tool is called. The window is SMALL for an
# immediate merge and UNBOUNDED for a scheduled one. Small is not zero, and this comment used to say
# "sound", which is the overclaim ersatztv#778 removed: this hook returns `allow` and a SEPARATE call
# immediate merge and UNBOUNDED for a scheduled one. Small is not zero, and calling this gate
# "sound" is the overclaim ersatztv#778 removed: this hook returns `allow` and a SEPARATE call
# performs the merge, so a push can still land in between. The merge API accepts an optional
# `head_commit_id` that would make that call a true compare-and-set; a PreToolUse hook cannot add an
# argument, only refuse without one. With merge_when_checks_succeed, Gitea merges
@@ -45,7 +45,8 @@
# 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:-...}` (ersatztv#858, #891).
# 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.
@@ -55,12 +56,6 @@ set -euo pipefail
# 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.
#
# The other twelve tracked hooks still carry the env-var-first form and are deliberately NOT changed
# here; that sweep needs its own population and review (ersatztv#891, where the reachable case is
# measured — husky launches the prepush hooks as `./.claude/hooks/...`, a RELATIVE path independent
# of `$CLAUDE_PROJECT_DIR`, so the two roots genuinely diverge there). This copy is fixed because
# leaving a total gate bypass 500 lines above the gate this PR hardens would make the rest decorative.
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
@@ -191,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).
#
@@ -212,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")
@@ -506,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
@@ -590,8 +585,8 @@ else
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. The first draft gave it an arm that set `unreadable-rules`, the same value
# the catch-all sets, and that arm was measured to be a no-op: deleting it left the WHOLE suite
# 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.)
@@ -706,7 +701,7 @@ fi
# 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.
# 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). Written once there
# 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.
@@ -715,8 +710,8 @@ fi
# 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. The twelve other hooks still resolve it from
# the env var and are ersatztv#891.
# 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."
@@ -775,7 +770,7 @@ esac
# `else` (mwcs = true) and never executes on an immediate merge, which is the common case; hanging
# the freshness check off it would fire it only when an auto-merge is armed. This file already
# records that exact defect one section up — the base re-read "first landed inside the
# scheduled-auto-merge branch only", and cold review found scheduled+retarget denied while
# scheduled-auto-merge branch only", with scheduled+retarget denied while
# immediate+retarget auto-GRANTED. Same shape, so it is not repeated here.
#
# It reads `main` (the branch the snapshot names), NOT `$base_ref`. That is a DIFFERENT question
+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
+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 }
+7 -2
View File
@@ -60,8 +60,13 @@ on:
# this file no longer republishes on its own, so pair it with a `docker/ci/**` edit.
#
# `paths:` here and `ci-image-pin`'s `expected` pathspec in pr-checks.yml MUST name the same
# sources, and nothing mechanically enforces that since the shared self-reference went —
# ersatztv#855.
# sources. Since the shared self-reference went, `scripts/tests/test_ci_image_paths_pin_agreement.py`
# is what holds them together: it derives BOTH lists from these two workflows and compares them for
# set equality (ersatztv#855). The two are written in different glob dialects, so it models exactly
# one pair of spellings — `<dir>/**` here against the pathspec `<dir>` — and REFUSES anything else
# rather than canonicalising a pattern space whose spellings the two consumers treat differently.
# Change this list and that guard goes red until the pathspec follows; write it any other way and
# it goes red asking for the new shape to be modelled.
workflow_dispatch:
push:
branches: [main]
+33 -40
View File
@@ -120,16 +120,32 @@ env:
# is NOT granted, and that holds at any owner default — it is not conditional on Restricted being on.
# Only `review-verdict.yml` needs write; it declares that at the job and says why there. Full
# rationale and the per-workflow credential audit: docs/ci-cd.md -> "Workflow token scope".
# Every credentialed thing this file does uses the scoped REGISTRY_* PAT, never the injected token:
# its registry pushes, its five `container:` image pulls, its three commit-status GET steps
# (`ETV_STATUS_AUTH` in jobs `test`, `migrations` and `functional-e2e`, each a read-only GET via
# scripts/ci-detect-already-validated.sh) and its registry tag READ (`ETV_REGISTRY_AUTH` in job
# `toolchain-preflight`, via scripts/ci-toolchain-image-resolves.sh). The injected token therefore
# serves only its eight `actions/checkout` steps. Note this file needs no `packages:` unit for that
# same reason: the `container:` blocks carry explicit `credentials:`.
# NO JOB ON THE `pull_request` ROUTE NAMES A STORED SECRET (ersatztv#885,
# `ci.pr-route-carries-no-stored-credential`). Gitea resolves a `pull_request` run from the PR HEAD,
# so this file is attacker-authored on that route and every `secrets.*` it names is materialised
# into the run. The scoped REGISTRY_* PAT is therefore held by `build` alone, which is gated
# `if: github.event_name != 'pull_request'`. The jobs that used to hold it now work without it:
# the five `container:` pulls and `toolchain-preflight`'s registry tag READ go through the registry's
# anonymous bearer-token flow, and the three commit-status GETs
# (scripts/ci-detect-already-validated.sh in `test`, `migrations` and `functional-e2e`) read the
# combined-status API unauthenticated. Those are TWO dependencies, on two different objects, with
# opposite failure directions — do not collapse them into one "keep it public or CI breaks loudly".
# The `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version of it
# reports `"repository": null`), so THIS repo's visibility is not what gates the anonymous pull
# token. (1) The five pulls and the preflight need that PACKAGE to stay anonymously pullable, and
# losing it IS loud: every `container:` job dies at image pull, before it runs a step, both
# required contexts among them, and `toolchain-preflight` names the cause in its own 401/403
# message. (2) The three commit-status GETs need `timothy/ersatztv` itself to stay publicly
# readable, and losing that is SILENT: `curl -sf` fails, ci-detect-already-validated.sh falls
# through to `skip=false`, and the jobs stay GREEN — only the ersatztv#420 cross-run skip quietly
# stops firing, which costs a redundant re-validation and never a skip that was not earned. The
# invariant is held by
# scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret.
# The injected token serves only this file's eight `actions/checkout` steps. Note it needs no
# `packages:` unit: the container pulls are anonymous, not token-authenticated.
# (Sites above are named by JOB, not by line number: this file is ~1150 lines, so any edit above a
# citation silently invalidates it — which is how the first version of this comment went stale two
# lines after it was written.)
# citation silently invalidates it — a line-number citation here has gone stale within two lines
# of being written.)
permissions:
code: read
@@ -150,8 +166,6 @@ jobs:
with:
persist-credentials: false
- name: Resolve the pinned toolchain tag in the registry
env:
ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark resolve
scripts/ci-toolchain-image-resolves.sh
@@ -165,9 +179,6 @@ jobs:
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
env:
CI_EXECUTION_CLASS: toolchain
CI_JOB_ROLE: guard
@@ -195,8 +206,6 @@ jobs:
scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark revalidate
scripts/ci-detect-already-validated.sh
@@ -389,9 +398,6 @@ jobs:
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
# Independent gate (not a 'needs' of build yet) so the new MySql-service dependency
# can't block image builds until it's proven reliable on the runner. Promote to a
# required check / build dependency once green. (ersatztv#13)
@@ -468,8 +474,6 @@ jobs:
scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark revalidate
scripts/ci-detect-already-validated.sh
@@ -611,9 +615,6 @@ jobs:
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
env:
CI_EXECUTION_CLASS: toolchain
CI_JOB_ROLE: guard
@@ -632,8 +633,6 @@ jobs:
run: scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: scripts/ci-detect-already-validated.sh
- name: Cache NuGet packages
@@ -727,9 +726,9 @@ jobs:
# runs at all — the image is not built, let alone pushed. Fail-closed by dependency, not by
# assertion.
#
# WHY IT RUNS THE REAL PYTEST rather than a bespoke scanner. The first cut of #767 hand-parsed the
# workflow YAML in stdlib Python, to avoid provisioning PyYAML on `build`'s bare runner. Two
# independent reviews found ~10 false NEGATIVES in that parser within one round (flow mappings
# WHY IT RUNS THE REAL PYTEST rather than a bespoke scanner. A stdlib hand-parser of the workflow
# YAML was TRIED AND REJECTED in #767 (its appeal: no PyYAML to provision on `build`'s bare
# runner). That parser had ~10 false NEGATIVES, all found at once (flow mappings
# `{run: …}`, a quoted `"run":` key, aliases, multiline quoted scalars) — i.e. it was strictly
# WEAKER than the check it was meant to backstop, in the one direction that matters for a security
# gate. Running the existing PyYAML-based test needs no second implementation of "what is a `run:`
@@ -785,16 +784,16 @@ jobs:
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark ban
PYTHONPATH=. python3 -m pytest scripts/tests/test_ci_dropped_step_guard.py scripts/tests/test_ci_release_path_scan_job.py -q
# THE POSITIVE CONTROL, and it is deliberately NOT a test (ersatztv#767). The step above proves
# the ban HOLDS; it cannot prove the ban would NOTICE. Review disarmed the entire gate with one
# repo-root `pytest.ini` (`addopts = -k "not delimiter_banned"`) or `conftest.py`
# (`pytest_collection_modifyitems`), which deselects the ban test and every test guarding it,
# the ban HOLDS; it cannot prove the ban would NOTICE. DEMONSTRATED: ONE repo-root `pytest.ini`
# (`addopts = -k "not delimiter_banned"`) or `conftest.py` (`pytest_collection_modifyitems`)
# disarms the entire gate, deselecting the ban test and every test guarding it,
# leaving all jobs green with a delimiter sitting in `Smoke`. Nothing inside pytest can be
# trusted to catch that, because pytest's own configuration outranks it.
#
# So this poisons the checked-out workflow, re-runs the SAME command, and fails the job if it
# PASSES. It runs in the real checkout — an isolated copy does not inherit the repo-root config
# a disarm would live in, which made the first version of this script report healthy while the
# job's real invocation was deselected. The workflow file is restored by an EXIT trap.
# a disarm would live in, so a check run in a copy reports healthy while the
# job's real invocation is deselected. The workflow file is restored by an EXIT trap.
- name: Prove the ban would DETECT a delimiter (ersatztv#767)
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark selfcheck
@@ -1044,9 +1043,6 @@ jobs:
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
if: github.event_name == 'pull_request'
env:
CI_EXECUTION_CLASS: toolchain
@@ -1149,9 +1145,6 @@ jobs:
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
if: github.event_name == 'pull_request'
env:
CI_EXECUTION_CLASS: toolchain
+10 -5
View File
@@ -106,9 +106,14 @@ jobs:
# runner placement than for build recipe. Make a recipe change alongside a `docker/ci/**`
# edit — a comment bump suffices, and it is the ONLY remedy: pinning the workflow-only
# commit is rejected here, because `expected` is the last `docker/ci` commit.
# Nothing MECHANICALLY couples this pathspec to `ci-image.yml`'s `on.push.paths`; before
# This pathspec and `ci-image.yml`'s `on.push.paths` MUST name the same sources; before
# #744 the shared self-reference kept them in step. Divergence is silent and green in the
# dangerous direction — tracked in ersatztv#855.
# dangerous direction, so it is enforced rather than asserted:
# `scripts/tests/test_ci_image_paths_pin_agreement.py` derives BOTH lists from the two
# workflows and compares them for set equality (ersatztv#855). It takes this pathspec from
# the ASSIGNMENT below rather than from any `git log` in the job, and models only a plain
# `<dir>` against `<dir>/**` there — any other spelling is refused rather than compared.
# Change this pathspec and that guard goes red until `on.push.paths` follows.
# See docs/ci-cd.md -> "Publishing from a branch is a dispatch, not a push".
#
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
@@ -535,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
+152 -80
View File
@@ -73,6 +73,40 @@ name: Review verdict
# ignored: the BASE definition ran and posted `h10=pending`, on both `opened` and `synchronize`,
# with `secrets` still available.
#
# THE DEFINITION-RESOLUTION HALF is re-established FROM THE v1.27.1 SOURCE, 2026-09-02
# (ersatztv#869) — the mechanism behind the measurement above. In
# `services/actions/notifier_helper.go` one function resolves the two triggers from two SEPARATELY
# RESOLVED refs: for a PR event `input.Ref` resolves to the PR's own HEAD ref (`WithPullRequest`
# defaults it to `pr.GetGitHeadRefName()` when the caller has not already set one), and workflows
# detected at that commit are kept only if their trigger is NOT `pull_request_target`; the base is
# then resolved separately as `git.BranchPrefix + input.PullRequest.BaseBranch`, and workflows
# detected there are kept only if their trigger IS `pull_request_target`. A head rewrite cannot
# reach this file's definition at all.
#
# WHICH HALF, precisely — the source covers definition resolution and NOTHING ELSE. It does not
# speak to which `types` produce a run, nor to secrets availability.
#
# What IS established on 1.27.1, and stated no wider than it was measured: `pull_request_target`
# fires for THIS workflow continuously — 210 such runs across 189 distinct head shas, counting only
# runs started strictly after the 2026-08-05 upgrade (measured 2026-09-02). The WINDOW is part of
# the claim, and the all-time REPO-WIDE figure is the WRONG window: 258 runs /
# 233 shas = 44 before the upgrade date + 4 ON it + 210 after. Citing 258 here would import
# 1.25.4 evidence into a 1.27.1 sentence. This workflow's own all-time count is 250; the difference
# is 8 runs from three since-deleted probe workflows, all pre-upgrade — which is why every one of
# the 210 post-upgrade runs is this file.
# The 4 same-day runs are excluded rather than assigned, because the upgrade landed mid-session and
# their 17:41-22:36 UTC start times cannot be placed either side of it without the exact upgrade
# time. Read those as UTC deliberately: this endpoint returns `started_at` Z-suffixed, while other
# Gitea timestamps render in the host zone, Europe/Brussels — `+02:00` in August, `+01:00` in
# winter — so a UI comparison shows different clock times.
# What that does NOT establish, deliberately: WHICH `types:` fire (a run count identifies no event
# type), and whether the STORED secret store is reachable under this trigger — this job references
# only the injected `secrets.GITEA_TOKEN`. Both of those stay 1.25.4-dated.
#
# That is also why `branches: [main]` is load-bearing rather than tidy: the base is resolved by
# BRANCH NAME, not by a pinned sha, so the base definition is whatever that branch points at when
# the event fires.
#
# `pull_request_target` is normally the DANGEROUS trigger, and it is worth being explicit about why
# that reputation does not transfer here. Its footgun is running untrusted HEAD code with a
# privileged token. This job never checks out the head and never executes anything the PR supplies:
@@ -251,7 +285,7 @@ jobs:
# exactly one event — the next run saw a machine-written `pending`, re-derived it, and
# posted `success` again, with its own freshly-taken high-water mark now ABOVE the human
# row, so the post-write check stayed silent and the rejection went green a second time.
# Found by cold review. Refusing here can only ever withhold an exemption, never grant one.
# Refusing here can only ever withhold an exemption, never grant one.
REPAIR_DESC="Human verdict raced this exemption write — re-post the verdict"
# THE SECOND SENTINEL, and the two are NOT interchangeable (ersatztv#849). `REPAIR_DESC`
# asserts a fact — a human verdict existed and this job's write buried it. Most of the
@@ -351,8 +385,8 @@ jobs:
# Package Management versions live in `Directory.Packages.props`, so a `.csproj` edit
# attributed to Renovate is anomalous by construction. Such a PR is not blocked, it simply
# needs a real verdict, which is the correct handling for a PR carrying source changes.
# NOTE the npm manifests are deliberately ABSENT. An earlier draft included
# `web/package.json` / `web/package-lock.json` "so a first SPA bump cannot deadlock". That was
# NOTE the npm manifests are deliberately ABSENT. Including
# `web/package.json` / `web/package-lock.json` "so a first SPA bump cannot deadlock" would be
# a self-inflicted code-execution vector for zero benefit: `renovate.json` sets
# `enabledManagers: ["nuget", "github-actions", "dockerfile"]`, so Renovate does not manage npm
# in this repo at all, while `package.json` carries `scripts` that CI EXECUTES (`npm ci`,
@@ -382,7 +416,7 @@ jobs:
# not prose about the project — they are the documents that DEFINE the completion protocol,
# the merge-consent convention and the H10 rule itself. `.claude/` being protected while the
# file that specifies what `.claude/` enforces was docs-only-exempt is the same
# self-exemption the header rules out, one directory over. Found by cold review
# self-exemption the header rules out, one directory over. Measured
# (ersatztv#751): driving the real classify body with a lone `CLAUDE.md` change produced
# `review-verdict/h10=success`, "Exempt: docs-only change (no code, no protected path)".
#
@@ -447,9 +481,9 @@ jobs:
# every time. Measured 2026-08-29, this repo puts 8 contexts on a `main` head. That case
# already stalled before this change — with an ABSENT required check, which reads as "not
# reviewed yet" — so what changes is that the stall now says why.
# THE RETURN VALUE IS THE WHOLE POINT, and the first version did not have one: the `else`
# branch ended with a successful `echo`, so the function returned 0 after BOTH POSTs failed
# and its fence caller took the `exit 0` beside it as though the head had been marked. An
# THE RETURN VALUE IS THE WHOLE POINT. Without one the `else`
# branch ends with a successful `echo`, so the function returns 0 after BOTH POSTs failed
# and its fence caller takes the `exit 0` beside it as though the head had been marked. An
# explicit `return` per arm, and every caller acts on it.
replace_unknown_state() { # $1 = the ::error:: naming what could not be established
echo "::error::$1"
@@ -474,10 +508,10 @@ jobs:
exit 1
}
# DEFINED HERE, BEFORE ANY USE. An earlier round defined these AFTER the classification
# chain that calls them, so `count_matching` was `command not found` on every run, the
# PROTECTED branch silently never fired, and three "protected path" tests still passed
# they reached `pending` by another route, so the guard being dead was invisible.
# DEFINED HERE, BEFORE ANY USE. Defining them AFTER the classification
# chain that calls them makes `count_matching` `command not found` on every run, the
# PROTECTED branch silently never fires, and three "protected path" tests still pass —
# they reach `pending` by another route, so the guard being dead is invisible.
#
# HOW THE PATH PREDICATES ARE EVALUATED, and why neither obvious spelling is used.
#
@@ -525,7 +559,7 @@ jobs:
# --- Is there already a verdict for THIS sha? ----------------------------------------
# NOTE the heading no longer says "never overwrite". It cannot promise that: the read below
# NOTE this heading does NOT say "never overwrite". It cannot promise that: the read below
# and the POST at the end of this job are not atomic, so a human verdict posted in between is
# still overwritten. The re-read immediately before the POST narrows that window; it does not
# close it. Tracked as ersatztv#706 rather than claimed as solved.
@@ -548,8 +582,8 @@ jobs:
read_existing_verdict() {
local json row rv try
# `.statuses` IS `null`, NOT `[]`, ON A HEAD WITH NO STATUSES YET — the same nil-slice
# serialization as the timeline terminator, found by cold review of the fix for that one
# (ersatztv#751). Measured on this instance: PR #739's head 5fa672e2 returns
# serialization as the timeline terminator (ersatztv#751).
# Measured on this instance: PR #739's head 5fa672e2 returns
# `{"state":"pending","total_count":0,"statuses":null}`. An `array`-only gate read that as
# unreadable and took the `exit 1` below, so the job posted NOTHING — fail-closed, but the
# user-visible outcome is exactly the one this issue is about: an exempt PR left with no
@@ -599,7 +633,7 @@ jobs:
st_kind=$(printf '%s' "$json" | jq -r '.statuses | type' 2>/dev/null) || st_kind=""
# NUMBER, not `jq -r` text: `jq -r` renders the JSON number 0 and the JSON string "0"
# identically, so a schema-corrupted `"total_count": "0"` would satisfy a string compare
# (cold review reproduced this). Requiring the type as well pins the accept path to a real
# (measured, not assumed). Requiring the type as well pins the accept path to a real
# numeric zero.
st_total=$(printf '%s' "$json" | jq -r 'if (.total_count | type) == "number" then (.total_count | tostring) else "x" end' 2>/dev/null) || st_total="x"
st_ok=no
@@ -637,7 +671,7 @@ jobs:
replace_unknown_and_die "Could not read existing commit statuses for ${SHA:0:7} (.statuses was '${st_kind:-unparseable}', total_count '${st_total}') after a retry, so any ${CONTEXT} already on this head — including one posted by an account this gate does not accept verdicts from — can neither be read nor re-derived."
fi
# `// []` so the null case cannot hard-error here under `set -e` once it is accepted above.
# TYPE-SAFE, AND A SCHEMA FAILURE REPLACES RATHER THAN DYING (ersatztv#849 round 3).
# TYPE-SAFE, AND A SCHEMA FAILURE REPLACES RATHER THAN DYING (ersatztv#849).
# `.statuses` being an array was checked; its ELEMENTS were not. A single scalar in that
# array makes `select(.context == ...)` hard-error, jq exits 5, and under `set -e` this
# unguarded assignment took the step down — BEFORE any of the replacement paths below,
@@ -668,7 +702,7 @@ jobs:
# direction for a required check, which must withhold a green rather than grant one.
unreadable=$(printf '%s' "$json" | jq -r '[(.statuses // [])[] | select(type != "object" or ((.context | type) != "string"))] | length' 2>/dev/null) || unreadable=""
case "$unreadable" in ''|*[!0-9]*) unreadable=1 ;; esac
# ANY UNREADABLE ELEMENT, whether or not a target row was also found. Round 8 scoped this
# ANY UNREADABLE ELEMENT, whether or not a target row was also found. A narrowing scoped it
# to "no readable row was found", on the reasoning that a malformed row BESIDE one we did
# read is noise. That reasoning does not hold: an element whose `.context` cannot be read
# cannot be shown to be a different context, so it may be a mangled rendering of this
@@ -724,8 +758,8 @@ jobs:
# 736649b3 returned 8 rows keyed id/context/status/creator/description/created_at/
# updated_at/url, ids 14..30 ascending. `// ""` so a server that ever stopped sending it
# degrades to the pre-existing text comparison rather than to a false "changed".
# A WRONG TYPE IS NOT AN ABSENT VALUE (ersatztv#849 round 4). Round 3 type-tested these
# four fields and resolved a failure to `""` — which for `.creator` means "no creator",
# A WRONG TYPE IS NOT AN ABSENT VALUE (ersatztv#849). Type-testing these
# four fields and resolving a failure to `""` means, for `.creator`, "no creator",
# i.e. unattributable, i.e. RE-DERIVE. That turned a corrupt row into an exemption
# `success` where `origin/main` had died before writing anything: measured, a head
# carrying `h10=failure` with `"creator": 7` posts `Exempt: docs-only change` here and
@@ -750,9 +784,7 @@ jobs:
# `.creator.login` HARD-ERRORS on any non-object creator — the same defect #763 fixed in
# the post-write filter, still live on this read. `(.creator | type)` short-circuits it,
# so the step is not killed; the wrong TYPE then takes the fault route above rather than
# reading as "no creator", which is what made it re-derivable. (An earlier version of
# this comment described that re-derivation as the intended behaviour — it was the
# regression the block above records.)
# reading as "no creator", which is what made it re-derivable.
ex_creator=$(printf '%s' "$row" | jq -r --arg f "$SCHEMA_FAULT" 'if (.creator | type) == "object" then (.creator.login // "") elif (.creator | type) == "null" then "" else $f end')
ex_desc=$(printf '%s' "$row" | jq -r --arg f "$SCHEMA_FAULT" 'if (.description | type) == "string" then .description elif (.description | type) == "null" then "" else $f end')
for _f in "$ex_id" "$ex_state" "$ex_creator" "$ex_desc"; do
@@ -763,8 +795,8 @@ jobs:
# A `case` prefix test rather than grep: the description is a single short string, and this
# removes one more pipeline from a security predicate entirely. The PATTERN is a literal, so
# there is no glob-injection concern from $ex_desc.
# A human verdict also has to have been formed against THIS base (ersatztv#698, found in
# round-4 review). `post-review-verdict.sh` records the base it reviewed in the status
# A human verdict also has to have been formed against THIS base (ersatztv#698).
# `post-review-verdict.sh` records the base it reviewed in the status
# description — `Review-verdict: MERGEABLE @ abc1234 (base: main)` — precisely because
# retargeting changes the effective diff without moving the head sha (ersatztv#632).
# Without this check the sha-binding is escapable through the HUMAN path rather than the
@@ -968,8 +1000,7 @@ jobs:
# triggered (push -> `pull_request_target` run) pairs on PRs #802, #834 and #761 the
# `pull_push` event predates its own run's `started_at` by 26-102s, itself a lower bound
# since the job runs a checkout and several steps before counting (69s end to end on run
# 2385). An earlier version of this comment rested the claim on that margin alone and
# called it a deployment property; see `ci.verdict-write-retarget-fence`, which also names
# 2385). See `ci.verdict-write-retarget-fence`, which also names
# the one window this does NOT cover — a PR's FIRST push is recorded about a second after
# creation and fires no `synchronize`, so an `opened` run landing inside it would abstain
# with no successor queued.
@@ -1101,12 +1132,11 @@ jobs:
# instance; the worst case with the per-page retry below is 40 requests and 20
# one-second sleeps — every page blipping once and recovering — which is 20s of
# sleep on top. MEASURED by executing this function against scripted responses,
# not counted by eye: the first draft of this sentence said 19, reasoning that
# the last page could not sleep. It can.
# not counted by eye: 19 would be the count if the last page could not sleep. It can.
#
# WALL-CLOCK, which is what the timeout is actually for. An earlier version of this
# comment reasoned that only the last page can pay two timeouts, since a page whose
# BOTH attempts fail ends the walk, and put the bound at ~335s. That bounds TIMEOUTS,
# WALL-CLOCK, which is what the timeout is actually for. A PLAUSIBLE reading is
# that only the last page can pay two timeouts, since a page whose BOTH attempts
# fail ends the walk, which puts the bound at ~335s. That bounds TIMEOUTS,
# not elapsed time: attempt 1 can burn its full 15s and attempt 2 still SUCCEED
# slowly, so the page costs ~31s and the walk carries on. The pessimum is therefore
# 20 x (15 + 1 + 15) = 620s per walk — ~21min on the exempt path's three walks — and
@@ -1127,7 +1157,7 @@ jobs:
# count with `rt_ok=yes`. Both re-reads share the blind spot, so the counts agree and
# the exemption is granted — the SAME construction as before, at the cap instead of at
# page 2, at roughly 10x the price. State that price ONCE and in one unit, because
# two earlier drafts of this comment stated it two ways 45 lines apart. The FILTERED
# two statements of it in one file drift into disagreement. The FILTERED
# BLOCK is 50 rows in both cases — that is the part which must be inline review
# comments. What grew is the timeline the attacker has to build around it: ~100 rows
# before (a non-empty page 1, then a 50-row block at a 50-aligned offset) against more
@@ -1175,8 +1205,8 @@ jobs:
# figure that rots: a real PR's timeline always carries at least one event on page 1
# (the PR is created by a push, which is itself an event). Spot-checked across
# #752/#753/#749/#739/#717, all non-empty; the counts themselves are deliberately
# not recorded here because timelines grow and an earlier version of this comment
# cited five numbers of which three were stale within days. Trusting a zero count
# not recorded here because timelines grow and three of five such
# counts went stale within days. Trusting a zero count
# from an anomalous first page would mean trusting that no retarget happened on the
# strength of a response we cannot explain, so it falls through to `rt_ok=no`, which
# withholds the exemption and asks for a human verdict — the safe direction.
@@ -1204,8 +1234,8 @@ jobs:
# truthiness of its LAST OUTPUT, which is the exit-status subtlety that already bit
# this workflow at jq 1.6 (ersatztv#647), and the runner still ships 1.6. A count read
# as a VALUE and then range-checked in shell cannot be read two ways, and it matches
# every other tally here. (The first draft of this guard used `jq -e`, safely by
# accident — the array is known non-empty by the check above, so the empty-input
# every other tally here. (Using `jq -e` here would be safe by accident — the
# array is known non-empty by the check above, so the empty-input
# divergence could not fire — but "safe because of a property three lines up" is
# exactly the reasoning the rule exists to retire.)
bad=$(printf '%s' "$raw" | jq -r '[.[] | select(type != "object" or (.type | type) != "string")] | length' 2>/dev/null) || bad=""
@@ -1238,8 +1268,8 @@ jobs:
# Both callers below want the WHOLE history, and a single `?limit=100` read cannot give it:
# `limit` clamps to the server-wide `MAX_RESPONSE_ITEMS`, measured at 50.
#
# BE PRECISE ABOUT WHAT THAT COST, because the issue and an earlier version of this comment
# both overstated it. Under the server default (`created_unix DESC`) page 1 holds the NEWEST
# BE PRECISE ABOUT WHAT THAT COST, because the issue overstates it.
# Under the server default (`created_unix DESC`) page 1 holds the NEWEST
# rows, and ids are monotonic with `created_at` (measured). So page 1 already carried the
# true maximum id, AND every row newer than the mark — which is the only thing the
# post-write check selects on. A single-page read therefore missed a raced verdict only if
@@ -1289,7 +1319,7 @@ jobs:
# EVERY exit publishes what WAS read, not just the complete ones. `ph_ok` alone says
# whether the list is whole; a caller that can still use a partial list must be able to
# reach it, and the high-water mark below is exactly such a caller — see the fail-open it
# otherwise creates (ersatztv#763, round 2).
# otherwise creates (ersatztv#763).
while [ "$page" -le 20 ]; do
raw=""
kind=""
@@ -1364,12 +1394,47 @@ jobs:
# The guard still exists for its original reason: re-posting `pending` over a real human
# verdict would un-approve a reviewed head and stall the PR. So it discriminates by PROVENANCE.
#
# MEASURED on this instance (Gitea 1.25.4), on the COMBINED endpoint this job reads: a status
# MEASURED on this instance, on the COMBINED endpoint this job reads: a status
# POSTed with a USER credential — how `scripts/post-review-verdict.sh` writes a verdict —
# carries `.creator.login`, while one POSTed by an Actions job with the built-in `GITEA_TOKEN`
# carries `"creator": null`. A real verdict read back `creator=timothy`; this job's own
# exemption read back `creator=null`.
#
# FIRST measured on 1.25.4; RE-MEASURED on 1.27.1, 2026-09-02 (ersatztv#869) — this is the
# claim the H10 allow-list rests on, which is why #869 prioritised it. It is NOT the last
# 1.25.4-dated load-bearing claim: the `pull_request_target` half the retarget fence relies
# on is still 1.25.4-dated too, deliberately (`ci.verdict-write-retarget-fence`).
# Four merged PR heads read back independently (#903, #884, #902,
# #899): on every one exactly ONE status carries `.creator.login` — `review-verdict/h10`,
# `timothy`, the one `post-review-verdict.sh` writes — and EVERY other row on that head
# carries `creator: null`. It holds on BOTH endpoints, combined `/commits/{sha}/status` and
# list `/statuses/{sha}`, so this guard and `page_statuses` see the same attribution. The
# two differ only in HOW MANY rows come back, which is not an attribution difference: the
# combined endpoint is latest-per-context (15 rows on each of the four) and the list
# endpoint returns every row ever posted (43-63 across them).
#
# THE MECHANISM, so this is re-derivable without a live PR. The INTUITIVE version is worth
# recording as WRONG, because it is the one a reader will re-derive:
# `creator: null` does NOT mean `CreatorID == 0`. At v1.27.1 an Actions job
# authenticates as the SYNTHETIC Actions user, `ActionsUserID = -2`
# (`models/user/user_system.go`), which is never a row in the `user` table, and
# `NewCommitStatus` stores `CreatorID = opts.Creator.ID` — so an Actions-posted status
# stores -2, NOT 0. `services/convert/status.go`'s `ToCommitStatus` therefore TAKES its
# `if status.CreatorID != 0` branch and the null comes from the next line:
# `creator, _ := user_model.GetUserByID(ctx, status.CreatorID)` is a bare lookup with NO
# system-user mapping (contrast `GetPossibleUserByID`, which handles `id < 0` explicitly),
# so -2 misses, THE ERROR IS DISCARDED, and `ToUser(ctx, nil, nil)` returns nil.
#
# It is still a property of the SERIALIZER, which is why it cannot differ between the two
# endpoints — the combined endpoint reaches the same `ToCommitStatus`.
#
# THE CONSEQUENCE FOR THIS GUARD, which the wrong mechanism hid: `creator: null` does not
# mean "a machine wrote this". Any row whose creator account was later DELETED serializes
# null by the same path. That does NOT weaken this guard, and the reason is the direction
# of the test below: it short-circuits only on a POSITIVE allow-list match, so both a
# machine write and a vanished author land on RE-DERIVE, the safe branch. It would break a
# guard written the other way round, which is one more reason not to invert it.
#
# BOTH conditions are required, and the DIRECTION of the test is the point: we short-circuit
# only on something POSITIVELY identified as a human verdict. Anything else, including anything
# we do not recognise, is RE-DERIVED. Written the other way round ("skip if it looks
@@ -1381,10 +1446,20 @@ jobs:
# credential, and reachable from any workflow because secrets are a per-repo store.
#
# What this STILL does not claim: the test asks "was this POSTed by an account we accept
# verdicts from", NOT "by someone who actually reviewed it". `ETV_STATUS_AUTH`'s registry
# credential can no longer POST a status at all (#697, fixed by scoping it off
# `write:repository` — see `ci.actions-credential-scoping`), and `RENOVATE_TOKEN` is now
# re-derived rather than inherited; but an ALLOW-LISTED collaborator's own token can still
# verdicts from", NOT "by someone who actually reviewed it". The registry credential
# `docker-build.yml` used to pass as `ETV_STATUS_AUTH` can no longer POST a status at all
# (#697, fixed by scoping it off `write:repository` — see `ci.actions-credential-scoping`),
# and since #885 that route materialises no STORED credential to refuse: nothing on the
# `pull_request` route names a stored secret and the status read is anonymous
# (`ci.pr-route-carries-no-stored-credential`). The INJECTED `GITEA_TOKEN` is still handed
# to every job there and STAYS IN THIS RESIDUAL SET: `docker-build.yml`'s workflow-level
# `permissions:` (`code: read`) NARROWS it for the COMMITTED file only, and on the
# `pull_request` route the head supplies that file and can delete the block — with the
# owner-level Actions default at `permissive` (`ci.actions-credential-scoping`) that alone
# yields a write-capable token. Removing the stored secrets does not touch it.
# `RENOVATE_TOKEN` is now re-derived rather than inherited.
#
# But an ALLOW-LISTED collaborator's own token can still
# POST a success with an attacker-chosen `Review-verdict:` description, which this guard then
# preserves. That is provenance, not authentication — branch protection binds the context
# NAME, not its issuer, and Gitea offers no way to restrict who may post a context.
@@ -1453,7 +1528,7 @@ jobs:
pre_state=$ex_state
pre_creator=$ex_creator
pre_desc=$ex_desc
# THE ID IS PART OF THE SNAPSHOT (ersatztv#849, round 2). The triple cannot see a
# THE ID IS PART OF THE SNAPSHOT (ersatztv#849). The triple cannot see a
# REPLACEMENT whose text matches, and the two sentinels are byte-identical by design, so
# "another run replaced this row while we classified" was invisible for exactly the rows
# where it matters most. Where the server does not send an id both sides are empty and the
@@ -1469,7 +1544,7 @@ jobs:
# this line — creator, allow-list and description together — is what says so. A separate
# `::warning::` annotation was tried and WITHDRAWN: it duplicated all three values from
# this line, its only delta was a severity whose rendering could not be probed, and it
# produced three defects across three review rounds (a message that read "from 'timothy',
# produced three successive defects (a message that read "from 'timothy',
# which is NOT in H10_REVIEWERS ('timothy')", an unescaped API value interpolated into a
# workflow command, and a test that asserted its absence on a path that exits two guards
# earlier and so could never fail). One line that is always right beats two where the
@@ -1502,7 +1577,7 @@ jobs:
if [ "$ex_unverified" = yes ]; then
echo "${CONTEXT} on ${SHA:0:7} carries the unverified-write sentinel from an earlier run — reconciling it against the per-POST status history."
page_statuses
# THE WITNESS: THE SENTINEL'S OWN ROW MUST BE IN THE HISTORY (ersatztv#849, round 2).
# THE WITNESS: THE SENTINEL'S OWN ROW MUST BE IN THE HISTORY (ersatztv#849).
# `ex_unverified=yes` means the COMBINED endpoint just returned the sentinel for this
# sha, and `/statuses/{sha}` keeps one row per POST — so a complete history that does
# NOT contain it, empty ones included, is a response that cannot be true. The post-write
@@ -1517,7 +1592,7 @@ jobs:
# PERMANENT sentinel. This asks for a SPECIFIC row already known to exist, and failing it
# carries the sentinel forward for THIS run only — the next run retries.
# THE WITNESS IS THE CURRENT ROW'S ID, not merely a row with the right text
# (ersatztv#849 round 3). Matching on description alone is satisfied by an OLDER
# (ersatztv#849). Matching on description alone is satisfied by an OLDER
# identical sentinel, which is precisely what a fixed point produces: with S1 and a
# buried human verdict below the CURRENT S2, a read carrying only S1 satisfies the
# witness, `buried` sees nothing, the sentinel clears, and the verdict ends up below the
@@ -1527,7 +1602,7 @@ jobs:
# `$ex_id` COMES FROM THE COMBINED READ, so it names the row that is current right now.
# When the server omits it — not observed on this instance, where every row carries one —
# there is nothing to match on and the check degrades to the description, which is the
# pre-round-3 behaviour rather than a new hole.
# description-only behaviour rather than a new hole.
if [ -n "$ex_id" ]; then
witness=$(printf '%s' "$ph_rows" | jq -r --arg c "$CONTEXT" --argjson wid "$ex_id" \
'[.[] | select(type == "object")
@@ -1635,10 +1710,10 @@ jobs:
for v in "$n_protected" "$n_not_manifest" "$n_not_docs"; do
case "$v" in
''|*[!0-9]*)
# REPLACES, for the reason every other refusal here does (ersatztv#849 round 3): this
# REPLACES, for the reason every other refusal here does (ersatztv#849): this
# run has already DECLINED to inherit whatever the head carries, so exiting without
# writing leaves that row authoritative. The old message said no status "will be
# written", which was true and beside the point — the question is what is standing.
# writing leaves that row authoritative. A message saying no status "will be
# written" is true and beside the point — the question is what is standing.
replace_unknown_and_die "A path predicate returned '${v}' instead of a count for ${SHA:0:7}, so the classifier is not operating and this run cannot say what belongs on this head." ;;
esac
done
@@ -1710,8 +1785,8 @@ jobs:
state=success
desc="Exempt: $reason"
elif [ "$ex_repair" = yes ]; then
# CARRY THE SENTINEL FORWARD. This branch exists because the first version of it did not,
# and cold review reproduced the consequence: refusing the exemption but posting the
# CARRY THE SENTINEL FORWARD. This branch exists because the consequence of omitting it
# was reproduced: refusing the exemption but posting the
# GENERIC pending description overwrote the very sentinel the refusal depends on, so the
# next run saw an ordinary machine `pending`, re-derived it, and posted `success` — burying
# the human rejection two events after the repair instead of one. The block has to be a
@@ -1743,12 +1818,12 @@ jobs:
# HIGH-WATER MARK for the post-write verification (ersatztv#706 race 2). Taken FIRST — before
# the re-read below, before the fence, before the POST — and the ORDER IS THE POINT.
#
# An earlier version captured it just before the POST, "as late as possible". Cold review
# caught that as a High: everything between the re-read and a late mark is a blind gap. A
# Capturing it just before the POST, "as late as possible", is WRONG, and badly so:
# everything between the re-read and a late mark is a blind gap. A
# human verdict landing there is invisible to the re-read (which already happened) AND
# excluded from the post-write check (its id is BELOW a mark taken afterwards), so it is
# silently overwritten with no repair. That gap spans the entire retarget re-count — up to 20
# timeline round-trips — so it was far wider than the one-round-trip residual being claimed.
# timeline round-trips — far wider than a one-round-trip residual.
#
# Taking the mark first closes the read side completely: any row newer than the mark is caught
# either by the re-read (abstain, post nothing) or by the post-write check (repair). There is
@@ -1829,13 +1904,11 @@ jobs:
max_id_before=-1
fi
# MARK ANY WRITE NOTHING CAN VERIFY (ersatztv#849 route 1). The heading said "withhold the
# EXEMPTION" while the block downgrades every re-derivable state, which is narrower than
# what it does.
# MARK ANY WRITE NOTHING CAN VERIFY (ersatztv#849 route 1).
#
# EVERY RE-DERIVABLE WRITE IS DOWNGRADED, not only `success` (corrected in round 2). An
# earlier version restricted this to the exemption, reasoning that a sticky generic
# `pending` "withholds nothing, since an unreviewed PR is blocked already". That analysed
# EVERY RE-DERIVABLE WRITE IS DOWNGRADED, not only `success`. Restricting
# this to the exemption, on the reasoning that a sticky generic
# `pending` "withholds nothing, since an unreviewed PR is blocked already", analyses
# the wrong PR. The damaging case is a PR that IS exemptible and only got the generic
# `pending` from a transient enumeration failure: the generic description carries no
# marker, the post-write check below does not run without a mark, so a human verdict
@@ -1892,17 +1965,17 @@ jobs:
exit 0
fi
# A SENTINEL THAT APPEARED MID-RUN (ersatztv#706, round-3 review). The re-read above recomputes
# A SENTINEL THAT APPEARED MID-RUN (ersatztv#706). The re-read above recomputes
# `ex_repair`, and until this guard existed nothing downstream read it: the POST writes the
# `$state` frozen at classification time, so a STALE OVERLAPPING RUN would post its `success`
# straight over a sentinel another run had just written — burying a human rejection, with no
# repair (the human row is below this run's mark) and no log. That fails toward SUCCESS, so it
# was not covered by the "repair fails toward pending" residual; it is the exact outcome this
# whole change exists to prevent, reached through the run overlap this branch itself measured.
# whole change exists to prevent, reached through a measured run overlap.
#
# THE RULE IS "NEVER REPLACE A SENTINEL WITH A NON-SENTINEL", not "never overwrite it with a
# success". A first draft of this guard tested `state = success`, which is one branch too
# narrow: a run can reach the POST on `state=pending` carrying the GENERIC description — most
# success". Testing `state = success` here is one branch too narrow: a run can reach
# the POST on `state=pending` carrying the GENERIC description — most
# realistically after a transient enumeration failure (`complete != yes`) — and that run
# passes a success-only guard, passes the fence, and overwrites the sentinel with ordinary
# text. The next run then sees no sentinel, re-derives, and posts `success`: the same buried
@@ -1967,7 +2040,7 @@ jobs:
# instead of a re-derivable description. Letting `pending` through here is still right — the
# alternative strands every PR whenever the timeline is unreadable — but the masking itself
# remains a cost, so the trade is "immediate block, one repaired write", not "free".
# ABSTAINING IS A HANDOFF ONLY WHEN THERE IS NOTHING TO HAND OFF (ersatztv#849 round 3).
# ABSTAINING IS A HANDOFF ONLY WHEN THERE IS NOTHING TO HAND OFF (ersatztv#849).
# The two arms below are right not to write their CLASSIFICATION — it was computed against
# a base or a head the PR may no longer have, and the mutation that invalidated it has
# already queued a successor. But when this run DECLINED to inherit a row the head carries,
@@ -2037,13 +2110,12 @@ jobs:
# in the helper that may be stronger than the reconcilable sentinel. It can only ever
# withhold an exemption.
mark_desc="$UNVERIFIED_DESC"
# `$desc` ALONE, because the classification has already collected the others. Round 8
# promoted on `$pre_desc` and `$ex_desc` as well, and both are unreachable as a sole
# `$desc` ALONE, because the classification has already collected the others. Promoting
# on `$pre_desc` and `$ex_desc` as well adds nothing — both are unreachable as a sole
# cause: a repair sentinel at the FIRST read sets `ex_repair`, which forces
# `desc="$REPAIR_DESC"`; one arriving mid-run is caught by the sentinel guard unless this
# run is itself writing that string, which is the same condition. Keeping them meant two
# clauses no fixture could ever distinguish — the shape this branch has spent several
# rounds either proving or declaring, and here the honest answer is that they are
# run is itself writing that string, which is the same condition. Keeping them means two
# clauses no fixture could ever distinguish, and the honest answer is that they are
# redundant rather than unprovable. `$desc` needs no state test: it is this run''s own
# decision, and it is `pending` whenever it is that string.
if [ "$desc" = "$REPAIR_DESC" ]; then mark_desc="$REPAIR_DESC"; fi
@@ -2081,11 +2153,11 @@ jobs:
exit 0
fi
if { [ "$retargets_before_ok" != yes ] || [ "$rt_ok" != yes ]; } && [ "$state" = "success" ]; then
# ABSTAINING HERE WAS A FAIL-OPEN WHEN THE HEAD ALREADY CARRIED A ROW (ersatztv#849,
# round 2). This branch is reached only after the classification DECLINED to inherit
# ABSTAINING HERE WAS A FAIL-OPEN WHEN THE HEAD ALREADY CARRIED A ROW (ersatztv#849).
# This branch is reached only after the classification DECLINED to inherit
# whatever `review-verdict/h10` the head carries — that is why it is re-deriving — so
# posting nothing leaves the declined row current. The old message said the context
# "stays absent", which is true only for a head that had none; on a head carrying a
# posting nothing leaves the declined row current. A message saying the context
# "stays absent" is true only for a head that had none; on a head carrying a
# machine or off-list `success` it is the opposite of what happens, and no retarget or
# push need have occurred, so no successor run is guaranteed either.
#
@@ -2243,7 +2315,7 @@ jobs:
# over — it carries `creator: null` (an Actions-token POST, measured; see the provenance
# note above) and its description is `Exempt: …`, not `Review-verdict:` — so the count is
# of human verdicts that did not exist when the mark was taken.
# TWO row shapes count as "something raced this write", not one (round-5 review).
# TWO row shapes count as "something raced this write", not one.
#
# (a) a HUMAN verdict — non-null creator, `Review-verdict:` description;
# (b) a machine SENTINEL — null creator, description exactly `$REPAIR_DESC`.
@@ -2465,8 +2537,8 @@ jobs:
# a machine-written `success` is re-derived, not inherited, so that successor changes the
# answer. A retarget landing before it is caught here.
#
# WHAT THAT BUYS IS TRANSIENT INSTEAD OF PERMANENT, not "the green never stands" — which is
# what this comment claimed until round-2 review. The `success` is live between its POST
# WHAT THAT BUYS IS TRANSIENT INSTEAD OF PERMANENT, not "the green never stands".
# The `success` is live between its POST
# and the repair below, including the timeline round trips in between, so branch protection
# or an already-scheduled auto-merge can observe it. THAT WINDOW GREW WITH #870 and the
# sentence is dated accordingly: the walk in between is now 20 requests rather than ~2,
@@ -23,7 +23,8 @@ public static class ChannelGraphicsDefaults
{
// HLS Direct is skipped because ErsatzTV is not transcoding there -- there is no frame
// pipeline to draw into, and the editor disables the toggle for the same reason. Identity is
// the element's filename, never its user-editable Name (the #67 lesson).
// the element's full seeded path (`GraphicsElementDefaults.OnNowNextSeededPath`), never its
// user-editable Name (the #67 lesson, sharpened from filename to full path by #568).
if (channel.StreamingMode is StreamingMode.HttpLiveStreamingDirect)
{
return;
@@ -47,8 +47,13 @@ public class UpdateChannelHandler(
{
Either<BaseError, string> resolvedLogo = await ResolveLogoPath(request, cancellationToken);
return await resolvedLogo.Match(
Right: async logoPath => Right<BaseError, ChannelViewModel>(
await ApplyUpdateRequest(dbContext, c, request, logoPath, cancellationToken)),
Right: logoPath =>
ApplyUpdateRequestTranslatingLostRace(
dbContext,
c,
request,
logoPath,
cancellationToken),
Left: e => Task.FromResult(Left<BaseError, ChannelViewModel>(e)));
},
Fail: errors => Task.FromResult(Left<BaseError, ChannelViewModel>(errors.Join())));
@@ -76,6 +81,56 @@ public class UpdateChannelHandler(
return cached;
}
// Validation and the write are two statements, not one atomic step: RefreshGraphicsElements
// deletes elements whose template file is gone, and a delete landing between the two turns the
// join insert back into the FK violation the validator exists to prevent -- the unhandled 500
// again (#568). A transaction does not close that window either: neither provider locks the rows
// the validator merely READ, so the concurrent delete still commits. Ask the existence question
// again on the failure path instead, and return the same 422 the validator would have returned;
// a DbUpdateException from any other cause keeps its own exception rather than being reported as
// a client error.
//
// What is re-asked is the WHOLE of Validate, not the graphics-element half: every FK on this
// full-replace DTO -- FFmpegProfileId, WatermarkId, FallbackFillerId, MirrorSourceChannelId and
// the graphics element ids -- is written by ApplyUpdateRequest and can lose the same race, and a
// recovery path that names its fields one by one silently omits the next FK the DTO gains.
// Re-running the validator set is what keeps the two paths from drifting: a check added to
// Validate is covered here by construction.
private async Task<Either<BaseError, ChannelViewModel>> ApplyUpdateRequestTranslatingLostRace(
TvContext dbContext,
Channel channel,
UpdateChannel request,
string logoPath,
CancellationToken cancellationToken)
{
try
{
return Right<BaseError, ChannelViewModel>(
await ApplyUpdateRequest(dbContext, channel, request, logoPath, cancellationToken));
}
catch (DbUpdateException)
{
// a fresh context: the failed save left the original one tracking the changes that
// could not be written, so the same query there could be answered from those. The
// channel entity is still the tracked one from the failed context, which Validate reads
// only in memory (MirrorSourceMustBeValid's own-playout count) and never re-queries.
await using TvContext recheckContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> recheck =
await Validate(recheckContext, request, channel, cancellationToken);
Option<BaseError> maybeError = recheck.Match(
Succ: _ => Option<BaseError>.None,
Fail: errors => Some(errors.Join()));
foreach (BaseError error in maybeError)
{
return Left<BaseError, ChannelViewModel>(error);
}
throw;
}
}
private async Task<ChannelViewModel> ApplyUpdateRequest(
TvContext dbContext,
Channel c,
@@ -229,14 +284,15 @@ public class UpdateChannelHandler(
.Apply((_, _, _, _, _) => channel);
// combine the page-only Group rule plus the FK existence checks (FFmpeg profile / watermark /
// fallback filler) with the channel validation; splitting keeps tuple arity within
// LanguageExt's supported applicative range while still accumulating all errors
// fallback filler / graphics elements) with the channel validation; splitting keeps tuple
// arity within LanguageExt's supported applicative range while still accumulating all errors
return (ValidateGroup(request.Group),
await FFmpegProfileMustExist(dbContext, request, cancellationToken),
await WatermarkMustExist(dbContext, request, cancellationToken),
await FillerPresetMustExist(dbContext, request, cancellationToken),
await GraphicsElementIdsMustExist(dbContext, request, cancellationToken),
channelValidation)
.Apply((_, _, _, _, c) => c);
.Apply((_, _, _, _, _, c) => c);
}
private static async Task<Validation<BaseError, int>> FFmpegProfileMustExist(
@@ -295,6 +351,28 @@ public class UpdateChannelHandler(
return BaseError.New($"Fallback filler {request.FallbackFillerId} does not exist.");
}
// The reconcile in ApplyUpdateRequest blindly Adds a ChannelGraphicsElement for every incoming
// id; an id with no matching GraphicsElement row would otherwise hit
// FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId at SaveChangesAsync and surface as
// an unhandled 500 (there is no global exception filter). Reject it here instead, for parity
// with every other FK field on this full-replace DTO (#568). The count cap, the request field
// named in the message and the cap on echoed ids all live in Validators.IdsMustExist, shared
// with the two UpdateDecoHandler twins so the three cannot drift apart.
private static Task<Validation<BaseError, Unit>> GraphicsElementIdsMustExist(
TvContext dbContext,
UpdateChannel request,
CancellationToken cancellationToken) =>
Validators.IdsMustExist(
request,
r => r.GraphicsElementIds,
"Graphics element",
idsAreConsumed: true,
(ids, token) => dbContext.GraphicsElements
.Where(e => ids.Contains(e.Id))
.Select(e => e.Id)
.ToListAsync(token),
cancellationToken);
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
TvContext dbContext,
UpdateChannel request,
@@ -22,7 +22,7 @@ public class GetAllGraphicsElementsForApiHandler(IDbContextFactory<TvContext> db
.Select(e => new
{
Vm = ProjectToViewModel(e),
BuiltIn = Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName
BuiltIn = GraphicsElementDefaults.IsOnNowNext(e.Path, e.Kind)
})
.OrderBy(x => x.Vm.Name == x.Vm.FileName)
.ThenBy(x => x.Vm.Name)
@@ -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,
@@ -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()
@@ -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);
}
@@ -1,10 +1,52 @@
using System.IO;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Graphics;
public static class GraphicsElementDefaults
{
// Built-in "On Now / Next" text element; identity is by filename, never by user-editable Name.
// Built-in "On Now / Next" text element filename -- a component of OnNowNextSeededPath below,
// never itself an identity check (#568: a filename-only comparison is folder-agnostic).
public const string OnNowNextFileName = "on-now-next.yml";
// Display name only. Never use it for identity -- that is the filename above (#67 / #74).
// Display name only. Never use it for identity -- that is IsOnNowNext below (#67 / #74 / #568).
public const string OnNowNextName = "On Now / Next";
// The full path the seeder writes the built-in template to (GraphicsElementSeeder.SeedOnNowNext
// builds `target` the same way). A `builtIn` discriminator must match THIS, not
// `Path.GetFileName(...) == OnNowNextFileName` -- a filename-only comparison is folder-agnostic:
// a user element named exactly `on-now-next.yml` in any of the other four template folders
// (image/motion/subtitle/script) would also report `builtIn:true` (#568). `Kind == Text` alone
// does not close this either, since a second text template could share the filename in principle.
public static string OnNowNextSeededPath =>
Path.Combine(FileSystemLayout.GraphicsElementsTextTemplatesFolder, OnNowNextFileName);
/// <summary>
/// The one identity test for the built-in On Now / Next element: the exact file the seeder
/// wrote, at the exact path it wrote it to, of the kind it wrote it as. Ordinal on purpose,
/// and so case-sensitive on purpose.
/// </summary>
/// <remarks>
/// <para>
/// <c>Kind</c> is part of the identity rather than a second test any caller may add or
/// skip: the seeder's own "does the built-in row exist yet?" check resolves through this
/// predicate, so a row of another kind at the seeded path answering yes would suppress
/// the Text row every consumer resolves. A caller applying only the path half would
/// report that wrong-kind row as the built-in element while the seeder refused to treat
/// it as one -- the two sites disagreeing about the same row, which is the defect this
/// predicate exists to make impossible (#568).
/// </para>
/// <para>
/// Callers compare in memory rather than in a <c>Where</c> clause, because in SQL the
/// answer would be the PROVIDER's to give: <c>GraphicsElement.Path</c> takes no explicit
/// collation (<c>TvContext.OnModelCreating</c> pins one only on the listed name/title
/// columns), so SQLite compares it case-sensitively while MySQL uses the server default,
/// which is normally case-INsensitive. Evaluating one discriminator site in SQL and the
/// other in memory would let the two disagree on MySQL alone. The SQLite test suite
/// cannot tell the two apart -- BINARY collation and an ordinal comparison agree on
/// every input -- so this is held by keeping the comparison out of SQL, not by a test.
/// </para>
/// </remarks>
public static bool IsOnNowNext(string path, GraphicsElementKind kind) =>
kind == GraphicsElementKind.Text && string.Equals(path, OnNowNextSeededPath, StringComparison.Ordinal);
}
@@ -173,14 +173,25 @@ public static class GraphicsElementSeeder
return;
}
bool exists = await context.GraphicsElements.AnyAsync(e => e.Path == target, cancellationToken);
if (exists)
// "Does the built-in row already exist?" is the same question every consumer asks later, so
// ask it with the same code instead of re-deriving it here. As its own SQL comparison
// (`AnyAsync(e => e.Path == target)`) it could answer differently in two ways, and either
// one leaves the built-in element undiscoverable after startup (#568):
// * string equality in SQL is the PROVIDER's collation to decide, so on MySQL's normally
// case-INsensitive default a case-variant row satisfied the check, the canonical row was
// never created, and the ordinal lookup below then matched nothing;
// * it ignored `Kind`, so a row of another kind sitting at the seeded path suppressed the
// Text row the lookup actually resolves.
// Creating the row stays idempotent because `target` IS the path the lookup matches -- held
// by `Repeated_Seeding_Does_Not_Accumulate_Element_Rows`, which reddens if the two drift.
if ((await GetBuiltInElementId(context, cancellationToken)).IsSome)
{
return;
}
// Name is display-only (identity is the filename), but leaving it null sorts the built-in
// element into the unnamed bucket at the bottom of the SPA list until the first refresh.
// Name is display-only (identity is the full seeded path, `target` above -- #568), but
// leaving it null sorts the built-in element into the unnamed bucket at the bottom of the
// SPA list until the first refresh.
await context.GraphicsElements.AddAsync(
new Core.Domain.GraphicsElement
{
@@ -246,29 +257,42 @@ public static class GraphicsElementSeeder
}
/// <summary>
/// Identity is the filename, never the user-editable Name (the #67 lesson carried into #74).
/// The Kind is part of it: the five template folders are separate namespaces, so an unrelated
/// image/motion/subtitle/script element may legitimately be named `on-now-next.yml` too, and
/// filename alone would hand back whichever row the unordered query happened to return first.
/// Identity is the full seeded path, never the user-editable Name (the #67 lesson carried into
/// #74) and never the bare filename (#568: filename-only matching is folder-agnostic, so a user
/// element named exactly `on-now-next.yml` in a different template folder would also match).
/// The <c>Kind</c> half of that identity is load-bearing rather than decorative:
/// <c>EnsureBuiltInElementRow</c> asks this method whether the row it is about to create already
/// exists, so a row of another kind at the seeded path must NOT answer yes -- it would suppress
/// the Text row every consumer resolves.
/// </summary>
/// <remarks>
/// Both halves are <see cref="GraphicsElementDefaults.IsOnNowNext(string,GraphicsElementKind)"/>
/// in memory rather than a <c>Where</c> clause. The path half must be, or the match would be the
/// provider's collation to decide and this site would disagree with the API's `builtIn` (which
/// compares in memory) on MySQL. The <c>Kind</c> half could be a SQL filter -- it is an enum,
/// not a string -- but then this site would hold half the identity and the predicate the other
/// half, and the API site could apply the predicate alone and quietly answer for rows this one
/// rejects. That is exactly the disagreement #568 found, so identity is one predicate applied
/// whole, at every site.
/// </remarks>
public static async Task<Option<int>> GetBuiltInElementId(
TvContext context,
CancellationToken cancellationToken)
{
List<(int Id, string Path)> candidates = await context.GraphicsElements
.Where(e => e.Kind == GraphicsElementKind.Text)
.Select(e => new { e.Id, e.Path })
List<(int Id, string Path, GraphicsElementKind Kind)> candidates = await context.GraphicsElements
.Select(e => new { e.Id, e.Path, e.Kind })
.ToListAsync(cancellationToken)
.Map(rows => rows.Select(r => (r.Id, r.Path)).ToList());
.Map(rows => rows.Select(r => (r.Id, r.Path, r.Kind)).ToList());
var matches = candidates
.Where(c => System.IO.Path.GetFileName(c.Path) == GraphicsElementDefaults.OnNowNextFileName)
.OrderBy(c => c.Id)
List<int> matches = candidates
.Where(c => GraphicsElementDefaults.IsOnNowNext(c.Path, c.Kind))
.Select(c => c.Id)
.OrderBy(id => id)
.ToList();
// Lowest id wins if two text templates somehow share the filename, so the choice is stable
// across restarts rather than dependent on query order.
return matches.Count == 0 ? Option<int>.None : matches[0].Id;
// Lowest id wins if two rows somehow share the seeded path, so the choice is stable across
// restarts rather than dependent on query order.
return matches.Count == 0 ? Option<int>.None : matches[0];
}
private static async Task UpgradeUnmodifiedTemplate(
@@ -26,7 +26,7 @@ public class CreateChannelDefaultGraphicsElementTests : ChannelHandlerTestBase
await using TvContext context = Db.CreateContext();
var element = new GraphicsElement
{
Path = $"/templates/text/{GraphicsElementDefaults.OnNowNextFileName}",
Path = GraphicsElementDefaults.OnNowNextSeededPath,
Kind = GraphicsElementKind.Text
};
@@ -464,7 +464,7 @@ public class CreateChannelFromLineupHandlerTests
[Test]
public async Task Should_Reject_WeightedShuffle_For_A_Multi_Item_Lineup()
{
// regression (#70, found by adversarial review of PR #402): a 2+ entry lineup is persisted as a
// regression (#70, PR #402): a 2+ entry lineup is persisted as a
// Playlist, and PlaylistEnumerator has no default arm -- an order it doesn't know leaves the
// enumerator null and the items vanish from the playlist with nothing reported. This handler is the
// THIRD writer of PlaylistItem.PlaybackOrder and was missed when the other two were gated.
@@ -1020,7 +1020,7 @@ public class CreateChannelFromLineupHandlerTests
await using TvContext context = _db.CreateContext();
var element = new GraphicsElement
{
Path = $"/templates/text/{GraphicsElementDefaults.OnNowNextFileName}",
Path = GraphicsElementDefaults.OnNowNextSeededPath,
Kind = GraphicsElementKind.Text
};
@@ -1,6 +1,9 @@
using System.Globalization;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
@@ -15,6 +18,9 @@ public class UpdateChannelGraphicsElementsTests : ChannelHandlerTestBase
{
private UpdateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets, RemoteLogoCacher);
private static BaseError LeftOf(Either<BaseError, ChannelViewModel> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
private async Task<(int ElementAId, int ElementBId)> SeedGraphicsElements()
{
await using TvContext context = Db.CreateContext();
@@ -25,6 +31,37 @@ public class UpdateChannelGraphicsElementsTests : ChannelHandlerTestBase
return (elementA.Id, elementB.Id);
}
private async Task<int> SeedWatermark()
{
await using TvContext context = Db.CreateContext();
var watermark = new ChannelWatermark { Name = "W" };
context.ChannelWatermarks.Add(watermark);
await context.SaveChangesAsync();
return watermark.Id;
}
private async Task<List<int>> SeedGraphicsElements(int count)
{
await using TvContext context = Db.CreateContext();
List<GraphicsElement> elements = Enumerable.Range(0, count)
.Select(i => new GraphicsElement { Path = $"element-{i}.yml" })
.ToList();
context.GraphicsElements.AddRange(elements);
await context.SaveChangesAsync();
return elements.Select(e => e.Id).ToList();
}
// Replaces the harness the base class built with one whose SaveChangesAsync can be made to fail
// on demand. Foreign keys are off in InMemoryTvContext, so the FK violation a concurrent delete
// really produces cannot be raised by seeding alone.
private async Task<ArmedSaveFailureInterceptor> UseFailingSaveHarness()
{
await Db.DisposeAsync();
var interceptor = new ArmedSaveFailureInterceptor();
Db = await InMemoryTvContext.CreateAsync(interceptor);
return interceptor;
}
[Test]
public async Task Should_Reconcile_GraphicsElement_Join_Add_Then_Remove()
{
@@ -73,4 +110,199 @@ public class UpdateChannelGraphicsElementsTests : ChannelHandlerTestBase
reloaded.ChannelGraphicsElements.ShouldBeEmpty();
}
}
// #568: an unknown graphicsElementIds entry used to reach ApplyUpdateRequest unchecked, which
// blindly Adds a ChannelGraphicsElement and lets SaveChangesAsync hit
// FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId -> unhandled 500. Removing the
// GraphicsElementIdsMustExist validator alone from UpdateChannelHandler.Validate is row 35 of the
// mutation table in docs/graphics-elements.md, measured against the whole ErsatzTV.Tests project.
[Test]
public async Task Should_Reject_Unknown_GraphicsElementId_With_422_Not_500()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [999]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain("999");
// no partial write: the channel keeps no graphics element association
await using TvContext context = Db.CreateContext();
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == channel.Id);
reloaded.ChannelGraphicsElements.ShouldBeEmpty();
}
[Test]
public async Task Should_Reject_When_One_Of_Several_GraphicsElementIds_Is_Unknown()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
(int elementAId, _) = await SeedGraphicsElements();
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId, 12345]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain("12345");
}
// #568: the id list is client-supplied and was bounded only by the Kestrel body cap, which is a
// transport limit and not a collection limit. The cap is Validators.MaximumIdListCount, shared
// by all three id-list validators; these three tests pin its two edges and the shape that makes
// its placement matter. Removing the cap from Validators.IdsMustExist is row 41 of the mutation
// table in docs/graphics-elements.md.
[Test]
public async Task Should_Accept_Exactly_The_Maximum_Number_Of_GraphicsElementIds()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
List<int> ids = await SeedGraphicsElements(Validators.MaximumIdListCount);
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext context = Db.CreateContext();
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == channel.Id);
reloaded.ChannelGraphicsElements.Count.ShouldBe(Validators.MaximumIdListCount);
}
[Test]
public async Task Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
List<int> ids = Enumerable.Range(1, Validators.MaximumIdListCount + 1).ToList();
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain((Validators.MaximumIdListCount + 1).ToString(CultureInfo.InvariantCulture));
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
}
// The cap counts the RAW list, before Distinct: every one of these ids exists and they collapse
// to a single distinct id, so a cap applied after deduplication would accept this request and
// leave the parse and materialization it costs unbounded.
[Test]
public async Task Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
(int elementAId, _) = await SeedGraphicsElements();
List<int> ids = Enumerable.Repeat(elementAId, Validators.MaximumIdListCount + 1).ToList();
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain((Validators.MaximumIdListCount + 1).ToString(CultureInfo.InvariantCulture));
error.Value.ShouldNotContain("do not exist");
}
// A 422 that echoes every rejected id turns an oversized request into an oversized response.
// Removing the truncation from Validators.DescribeIds is row 42 of the mutation table.
[Test]
public async Task Should_Cap_The_Ids_Echoed_Back_In_The_Unknown_Id_422()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
List<int> ids = Enumerable.Range(1001, 30).ToList();
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("1001");
error.Value.ShouldContain("(and 20 more)");
error.Value.ShouldNotContain("1030");
}
// #568: validation and the write are two statements, so RefreshGraphicsElements can delete a
// validated element in between and hand the join insert the FK violation the validator exists to
// prevent -- the unhandled 500 again. Removing the DbUpdateException catch from
// ApplyUpdateRequestTranslatingLostRace is row 43 of the mutation table.
[Test]
public async Task Should_Translate_An_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422()
{
ArmedSaveFailureInterceptor interceptor = await UseFailingSaveHarness();
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
(int elementAId, _) = await SeedGraphicsElements();
interceptor.SqlBeforeFailing =
$"DELETE FROM GraphicsElement WHERE Id = {elementAId.ToString(CultureInfo.InvariantCulture)}";
interceptor.Armed = true;
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain(elementAId.ToString(CultureInfo.InvariantCulture));
}
// graphicsElementIds is not the only FK this DTO writes, and the recovery path re-asks the whole
// of Validate rather than the graphics-element half precisely so the other FKs are covered:
// WatermarkId is written by the same SaveChangesAsync and loses the same race. Reddens if the
// recheck is narrowed back to GraphicsElementIdsMustExist -- row 45 of the mutation table in
// docs/graphics-elements.md.
[Test]
public async Task Should_Translate_A_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422()
{
ArmedSaveFailureInterceptor interceptor = await UseFailingSaveHarness();
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
int watermarkId = await SeedWatermark();
interceptor.SqlBeforeFailing =
$"DELETE FROM ChannelWatermark WHERE Id = {watermarkId.ToString(CultureInfo.InvariantCulture)}";
interceptor.Armed = true;
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", watermarkId: watermarkId),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("Watermark");
error.Value.ShouldContain(watermarkId.ToString(CultureInfo.InvariantCulture));
}
// The other half of that catch: a DbUpdateException whose cause is NOT a missing graphics
// element is a real fault and must keep its own exception rather than be reported to the client
// as a validation error about ids that are all still present.
[Test]
public async Task Should_Not_Report_An_Unrelated_DbUpdateException_As_A_Graphics_Element_422()
{
ArmedSaveFailureInterceptor interceptor = await UseFailingSaveHarness();
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
(int elementAId, _) = await SeedGraphicsElements();
interceptor.Armed = true;
await Should.ThrowAsync<DbUpdateException>(
async () => await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId]),
CancellationToken.None));
}
}
@@ -1,6 +1,8 @@
using ErsatzTV.Application.Graphics;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Graphics;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NUnit.Framework;
@@ -62,6 +64,97 @@ public class GraphicsElementHandlerTests
result.ShouldBeEmpty();
}
// #568: the discriminator used to be Path.GetFileName(e.Path) == OnNowNextFileName, which is
// folder-agnostic -- a user element named exactly "on-now-next.yml" outside the seeded text
// template folder would also report builtIn:true. The row carries the SAME Kind as the real
// seeded element, deliberately: a wrong-kind row here would be rejected by the Kind conjunct
// whatever the path comparison is, so the composite revert (filename AND Kind == Text) would
// pass. Only the PATH half can reject a Text row in another folder, which is what this pins --
// it mirrors the seeder-site Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder.
[Test]
public async Task GetAllGraphicsElementsForApi_Should_Not_Mark_Same_Filename_Outside_Seeded_Folder_As_BuiltIn()
{
string userElementPath = System.IO.Path.Combine(
"/config/graphics-elements/text/some-subfolder",
GraphicsElementDefaults.OnNowNextFileName);
await SeedElement(1, userElementPath, GraphicsElementKind.Text, string.Empty);
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
List<GraphicsElementResponseModel> result =
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
result.Count.ShouldBe(1);
result[0].BuiltIn.ShouldBeFalse();
}
[Test]
public async Task GetAllGraphicsElementsForApi_Should_Mark_The_Seeded_Path_As_BuiltIn()
{
await SeedElement(
1,
GraphicsElementDefaults.OnNowNextSeededPath,
GraphicsElementKind.Text,
GraphicsElementDefaults.OnNowNextName);
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
List<GraphicsElementResponseModel> result =
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
result.Count.ShouldBe(1);
result[0].BuiltIn.ShouldBeTrue();
}
// #568: identity is GraphicsElementDefaults.IsOnNowNext, an ORDINAL comparison, so a row whose
// path differs from the seeded one only in case is a different element. Reddens if that
// comparison is loosened to OrdinalIgnoreCase. It does NOT pin provider independence: under
// SQLite's BINARY collation a `Where(e => e.Path == ...)` in SQL answers identically, which is
// why the comparison is kept in memory rather than pinned here (see IsOnNowNext's remarks).
[Test]
public async Task GetAllGraphicsElementsForApi_Should_Not_Mark_A_Case_Variant_Of_The_Seeded_Path_As_BuiltIn()
{
await SeedElement(1, CaseVariantOfSeededPath(), GraphicsElementKind.Text, string.Empty);
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
List<GraphicsElementResponseModel> result =
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
result.Count.ShouldBe(1);
result[0].BuiltIn.ShouldBeFalse();
}
// #568: Kind is part of the built-in element's identity, not a second test the seeder applies
// and the API skips. GetBuiltInElementId refuses a wrong-kind row at the seeded path -- it has
// to, since EnsureBuiltInElementRow asks it whether the Text row it is about to create already
// exists -- so an API that reported the same row as builtIn:true would have the two sites
// disagreeing about one row. Reddens if the Kind conjunct is dropped from
// GraphicsElementDefaults.IsOnNowNext (row 18 of the mutation table in docs/graphics-elements.md).
[Test]
public async Task GetAllGraphicsElementsForApi_Should_Not_Mark_A_Wrong_Kind_Row_At_The_Seeded_Path_As_BuiltIn()
{
await SeedElement(
1,
GraphicsElementDefaults.OnNowNextSeededPath,
GraphicsElementKind.Image,
string.Empty);
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
List<GraphicsElementResponseModel> result =
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
result.Count.ShouldBe(1);
result[0].BuiltIn.ShouldBeFalse();
}
// The seeded path with only the FILENAME's case changed -- same folder, same spelling.
private static string CaseVariantOfSeededPath() =>
System.IO.Path.Combine(
System.IO.Path.GetDirectoryName(GraphicsElementDefaults.OnNowNextSeededPath)!,
GraphicsElementDefaults.OnNowNextFileName.ToUpperInvariant());
private async Task SeedElement(int id, string path, GraphicsElementKind kind, string name)
{
await using TvContext context = _db.CreateContext();
@@ -358,9 +358,9 @@ public class ScheduleItemResponseRoundTripTests
// that for this fixture's payload: it forwards every Id, takes the id-based reconcile, and
// updates rows in place. So Id compares equal, and the exemption was unnecessary.
//
// Two mutations of this fixture, both EXECUTED — recorded as results, with no account of why,
// because three earlier drafts of this comment each supplied a confident mechanism for a
// correct observation and two of them were contradicted by the code:
// Two mutations of this fixture, both EXECUTED — recorded as results, with no account of why:
// a confident mechanism for this observation is easy to get wrong, and two independent ones
// were each contradicted by the code:
//
// ToReplaceCommand passes `null` for EVERY id -> test stays GREEN
// ToReplaceCommand passes `null` for index 0 only -> test goes RED, "Id differs"
@@ -0,0 +1,416 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Scheduling;
/// <summary>
/// #568: the same full-replace-DTO FK hardening applied to UpdateChannelHandler's
/// graphicsElementIds also closes the identical twin defect in UpdateDecoHandler -- both
/// graphicsElementIds and watermarkIds are top-level ReplaceDecoRequest fields (not the "deep FK
/// ids nested inside item-list request bodies" carve-out in api-conventions.md), and the
/// reconcile in ApplyUpdateRequest blindly Adds a join row for every incoming id, so an unknown
/// id used to hit the FK constraint at SaveChangesAsync and surface as an unhandled 500.
/// </summary>
[TestFixture]
public class UpdateDecoGraphicsElementsTests
{
private InMemoryTvContext _db = null!;
private ChannelWriter<IBackgroundServiceRequest> _channel = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_channel = Substitute.For<ChannelWriter<IBackgroundServiceRequest>>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private static bool IsLeft<T>(Either<BaseError, T> result) => result.Match(Right: _ => false, Left: _ => true);
private async Task SeedDeco()
{
await using TvContext context = _db.CreateContext();
context.Decos.Add(
new Deco
{
Id = 1,
DecoGroupId = 1,
Name = "D",
BreakContent = [],
DecoWatermarks = [],
DecoGraphicsElements = []
});
await context.SaveChangesAsync();
}
private static UpdateDeco MakeUpdate(
List<int> graphicsElementIds = null,
List<int> watermarkIds = null,
DecoMode? graphicsElementsMode = null,
DecoMode? watermarkMode = null) =>
new(
1,
1,
"D",
watermarkMode ?? DecoMode.Inherit,
watermarkIds ?? [],
false,
graphicsElementsMode ?? (graphicsElementIds is null ? DecoMode.Inherit : DecoMode.Override),
graphicsElementIds ?? [],
false,
DecoMode.Inherit,
[],
DecoMode.Inherit,
CollectionType.Collection,
null,
null,
null,
null,
false,
DecoMode.Inherit,
CollectionType.Collection,
null,
null,
null,
null);
private async Task<int> SeedGraphicsElement()
{
await using TvContext context = _db.CreateContext();
var element = new GraphicsElement { Path = "element-a.yml" };
context.GraphicsElements.Add(element);
await context.SaveChangesAsync();
return element.Id;
}
private async Task<List<int>> SeedGraphicsElements(int count)
{
await using TvContext context = _db.CreateContext();
List<GraphicsElement> elements = Enumerable.Range(0, count)
.Select(i => new GraphicsElement { Path = $"element-{i}.yml" })
.ToList();
context.GraphicsElements.AddRange(elements);
await context.SaveChangesAsync();
return elements.Select(e => e.Id).ToList();
}
private async Task<List<int>> SeedWatermarks(int count)
{
await using TvContext context = _db.CreateContext();
List<ChannelWatermark> watermarks = Enumerable.Range(0, count)
.Select(i => new ChannelWatermark { Name = $"W{i}" })
.ToList();
context.ChannelWatermarks.AddRange(watermarks);
await context.SaveChangesAsync();
return watermarks.Select(w => w.Id).ToList();
}
private async Task<int> SeedWatermark()
{
await using TvContext context = _db.CreateContext();
var watermark = new ChannelWatermark { Name = "W" };
context.ChannelWatermarks.Add(watermark);
await context.SaveChangesAsync();
return watermark.Id;
}
private async Task AttachWatermark(int watermarkId)
{
await using TvContext context = _db.CreateContext();
Deco deco = await context.Decos.Include(d => d.DecoWatermarks).SingleAsync(d => d.Id == 1);
deco.WatermarkMode = DecoMode.Override;
deco.DecoWatermarks.Add(new DecoWatermark { DecoId = 1, WatermarkId = watermarkId });
await context.SaveChangesAsync();
}
private async Task AttachGraphicsElement(int elementId)
{
await using TvContext context = _db.CreateContext();
Deco deco = await context.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
deco.GraphicsElementsMode = DecoMode.Override;
deco.DecoGraphicsElements.Add(new DecoGraphicsElement { DecoId = 1, GraphicsElementId = elementId });
await context.SaveChangesAsync();
}
// Removing UpdateDecoHandler.GraphicsElementIdsMustExist alone from Validate is row 36 of the
// mutation table in docs/graphics-elements.md, measured against the whole ErsatzTV.Tests project.
[Test]
public async Task Should_Reject_Unknown_GraphicsElementId_With_A_Validation_Error_Not_A_Throw()
{
await SeedDeco();
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(graphicsElementIds: [999]),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain("999");
// no partial write: the deco keeps no graphics element association
await using TvContext context = _db.CreateContext();
Deco reloaded = await context.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
reloaded.DecoGraphicsElements.ShouldBeEmpty();
}
// Removing UpdateDecoHandler.WatermarkIdsMustExist alone from Validate is row 37 of the
// mutation table in docs/graphics-elements.md.
[Test]
public async Task Should_Reject_Unknown_WatermarkId_With_A_Validation_Error_Not_A_Throw()
{
await SeedDeco();
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
new UpdateDeco(
1,
1,
"D",
DecoMode.Override,
[999],
false,
DecoMode.Inherit,
[],
false,
DecoMode.Inherit,
[],
DecoMode.Inherit,
CollectionType.Collection,
null,
null,
null,
null,
false,
DecoMode.Inherit,
CollectionType.Collection,
null,
null,
null,
null),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[WatermarkIds]");
error.Value.ShouldContain("999");
}
// The mode, not the id list, decides whether an id is data. ApplyUpdateRequest reconciles the
// join table only under Override/Merge and Clear()s it otherwise, so validating unconditionally
// would reject a save the apply path was going to discard. Removing the ConsumesGraphicsElementIds
// guard alone from UpdateDecoHandler.GraphicsElementIdsMustExist is row 38 of the mutation table
// in docs/graphics-elements.md.
[Test]
public async Task Should_Ignore_An_Unknown_GraphicsElementId_When_The_Mode_Does_Not_Consume_It()
{
await SeedDeco();
int elementId = await SeedGraphicsElement();
await AttachGraphicsElement(elementId);
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(graphicsElementsMode: DecoMode.Inherit, graphicsElementIds: [999]),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
// the apply path discards the ids under Inherit, and the existing attachment with them
await using TvContext reload = _db.CreateContext();
Deco reloaded = await reload.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
reloaded.GraphicsElementsMode.ShouldBe(DecoMode.Inherit);
reloaded.DecoGraphicsElements.ShouldBeEmpty();
}
// Twin of the above for the watermark half; removing the ConsumesWatermarkIds guard alone from
// UpdateDecoHandler.WatermarkIdsMustExist is row 39 of the mutation table in
// docs/graphics-elements.md.
[Test]
public async Task Should_Ignore_An_Unknown_WatermarkId_When_The_Mode_Does_Not_Consume_It()
{
await SeedDeco();
int watermarkId = await SeedWatermark();
await AttachWatermark(watermarkId);
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(watermarkMode: DecoMode.Disable, watermarkIds: [999]),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext reload = _db.CreateContext();
Deco reloaded = await reload.Decos.Include(d => d.DecoWatermarks).SingleAsync(d => d.Id == 1);
reloaded.WatermarkMode.ShouldBe(DecoMode.Disable);
reloaded.DecoWatermarks.ShouldBeEmpty();
}
[Test]
public async Task Should_Accept_A_Known_GraphicsElementId()
{
await SeedDeco();
int elementId = await SeedGraphicsElement();
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(graphicsElementIds: [elementId]),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext reload = _db.CreateContext();
Deco reloaded = await reload.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
reloaded.DecoGraphicsElements.Select(x => x.GraphicsElementId).ShouldBe(new[] { elementId });
}
// Both deco id lists go through the same Validators.IdsMustExist as the channel's, so both
// inherit the same raw-count cap; the channel fixture pins its edges, these two pin that each
// deco field is actually behind it and names itself when it rejects.
[Test]
public async Task Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds()
{
await SeedDeco();
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(graphicsElementIds: Enumerable.Range(1, Validators.MaximumIdListCount + 1).ToList()),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
}
[Test]
public async Task Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds()
{
await SeedDeco();
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(
watermarkMode: DecoMode.Override,
watermarkIds: Enumerable.Range(1, Validators.MaximumIdListCount + 1).ToList()),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[WatermarkIds]");
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
}
// The mode gate is handed to Validators.IdsMustExist rather than short-circuiting the call,
// because only the EXISTENCE half is the apply path's business: a list the reconcile discards
// was still parsed and materialized out of the request body. These two pin that the cap holds
// under a mode that consumes nothing -- row 47 of the mutation table in
// docs/graphics-elements.md. Note the ids all EXIST here, so nothing but the cap can reject
// them: a rejection is the cap's, not a smuggled existence check.
[Test]
public async Task Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them()
{
await SeedDeco();
List<int> ids = await SeedGraphicsElements(Validators.MaximumIdListCount + 1);
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(graphicsElementsMode: DecoMode.Inherit, graphicsElementIds: ids),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
}
[Test]
public async Task Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them()
{
await SeedDeco();
List<int> ids = await SeedWatermarks(Validators.MaximumIdListCount + 1);
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(watermarkMode: DecoMode.Disable, watermarkIds: ids),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[WatermarkIds]");
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
}
// The deco twin of the channel handler's lost-race translation: an element deleted between
// Validate and SaveChangesAsync must come back as the validator's own 422, not the FK
// exception. Removing the DbUpdateException catch from
// UpdateDecoHandler.ApplyUpdateRequestTranslatingLostRace is row 44 of the mutation table in
// docs/graphics-elements.md.
[Test]
public async Task Should_Translate_A_Deco_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422()
{
var interceptor = new ArmedSaveFailureInterceptor();
await _db.DisposeAsync();
_db = await InMemoryTvContext.CreateAsync(interceptor);
await SeedDeco();
int elementId = await SeedGraphicsElement();
interceptor.SqlBeforeFailing =
$"DELETE FROM GraphicsElement WHERE Id = {elementId.ToString(CultureInfo.InvariantCulture)}";
interceptor.Armed = true;
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(graphicsElementIds: [elementId]),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain(elementId.ToString(CultureInfo.InvariantCulture));
}
// The watermark half of the same recovery. Without it, removing the watermark question from the
// recheck would redden nothing -- and the recheck re-asks the whole of Validate exactly so that
// neither id list is the only one covered. Row 46 of the mutation table.
[Test]
public async Task Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422()
{
var interceptor = new ArmedSaveFailureInterceptor();
await _db.DisposeAsync();
_db = await InMemoryTvContext.CreateAsync(interceptor);
await SeedDeco();
int watermarkId = await SeedWatermark();
interceptor.SqlBeforeFailing =
$"DELETE FROM ChannelWatermark WHERE Id = {watermarkId.ToString(CultureInfo.InvariantCulture)}";
interceptor.Armed = true;
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(watermarkMode: DecoMode.Override, watermarkIds: [watermarkId]),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[WatermarkIds]");
error.Value.ShouldContain(watermarkId.ToString(CultureInfo.InvariantCulture));
}
}
@@ -372,7 +372,7 @@ public class GetSearchFieldValuesHandlerTests
[Test]
public async Task List_Valued_Walk_Reads_Live_Rows_Regardless_Of_Id_Density()
{
// THE round-4 killer. That revision bounded the Id KEYSPACE, and keyspace is not rows: with 20,000
// Bounding the Id KEYSPACE is the killer here: keyspace is not rows with 20,000
// historical rows deleted and one live song at Id 20001, the walk spent its whole allowance on empty
// ranges and returned [] for a table containing exactly one row. Capacity degraded linearly with
// deletion ratio, and no ratio was safe -- one placed gap hid the next match.
@@ -0,0 +1,52 @@
{
"description": "Committed stand-in for a user-authored scripted schedule: the ordered sequence of calls such a script makes against /api/v1/scripted/playout/build/{buildId}, replayed through the real ScriptedScheduleController by ScriptedScheduleControllerTests. Field names and casing match what the HTTP body binder accepts \u2014 the replay deserializes each body with ApiJsonSettings, the configuration Startup applies to AddNewtonsoftJson. Deliberately avoids wait_until / pad_until / pad_to_next (local day and time-of-day) and shuffle order, which would make the pinned snapshot machine-timezone- or seed-dependent. add_duration and pad_until_exact both end BETWEEN two content boundaries on purpose: an instruction that happens to end on one never reaches the engine's trim branch, and its trim flag is then witnessed by nothing.",
"script": [
{
"action": "add_collection",
"body": {
"key": "content",
"collection": "Test Collection",
"order": "chronological"
}
},
{
"action": "start_epg_group",
"body": {
"advance": true,
"customTitle": "Morning Block"
}
},
{
"action": "add_count",
"body": {
"content": "content",
"count": 2
}
},
{
"action": "stop_epg_group"
},
{
"action": "add_duration",
"body": {
"content": "content",
"duration": "2:00:00",
"trim": true
}
},
{
"action": "pad_until_exact",
"body": {
"content": "content",
"when": "2026-01-15T09:55:00+00:00",
"trim": true
}
},
{
"action": "add_all",
"body": {
"content": "content"
}
}
]
}
@@ -24,14 +24,13 @@ namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class OpenApiSerializerContractTests
{
// Mirrors Startup.ConfigureServices -> AddNewtonsoftJson exactly.
private static readonly JsonSerializerSettings RuntimeSettings = new()
{
NullValueHandling = NullValueHandling.Ignore,
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
ContractResolver = new CustomContractResolver(),
Converters = { new StringEnumConverter() }
};
// The configuration Startup.ConfigureServices -> AddNewtonsoftJson applies, from the same function
// rather than a hand-copy of it. This fixture is the WRITE-side witness for the naming strategy: the
// four cases below assert camelCase keys, which CustomContractResolver produces and a bare
// JsonSerializerSettings does not. It says nothing about NullValueHandling (every DTO member below is
// populated, so nothing is dropped either way) or the StringEnumConverter (it compares key NAMES, not
// values). docs/testing.md -> "Scripted playout coverage" tabulates which suite witnesses which half.
private static readonly JsonSerializerSettings RuntimeSettings = ApiJsonSettings.Create();
private static IEnumerable<TestCaseData> Cases()
{
@@ -0,0 +1,464 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.ScriptedPlayout;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling.Engine;
using ErsatzTV.Core.Scheduling.ScriptedScheduling;
using ErsatzTV.Serialization;
using LanguageExt;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
/// <summary>
/// In-process stand-in for a scripted playout build (ersatztv#563). A committed script fixture
/// (<c>Fixtures/scripted-build.json</c>) is replayed through the REAL
/// <see cref="ScriptedScheduleController" />, the REAL <see cref="ScriptedPlayoutBuilderService" /> and the
/// REAL <see cref="SchedulingEngine" />, and the resulting <c>PlayoutItem</c>s are compared to a pinned
/// snapshot in the same line format the playout goldens use. The engine is registered under a fixed build
/// id via <see cref="ScriptedPlayoutBuilderService.MockSession" />, the seam that exists for exactly this.
/// <para>
/// The hops this cannot host, named rather than implied: the <c>Cli.Wrap</c> launch of the user's
/// own program (exit code, timeout, stdout capture); the Kestrel/middleware/auth transport the
/// program calls back over; and MVC's binding <i>wrapper</i> — the input formatter's content-type
/// selection and malformed-body handling, model validation (a non-nullable reference type picks
/// up an implicit required check there), and the <c>[ApiController]</c> automatic 400 either
/// produces before an action runs, since every test here hands an action an already-bound object.
/// The serializer <i>inside</i> that wrapper is not residue: request bodies are deserialized with
/// <see cref="ApiJsonSettings" />, the same function <c>Startup</c> hands to <c>AddNewtonsoftJson</c>.
/// It is the production <i>configuration</i>, not the production <i>object</i> — MVC applies it to
/// settings it has already configured, and <c>ApiJsonSettings.Create()</c> starts from a bare one, so
/// MVC's stricter <c>MaxDepth</c> and its two <c>ProblemDetails</c> converters are missing here
/// (enumerated and pinned by <c>ApiJsonSettingsTests</c>; both inert for these DTOs, which nest two
/// levels and are never a <c>ProblemDetails</c>). Two tests hold the two ways that binder can be
/// replaced, since the fixture's own bodies parse identically under all of them:
/// <see cref="Production_Body_Binder_Ignores_Required_Members" /> against a System.Text.Json swap, and
/// <see cref="Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null" /> against a
/// plain Newtonsoft settings object. What no test here observes is <c>Startup</c>'s own
/// registration — <see cref="ApiJsonSettings" /> removes the duplicate rather than detecting
/// drift in one. The reasoning for the scope-out is in docs/testing.md → "Scripted playout
/// coverage" and the decision record <c>testing.scripted-engine-in-process-net</c>.
/// </para>
/// <para>
/// Deviation from the three golden-file nets (docs/contributing.md §10): the expected output is a
/// string constant in this file rather than a committed golden. The golden harness lives in
/// <c>ErsatzTV.Core.Tests</c>, which cannot reference a controller, and duplicating it here would
/// create a second action-to-engine mapping — the thing this design exists to avoid.
/// </para>
/// </summary>
[TestFixture]
public class ScriptedScheduleControllerTests
{
private const string ContentKey = "content";
private const string CollectionName = "Test Collection";
private static readonly Guid BuildId = Guid.Parse("00000000-0000-0000-0000-000000000563");
private static readonly DateTimeOffset Start = new(2026, 1, 15, 6, 0, 0, TimeSpan.Zero);
// What Startup hands to AddNewtonsoftJson, from the same function rather than a hand-copy of it.
// The two Production_Body_Binder_* tests below are what hold this to the production configuration.
private static readonly JsonSerializerSettings BodyBinderSettings = ApiJsonSettings.Create();
// A body that omits the `required` member "collection"; Newtonsoft and System.Text.Json disagree.
private const string BodyMissingRequiredMember = """{"key":"content","order":"chronological"}""";
// A body sending "order" as an explicit null. NullValueHandling.Ignore keeps the DTO's declared
// default; Newtonsoft's own default (Include) overwrites it with null.
private const string BodyWithExplicitNullOrder =
"""{"key":"content","collection":"Test Collection","order":null}""";
// Every action the fixture is expected to exercise. A fixture edit that drops one must fail loudly
// rather than quietly shrinking what the snapshot covers.
private static readonly string[] ExpectedActions =
[
"add_collection",
"start_epg_group",
"add_count",
"stop_epg_group",
"add_duration",
"pad_until_exact",
"add_all"
];
// Raw UTC Start/Finish (never the *Offset properties, which localize), FillerKind, and the item's
// MediaItemId rendered as "Movie NN" — a label built from the id, not a lookup of the seeded title.
//
// 004 and 006 are the two trimmed items, and they are the reason the fixture's numbers look arbitrary.
// Movie 05 (90m) starts at 08:35 inside a two-hour add_duration ending 09:15, and pad_until_exact
// targets 09:55, which Movie 01 (30m) would overshoot from 09:30. Land either target on a content
// boundary instead and the trim branch never runs, leaving that action's `trim` argument unwitnessed.
private const string ExpectedSnapshot =
"""
000 | 2026-01-15 06:00:00 - 2026-01-15 06:30:00 | None | Movie 01
001 | 2026-01-15 06:30:00 - 2026-01-15 07:15:00 | None | Movie 02
002 | 2026-01-15 07:15:00 - 2026-01-15 08:15:00 | None | Movie 03
003 | 2026-01-15 08:15:00 - 2026-01-15 08:35:00 | None | Movie 04
004 | 2026-01-15 08:35:00 - 2026-01-15 09:15:00 | None | Movie 05
005 | 2026-01-15 09:15:00 - 2026-01-15 09:30:00 | None | Movie 06
006 | 2026-01-15 09:30:00 - 2026-01-15 09:55:00 | None | Movie 01
007 | 2026-01-15 09:55:00 - 2026-01-15 10:40:00 | None | Movie 02
008 | 2026-01-15 10:40:00 - 2026-01-15 11:40:00 | None | Movie 03
009 | 2026-01-15 11:40:00 - 2026-01-15 12:00:00 | None | Movie 04
010 | 2026-01-15 12:00:00 - 2026-01-15 13:30:00 | None | Movie 05
011 | 2026-01-15 13:30:00 - 2026-01-15 13:45:00 | None | Movie 06
012 | 2026-01-15 13:45:00 - 2026-01-15 14:15:00 | None | Movie 01
""";
[Test]
public async Task Committed_Script_Fixture_Produces_The_Pinned_Snapshot()
{
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
List<string> replayed = await ReplayFixture(controller);
replayed.ShouldBe(ExpectedActions);
List<PlayoutItem> items = engine.GetState().AddedItems;
Snapshot(items).ShouldBe(Canonicalize(ExpectedSnapshot));
// invariants the snapshot alone does not state, so a regenerated snapshot cannot silently absorb them
items.Count.ShouldBe(13);
for (var i = 1; i < items.Count; i++)
{
items[i].Start.ShouldBe(items[i - 1].Finish);
}
// start_epg_group ... stop_epg_group holds one guide group across its items and titles them
items[0].GuideGroup.ShouldBe(items[1].GuideGroup);
items[2].GuideGroup.ShouldNotBe(items[0].GuideGroup);
items[0].CustomTitle.ShouldBe("Morning Block");
items[1].CustomTitle.ShouldBe("Morning Block");
items[2].CustomTitle.ShouldBeNull();
// after stop_epg_group every item opens its own guide group again
items.Skip(2).Select(i => i.GuideGroup).ShouldBe(Enumerable.Range(2, 11));
// pad_until_exact lands on the requested instant, whatever the machine offset is, and gets there by
// TRIMMING: Movie 01 is 30 minutes and starts at 09:30, so the 25-minute item below is the fixture's
// `"trim": true` reaching the engine. Passing `false` for that argument drops the item entirely.
items[6].Finish.ShouldBe(new DateTime(2026, 1, 15, 9, 55, 0, DateTimeKind.Utc));
(items[6].Finish - items[6].Start).ShouldBe(TimeSpan.FromMinutes(25));
items[6].OutPoint.ShouldBe(TimeSpan.FromMinutes(25));
}
[Test]
public void Production_Body_Binder_Ignores_Required_Members()
{
// The replay above is only faithful while Bind uses MVC's own serializer, and "the two agree" is
// not a safe assumption to leave unstated: Newtonsoft has no notion of the C# `required` keyword,
// so a body omitting one deserializes to a default, where System.Text.Json rejects it outright.
// The claim is about the SERIALIZER only — what MVC's validation layer then does with such a
// body (non-nullable reference types pick up an implicit required check) is the uncovered
// wrapper, not this. Binding through Bind itself is what gives this teeth: the fixture's own
// bodies parse identically under either serializer, so only a body like this one separates them.
// It separates THAT pair only — a Newtonsoft settings object that has merely lost the production
// configuration reads this body exactly as production does, which is the sibling test's subject.
using JsonDocument document = JsonDocument.Parse(BodyMissingRequiredMember);
ContentCollection bound = Bind<ContentCollection>(document.RootElement).ShouldNotBeNull();
bound.Key.ShouldBe(ContentKey);
bound.Collection.ShouldBeNull();
// the negative control: the same body under the serializer a swap would reach for
Should.Throw<System.Text.Json.JsonException>(
() => System.Text.Json.JsonSerializer.Deserialize<ContentCollection>(
BodyMissingRequiredMember,
new JsonSerializerOptions(JsonSerializerDefaults.Web)));
}
[Test]
public async Task Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null()
{
// The other half of the binder-fidelity claim, and the half that a NEWTONSOFT lookalike witnesses:
// production sets NullValueHandling.Ignore, so an explicit `"order": null` leaves ContentCollection
// at its declared "shuffle" rather than overwriting it. Newtonsoft's own default is Include, which
// writes the null through — and AddCollection's Enum.TryParse then rejects it as a 400. So this is
// a behaviour difference a script would see, not a settings-shape assertion — which is what makes
// the "shuffle" and the OkResult below assertions about the production configuration itself.
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
using JsonDocument document = JsonDocument.Parse(BodyWithExplicitNullOrder);
ContentCollection bound = Bind<ContentCollection>(document.RootElement).ShouldNotBeNull();
bound.Order.ShouldBe("shuffle");
IActionResult result = await controller.AddCollection(BuildId, bound, CancellationToken.None);
result.ShouldBeOfType<OkResult>();
// and the content is usable, i.e. the surviving default really was a valid playback order
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeTrue();
}
[Test]
public async Task Unknown_Build_Id_Returns_404()
{
(ScriptedScheduleController controller, SchedulingEngine _) = NewSession();
var other = Guid.Parse("00000000-0000-0000-0000-000000000999");
controller.GetContext(other).Result.ShouldBeOfType<NotFoundObjectResult>();
IActionResult collection = await controller.AddCollection(
other,
new ContentCollection { Key = ContentKey, Collection = CollectionName, Order = "chronological" },
CancellationToken.None);
collection.ShouldBeOfType<NotFoundObjectResult>();
ActionResult<PlayoutContext> count = controller.AddCount(
other,
new PlayoutCount { Content = ContentKey, Count = 1 });
count.Result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Invalid_Playback_Order_Returns_400()
{
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
IActionResult result = await controller.AddCollection(
BuildId,
new ContentCollection { Key = ContentKey, Collection = CollectionName, Order = "nonsense" },
CancellationToken.None);
result.ShouldBeOfType<BadRequestObjectResult>();
// the 400 short-circuits before the engine sees the content
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeFalse();
engine.GetState().AddedItems.ShouldBeEmpty();
}
[Test]
public async Task Unknown_Filler_Kind_Falls_Back_To_None()
{
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
(await controller.AddCollection(
BuildId,
new ContentCollection { Key = ContentKey, Collection = CollectionName, Order = "chronological" },
CancellationToken.None)).ShouldBeOfType<OkResult>();
// an unparseable filler_kind is NOT rejected the way an unparseable order is; it degrades to None
ActionResult<PlayoutContext> result = controller.AddCount(
BuildId,
new PlayoutCount { Content = ContentKey, Count = 1, FillerKind = "not-a-filler-kind" });
result.Result.ShouldBeOfType<OkObjectResult>();
engine.GetState().AddedItems.Single().FillerKind.ShouldBe(FillerKind.None);
}
[Test]
public async Task Known_Filler_Kind_Reaches_The_Item()
{
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
(await controller.AddCollection(
BuildId,
new ContentCollection { Key = ContentKey, Collection = CollectionName, Order = "chronological" },
CancellationToken.None)).ShouldBeOfType<OkResult>();
controller.AddCount(
BuildId,
new PlayoutCount { Content = ContentKey, Count = 1, FillerKind = "PreRoll" })
.Result.ShouldBeOfType<OkObjectResult>();
engine.GetState().AddedItems.Single().FillerKind.ShouldBe(FillerKind.PreRoll);
}
[Test]
public void No_Progress_Throw_Is_Mapped_To_400()
{
(ScriptedScheduleController controller, SchedulingEngine _) = NewSession();
// GetContext reads IsDone, which halts a script that stops advancing time. The engine throws;
// the controller must translate that into a 400 rather than letting it escape as a 500.
for (var i = 0; i < 20; i++)
{
controller.GetContext(BuildId).Result.ShouldBeOfType<OkObjectResult>();
}
controller.GetContext(BuildId).Result.ShouldBeOfType<BadRequestObjectResult>();
}
[Test]
public void Context_Reports_Start_Finish_And_Current_Time()
{
(ScriptedScheduleController controller, SchedulingEngine _) = NewSession();
var ok = controller.GetContext(BuildId).Result.ShouldBeOfType<OkObjectResult>();
var context = ok.Value.ShouldBeOfType<PlayoutContext>();
context.StartTime.ToUniversalTime().ShouldBe(Start);
context.FinishTime.ToUniversalTime().ShouldBe(Start.AddHours(12));
context.CurrentTime.ToUniversalTime().ShouldBe(Start);
context.IsDone.ShouldBeFalse();
}
private static (ScriptedScheduleController Controller, SchedulingEngine Engine) NewSession()
{
var repository = Substitute.For<IMediaCollectionRepository>();
repository.GetCollectionItemsByName(CollectionName, Arg.Any<CancellationToken>())
.Returns(_ => TestCollection());
var engine = new SchedulingEngine(
repository,
Substitute.For<IGraphicsElementRepository>(),
Substitute.For<IChannelRepository>(),
Substitute.For<ILogger<SchedulingEngine>>());
// same setup order ScriptedPlayoutBuilder uses; WithReferenceData must precede RestoreOrReset
engine.WithPlayoutId(1)
.WithMode(PlayoutBuildMode.Reset)
.WithSeed(0)
.BuildBetween(Start, Start.AddHours(12))
.WithReferenceData(new PlayoutReferenceData(null, Option<Deco>.None, [], [], null, [], [], TimeSpan.Zero))
.RestoreOrReset(Option<PlayoutAnchor>.None);
// a fresh service per test, so no session leaks between fixtures
var service = new ScriptedPlayoutBuilderService();
service.MockSession(engine, BuildId).ShouldBeTrue();
return (new ScriptedScheduleController(service), engine);
}
// Deserializes each fixture entry into the controller's own request DTO and invokes the matching action,
// asserting a 200 each time. Returns the actions actually replayed, so the caller can assert coverage.
private static async Task<List<string>> ReplayFixture(ScriptedScheduleController controller)
{
string json = await File.ReadAllTextAsync(Path.Combine(FixtureDir(), "scripted-build.json"));
using JsonDocument document = JsonDocument.Parse(json);
var replayed = new List<string>();
foreach (JsonElement entry in document.RootElement.GetProperty("script").EnumerateArray())
{
string action = entry.GetProperty("action").GetString().ShouldNotBeNull();
JsonElement body = entry.TryGetProperty("body", out JsonElement maybeBody) ? maybeBody : default;
switch (action)
{
case "add_collection":
ShouldBeOk(
await controller.AddCollection(
BuildId,
Bind<ContentCollection>(body),
CancellationToken.None));
break;
case "start_epg_group":
ShouldBeOk(controller.StartEpgGroup(BuildId, Bind<ControlStartEpgGroup>(body)));
break;
case "stop_epg_group":
ShouldBeOk(controller.StopEpgGroup(BuildId));
break;
case "add_count":
ShouldBeOkContext(controller.AddCount(BuildId, Bind<PlayoutCount>(body)));
break;
case "add_all":
ShouldBeOkContext(controller.AddAll(BuildId, Bind<ContentAll>(body)));
break;
case "add_duration":
ShouldBeOkContext(controller.AddDuration(BuildId, Bind<PlayoutDuration>(body)));
break;
case "pad_until_exact":
ShouldBeOkContext(controller.PadUntilExact(BuildId, Bind<PlayoutPadUntilExact>(body)));
break;
default:
Assert.Fail($"Fixture uses action '{action}', which the replayer does not implement.");
break;
}
replayed.Add(action);
}
return replayed;
}
// Deserializes a fixture body with the configuration the production body binder runs
// (Startup -> AddNewtonsoftJson -> ApiJsonSettings), so the fixture's field names and casing are
// asserted against that serializer rather than a convenient one. Not the identical settings OBJECT:
// MVC applies the configuration to its own pre-configured settings, so its stricter MaxDepth and its
// two ProblemDetails converters are absent from Create() — ApiJsonSettingsTests enumerates the gap and
// shows why neither reaches a request body. The two Production_Body_Binder_* tests, not this helper,
// are what make a replacement of the configuration visible.
private static T Bind<T>(JsonElement body) where T : class
{
body.ValueKind.ShouldBe(JsonValueKind.Object);
return JsonConvert.DeserializeObject<T>(body.GetRawText(), BodyBinderSettings).ShouldNotBeNull();
}
private static void ShouldBeOk(IActionResult result) => result.ShouldBeOfType<OkResult>();
private static void ShouldBeOkContext(ActionResult<PlayoutContext> result)
{
var ok = result.Result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBeOfType<PlayoutContext>();
}
// Same line format as PlayoutBuildGoldenTests.Snapshot: ordered by Start then MediaItemId, raw UTC.
private static string Snapshot(List<PlayoutItem> items)
{
List<PlayoutItem> ordered = items
.OrderBy(i => i.Start)
.ThenBy(i => i.MediaItemId)
.ToList();
var sb = new StringBuilder();
for (var index = 0; index < ordered.Count; index++)
{
PlayoutItem item = ordered[index];
sb.Append(index.ToString("D3", CultureInfo.InvariantCulture));
sb.Append(" | ");
sb.Append(item.Start.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
sb.Append(" - ");
sb.Append(item.Finish.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
sb.Append(" | ");
sb.Append(item.FillerKind.ToString());
sb.Append(" | ");
sb.Append($"Movie {item.MediaItemId:D2}");
sb.Append('\n');
}
return sb.ToString();
}
private static string Canonicalize(string text) => text.ReplaceLineEndings("\n").TrimEnd('\n') + "\n";
private static string FixtureDir([CallerFilePath] string thisFile = "") =>
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Fixtures");
// Distinct release dates make chronological order deterministic (id order); distinct durations make
// every boundary in the snapshot unambiguous.
private static List<MediaItem> TestCollection() =>
[
FakeMovie(1, 30),
FakeMovie(2, 45),
FakeMovie(3, 60),
FakeMovie(4, 20),
FakeMovie(5, 90),
FakeMovie(6, 15)
];
private static Movie FakeMovie(int id, int minutes) =>
new()
{
Id = id,
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(minutes) }],
MovieMetadata =
[
new MovieMetadata
{
Title = $"Movie {id:D2}",
ReleaseDate = new DateTime(2005, 1, 1).AddDays(id)
}
]
};
}
@@ -35,7 +35,7 @@ public class GraphicsElementDefaultAttachTests
{
var element = new GraphicsElement
{
Path = $"/templates/text/{GraphicsElementDefaults.OnNowNextFileName}",
Path = GraphicsElementDefaults.OnNowNextSeededPath,
Name = "On Now / Next",
Kind = GraphicsElementKind.Text
};
@@ -153,17 +153,36 @@ public class GraphicsElementDefaultAttachTests
(await AttachedElementIds(context, channel.Id)).Count.ShouldBe(1);
}
// Filename alone is ambiguous: the five template folders are separate namespaces, so an element
// of another kind may legitimately carry the same filename.
[Test]
public async Task Ignores_A_Same_Named_Element_Of_A_Different_Kind()
public async Task Ignores_A_Non_Built_In_Element_With_A_Different_Filename()
{
await using TvContext context = _db.CreateContext();
await context.GraphicsElements.AddAsync(
new GraphicsElement { Path = "/templates/text/something-else.yml", Kind = GraphicsElementKind.Text });
await context.SaveChangesAsync();
Channel channel = await SeedChannel(context, "1");
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
(await AttachedElementIds(context, channel.Id)).ShouldBeEmpty();
}
// #568: filename-only matching was folder-agnostic -- a user Text element named exactly
// "on-now-next.yml" outside the seeded text-template folder used to also count as built-in.
// Same Kind as the real seeded row, deliberately a different folder, so this only reddens if
// GetBuiltInElementId goes back to comparing Path.GetFileName(...) instead of the full path.
[Test]
public async Task Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder()
{
await using TvContext context = _db.CreateContext();
await context.GraphicsElements.AddAsync(
new GraphicsElement
{
Path = $"/templates/image/{GraphicsElementDefaults.OnNowNextFileName}",
Kind = GraphicsElementKind.Image
Path = System.IO.Path.Combine(
"/config/graphics-elements/text/some-subfolder",
GraphicsElementDefaults.OnNowNextFileName),
Kind = GraphicsElementKind.Text
});
await context.SaveChangesAsync();
@@ -174,12 +193,23 @@ public class GraphicsElementDefaultAttachTests
(await AttachedElementIds(context, channel.Id)).ShouldBeEmpty();
}
// #568: GetBuiltInElementId resolves through GraphicsElementDefaults.IsOnNowNext, an ORDINAL
// comparison, so a Text row in the seeded folder whose filename differs only in case is a
// different element. Reddens if that comparison is loosened to OrdinalIgnoreCase. It does NOT
// pin provider independence -- under SQLite's BINARY collation an equivalent SQL `Where` answers
// identically; that is held by keeping the comparison in memory (see IsOnNowNext's remarks).
[Test]
public async Task Ignores_A_Non_Built_In_Element_With_A_Different_Filename()
public async Task Ignores_A_Case_Variant_Of_The_Seeded_Path()
{
await using TvContext context = _db.CreateContext();
await context.GraphicsElements.AddAsync(
new GraphicsElement { Path = "/templates/text/something-else.yml", Kind = GraphicsElementKind.Text });
new GraphicsElement
{
Path = System.IO.Path.Combine(
System.IO.Path.GetDirectoryName(GraphicsElementDefaults.OnNowNextSeededPath)!,
GraphicsElementDefaults.OnNowNextFileName.ToUpperInvariant()),
Kind = GraphicsElementKind.Text
});
await context.SaveChangesAsync();
Channel channel = await SeedChannel(context, "1");
@@ -242,6 +272,54 @@ public class GraphicsElementDefaultAttachTests
(await GraphicsElementSeeder.GetBuiltInElementId(context, CancellationToken.None)).IsSome.ShouldBeTrue();
}
// #568: EnsureBuiltInElementRow's "does it exist already?" test and the lookup every consumer
// resolves through must be the same question. While that test was its own SQL comparison
// (`e.Path == target`), a row at the seeded path of ANY kind answered yes, so the Text row
// GetBuiltInElementId actually matches was never created and the built-in element stayed
// undiscoverable for the life of the install.
//
// This is the half of that divergence SQLite can observe. The other half -- a case-variant path,
// which MySQL's normally case-insensitive default collation matches in SQL while the ordinal
// lookup never does -- cannot be shown from a test here, because SQLite's BINARY collation and
// an ordinal comparison agree on every input; it is held by keeping the comparison out of SQL.
//
// RefreshGraphicsElements derives Kind from the template FOLDER, so it cannot itself produce the
// wrong-kind row seeded below. The point is not that this state is common but that the seeder
// must not answer a question differently from the code that consumes its answer.
[Test]
public async Task A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row()
{
var fs = new MockFileSystem();
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
string target = Path.Combine(
FileSystemLayout.GraphicsElementsTextTemplatesFolder,
GraphicsElementDefaults.OnNowNextFileName);
await fs.File.WriteAllTextAsync(target, "name: On Now / Next\n");
await using TvContext context = _db.CreateContext();
context.ConfigElements.Add(
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
await context.GraphicsElements.AddAsync(
new GraphicsElement { Path = target, Kind = GraphicsElementKind.Image });
await context.SaveChangesAsync();
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
List<GraphicsElement> rows = await context.GraphicsElements.OrderBy(e => e.Id).ToListAsync();
rows.Count.ShouldBe(2, "the built-in Text row must be created alongside the wrong-kind row");
rows[1].Path.ShouldBe(target);
rows[1].Kind.ShouldBe(GraphicsElementKind.Text);
rows[1].Name.ShouldBe(GraphicsElementDefaults.OnNowNextName);
// The lookup must land on the Text row, not on the lower-id wrong-kind row that shares its
// path. Both clauses this test covers are measured rows of the mutation table in
// docs/graphics-elements.md -- 18 (the Kind filter) and 34 (the existence check re-derived
// in SQL) -- rather than an outcome asserted only here.
int builtInId = (await GraphicsElementSeeder.GetBuiltInElementId(context, CancellationToken.None))
.IfNone(-1);
builtInId.ShouldBe(rows[1].Id);
}
// The armed path is a real, reachable state: an operator deletes the template, refresh reaps the
// row, and the backfill then has nothing to resolve. Pin what happens when the element comes
// back -- a single global marker cannot both avoid stranding and avoid re-adding, and this is
@@ -33,7 +33,7 @@ namespace ErsatzTV.Tests.Integration;
/// the decision record <c>media.nullable-primitive-collection-mutation</c>.
/// Assigning one on a TRACKED entity flips it to <see cref="EntityState.Modified" />, and the next
/// <c>SaveChanges</c> writes <c>[]</c> over what the database held as <c>NULL</c> — the exact
/// mechanism an adversarial review demonstrated in ersatztv#691, which is why that issue's
/// mechanism demonstrated in ersatztv#691, which is why that issue's
/// entity-level guard was reverted in favour of guarding at the READ SITE.
/// </para>
/// <para>
@@ -0,0 +1,84 @@
using ErsatzTV.Serialization;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Serialization;
/// <summary>
/// Pins the gap between the two objects <see cref="ApiJsonSettings" /> produces:
/// <see cref="ApiJsonSettings.Apply" /> onto MVC's own settings, which is what the production body binder
/// runs, and <see cref="ApiJsonSettings.Create" />, which tests outside the MVC pipeline
/// (<c>ScriptedScheduleControllerTests</c>, <c>OpenApiSerializerContractTests</c>) bind and serialize with.
/// They are not the same object, and the difference belongs in a test rather than in a comment: a prose
/// claim of parity cannot notice the day it stops being true, and every doc describing the scripted
/// coverage rests on knowing which parts carry over and which do not.
/// </summary>
[TestFixture]
public class ApiJsonSettingsTests
{
// The object MVC's options pipeline constructs before Startup's configure delegate runs. Building it
// directly rather than hosting the app is the point of the caveat below: this measures the settings
// MvcNewtonsoftJsonOptions itself defines, not a fully composed host.
private static JsonSerializerSettings Production() =>
ApiJsonSettings.Apply(new MvcNewtonsoftJsonOptions().SerializerSettings);
[Test]
public void Apply_Sets_The_Same_Four_Things_On_Either_Object()
{
JsonSerializerSettings production = Production();
JsonSerializerSettings standalone = ApiJsonSettings.Create();
production.NullValueHandling.ShouldBe(NullValueHandling.Ignore);
standalone.NullValueHandling.ShouldBe(NullValueHandling.Ignore);
production.ReferenceLoopHandling.ShouldBe(ReferenceLoopHandling.Ignore);
standalone.ReferenceLoopHandling.ShouldBe(ReferenceLoopHandling.Ignore);
production.ContractResolver.ShouldBeOfType<CustomContractResolver>();
standalone.ContractResolver.ShouldBeOfType<CustomContractResolver>();
production.Converters.ShouldContain(c => c is Newtonsoft.Json.Converters.StringEnumConverter);
standalone.Converters.ShouldContain(c => c is Newtonsoft.Json.Converters.StringEnumConverter);
}
[Test]
public void Standalone_Matches_On_The_Mvc_Defaults_That_Change_How_A_Body_Parses()
{
JsonSerializerSettings production = Production();
JsonSerializerSettings standalone = ApiJsonSettings.Create();
standalone.MissingMemberHandling.ShouldBe(production.MissingMemberHandling);
standalone.TypeNameHandling.ShouldBe(production.TypeNameHandling);
standalone.DateParseHandling.ShouldBe(production.DateParseHandling);
}
[Test]
public void Standalone_Does_Not_Carry_Mvcs_Depth_Limit_Or_Its_Error_Converters()
{
JsonSerializerSettings production = Production();
JsonSerializerSettings standalone = ApiJsonSettings.Create();
// MVC clamps the object graph it will consume; a bare settings object keeps Newtonsoft's own limit.
// A body deeper than 32 therefore binds here and 400s in production — inert for the scripted request
// DTOs, which nest two levels, and the reason no test may generalize from Create() to "production".
production.MaxDepth.ShouldBe(32);
standalone.MaxDepth.ShouldBe(64);
// The rest of the delta is MVC's error-response shaping, which no request body reaches.
string[] missing = production.Converters
.Select(c => c.GetType().Name)
.Except(standalone.Converters.Select(c => c.GetType().Name))
.Order()
.ToArray();
missing.ShouldBe(["ProblemDetailsConverter", "ValidationProblemDetailsConverter"]);
// and nothing goes the other way: the standalone object adds nothing production lacks
standalone.Converters
.Select(c => c.GetType().Name)
.Except(production.Converters.Select(c => c.GetType().Name))
.ShouldBeEmpty();
}
}
@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace ErsatzTV.Tests.Support;
/// <summary>
/// Makes the next <c>SaveChangesAsync</c> fail the way a lost race against a concurrent delete
/// does: run <see cref="SqlBeforeFailing" /> first (the delete), then throw the
/// <see cref="DbUpdateException" /> the foreign-key violation would have produced.
/// </summary>
/// <remarks>
/// What is simulated is the TRIGGER, not the decision: <see cref="InMemoryTvContext" /> runs
/// with foreign keys OFF, so a real constraint cannot fire here. The handler's recovery — re-ask
/// the existence question against the post-delete state, translate a now-missing id into the
/// validator's own 422, re-throw anything else — is real code running against real data.
/// Disarms itself on the first fire so the recovery path gets a working context.
/// </remarks>
public sealed class ArmedSaveFailureInterceptor : SaveChangesInterceptor
{
public bool Armed { get; set; }
public string SqlBeforeFailing { get; set; } = string.Empty;
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (!Armed)
{
return await base.SavingChangesAsync(eventData, result, cancellationToken);
}
Armed = false;
if (!string.IsNullOrEmpty(SqlBeforeFailing))
{
await eventData.Context!.Database.ExecuteSqlRawAsync(SqlBeforeFailing, cancellationToken);
}
throw new DbUpdateException("simulated: a validated principal row was deleted concurrently");
}
}
@@ -128,7 +128,8 @@ public abstract class ChannelHandlerTestBase
string logoPath = "",
string name = "Test",
string group = "ErsatzTV",
List<int> graphicsElementIds = null) =>
List<int> graphicsElementIds = null,
int? watermarkId = null) =>
new(
channelId,
name,
@@ -147,7 +148,7 @@ public abstract class ChannelHandlerTestBase
null,
null,
StreamingMode.TransportStreamHybrid,
null,
watermarkId,
null,
string.Empty,
ChannelSubtitleMode.None,
+9 -1
View File
@@ -3,6 +3,7 @@ using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Sqlite.Data;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
namespace ErsatzTV.Tests.Support;
@@ -27,7 +28,13 @@ public sealed class InMemoryTvContext : IAsyncDisposable
public IDbContextFactory<TvContext> Factory => new TestDbContextFactory(_options);
public static async Task<InMemoryTvContext> CreateAsync()
/// <param name="interceptors">
/// Extra EF interceptors registered on every context this harness hands out. The only
/// current use is making <c>SaveChangesAsync</c> fail on demand: foreign keys are off here
/// (see the type summary), so a write-path failure a real FK constraint would raise cannot
/// be produced by seeding alone.
/// </param>
public static async Task<InMemoryTvContext> CreateAsync(params IInterceptor[] interceptors)
{
TvContext.IsSqlite = true;
TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation;
@@ -38,6 +45,7 @@ public sealed class InMemoryTvContext : IAsyncDisposable
DbContextOptions<TvContext> options = new DbContextOptionsBuilder<TvContext>()
.UseSqlite(connection)
.AddInterceptors(interceptors)
.Options;
await using (TvContext context = Create(options))
+43
View File
@@ -0,0 +1,43 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace ErsatzTV.Serialization;
/// <summary>
/// The Newtonsoft settings the MVC JSON formatters read and write `/api/*` bodies with. Defined once,
/// here, so a test can deserialize a request body exactly the way the production body binder does
/// instead of mirroring <see cref="Startup" />'s registration. This REMOVES the duplicate rather than
/// detecting drift in one: no test observes <c>Startup.ConfigureServices</c>, and a byte-equal hand-copy
/// of <see cref="Apply" /> is behaviourally indistinguishable from calling it. What the tests do
/// separate is a caller that binds with something else — a body omitting a C#
/// <c>required</c> member deserializes to a default here where System.Text.Json throws, and
/// <see cref="NullValueHandling" />.<c>Ignore</c> keeps a DTO's declared default over an explicit
/// null where plain Newtonsoft writes the null through. Both are pinned by
/// <c>ScriptedScheduleControllerTests</c>; the naming strategy is pinned on the write side by
/// <c>OpenApiSerializerContractTests</c>. See docs/testing.md → "Scripted playout coverage".
/// </summary>
public static class ApiJsonSettings
{
/// <summary>Applies the production configuration to an existing settings object (the MVC path).</summary>
public static JsonSerializerSettings Apply(JsonSerializerSettings settings)
{
settings.NullValueHandling = NullValueHandling.Ignore;
settings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
settings.ContractResolver = new CustomContractResolver();
settings.Converters.Add(new StringEnumConverter());
return settings;
}
/// <summary>
/// <see cref="Apply" /> onto a bare settings object, for a caller outside the MVC pipeline. It carries
/// everything <see cref="Apply" /> sets and nothing MVC supplies around it, so it is NOT the object the
/// body binder runs. Measured 2026-09-05 against <c>new MvcNewtonsoftJsonOptions().SerializerSettings</c>:
/// <c>MaxDepth</c> is Newtonsoft's 64 rather than MVC's stricter 32, and MVC's two error-shaping
/// converters — <c>ProblemDetailsConverter</c> and <c>ValidationProblemDetailsConverter</c> — are absent.
/// <c>MissingMemberHandling</c>, <c>TypeNameHandling</c> and <c>DateParseHandling</c> match. Both gaps are
/// inert for the request DTOs the tests bind (two levels deep, never a <c>ProblemDetails</c>), which is why
/// this is usable there at all; the delta is pinned by <c>ApiJsonSettingsTests</c> so it cannot decay into
/// a parity claim.
/// </summary>
public static JsonSerializerSettings Create() => Apply(new JsonSerializerSettings());
}
+3 -8
View File
@@ -104,7 +104,6 @@ using Microsoft.IdentityModel.Tokens;
using Microsoft.IO;
using Microsoft.OpenApi;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Refit;
using Scalar.AspNetCore;
using Serilog;
@@ -503,13 +502,9 @@ public class Startup
options.OutputFormatters.Insert(0, new HdhrJsonOutputFormatter());
options.Filters.AddService<ApiAuthorizationFilter>();
})
.AddNewtonsoftJson(opt =>
{
opt.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
opt.SerializerSettings.ContractResolver = new CustomContractResolver();
opt.SerializerSettings.Converters.Add(new StringEnumConverter());
});
// ApiJsonSettings rather than an inline lambda: the request-body binder's semantics are asserted
// by ScriptedScheduleControllerTests, which has to configure the SAME object, not a mirror of it.
.AddNewtonsoftJson(opt => ApiJsonSettings.Apply(opt.SerializerSettings));
services.AddScoped(_ => new ConditionalIptvAuthorizeFilter("JwtOnlyScheme"));
@@ -176,7 +176,7 @@ Tracked under epic **#62**; sub-issues #63#68:
## 7. Out of scope & next steps
**Out of scope now:** the full #59 implementation spec, React app architecture (routing/state/auth/build/deploy), the media-libraries/collections browser (Pass 2), browser-based channel playback (tracked separately in #60), the actual rebuild. (Channel creation was originally deferred to Pass 2 but is now specified in §6.)
**Out of scope now:** the full #59 implementation spec, React app architecture (routing/state/auth/build/deploy), the media-libraries/collections browser (Pass 2), browser-based channel playback (tracked separately in #60), the actual rebuild. (Channel creation is specified in §6, not deferred to Pass 2.)
**Next steps (after a look is locked):**
1. Design the §6 Create Channel screen in Claude Design; file the §6 backend-gap issues.
+35 -31
View File
@@ -9,37 +9,41 @@ WORKDIR /source
COPY design-system/. ./design-system/
COPY web/. ./web/
WORKDIR /source/web
# The SPA suite runs here except for two files (ersatztv#819), excluded for OVERLAPPING reasons —
# one needs the git binary, the other needs the binary AND a checkout. Reading them as two separate
# reasons is what broke this stage once already:
# * `web/src/api/pageSizeCallSites.guard.test.ts` needs a git CHECKOUT — and, through it, the
# binary. It derives its file population from `git ls-files` rather than a directory walk, and
# refuses rather than falling back. This stage's context is `web/` + `design-system/` only, so
# there is no `.git`.
# * `web/vite-plugins/trackedSourceFiles.realgit.test.ts` needs the BINARY but no checkout: it
# builds its own temp repository to prove the derivation by executing it.
# `node:22-bookworm-slim` ships no git (`command -v git` -> not found), so it dies with
# `spawnSync git ENOENT`.
# So this is NOT checkout-versus-binary, and adding a `COPY .git` would not let either run here —
# the binary would still be missing. Excluding only the first is not enough either, and a replica
# that merely deletes `.git` cannot show that: verify any change here with the git binary off
# `PATH`, not just with the directory absent.
# Everything else — all but those two files — runs fine gitless and is kept, rather than dropping
# the whole suite for one file as an earlier fix here did.
# The excluded pair is not skipped overall: `docker-build.yml`'s `Build & test (.NET)` job runs the
# whole suite on a real checkout, and `build` (the job that invokes this Dockerfile) carries
# `needs: [test, migrations, scan]`. State that chain precisely, because the `needs:` edge is not
# all of it: `Test SPA` is also gated on `docs_only` and on the #420 revalidate skip, and `build` is
# not gated on `revalidate`. On a push whose tree is byte-identical to an already-green head the
# suite is skipped and the image still builds — carried there by #420's byte-identical-tree
# argument. The `docs_only` arm cannot ship an image at all (`Build and push` is gated on it too),
# and `ci-detect-docs-only.sh` classifies by PATH SHAPE (`docs/` or `*.md`), not by directory, so a
# `web/*.md` would count as docs — there are none today, but do not restate it as "any `web/**`".
RUN npm run lint && npm run typecheck && \
npm test -- --run \
--exclude 'src/api/pageSizeCallSites.guard.test.ts' \
--exclude 'vite-plugins/trackedSourceFiles.realgit.test.ts' && \
npm run build
# THE VITEST SUITE IS NOT RUN IN THIS STAGE, AND MUST NOT BE RE-ADDED (ersatztv#887).
# This stage is gitless in both senses at once. It copies only `web/` and `design-system/`, so it
# holds no `.git` — a property of the STAGE, not of the context, which is the repository root
# (`context: .`) and whose `.dockerignore` does not exclude `.git`: the directory is there to be
# copied and simply is not. And `node:22-bookworm-slim` ships no git binary (`command -v git` -> not
# found), which is why a `COPY .git` would not help either. Members of the SPA suite need one or the
# other: `pageSizeCallSites.guard.test.ts` and `completeAnnotations.guard.test.ts` derive their file
# population from `git ls-files` and refuse to fall back to a directory walk
# (`testing.guard-derives-population-from-source`), and `trackedSourceFiles.realgit.test.ts` builds
# its own temp repository. (Not every `*.guard.test.ts` — `completeRequest.guard.test.ts` needs no
# git at all.)
#
# Running the suite here anyway therefore costs a hand-maintained list of the members that cannot
# run — and that list is a population nothing derives. It went stale the first time a guard was
# added without updating it, and because `Build & push image (amd64)` is `if: github.event_name !=
# 'pull_request'`, the resulting red is unreachable on a PR and lands on `main` and on the `v*` tag
# path: every image build failed and `:latest` stopped being republished. Excluding one more file
# re-arms that; the list is removed instead.
#
# WHAT STILL VALIDATES THE SUITE. `docker-build.yml`'s `test` job runs it UNFILTERED on a real
# checkout, and `build` — the job that invokes this Dockerfile — carries `needs: [test, migrations,
# scan]`, so an image cannot be published past a red suite. Two skips exist inside `test` and
# neither leaves an image unvalidated: `docs_only` also gates `Build and push`, so that arm ships
# nothing — and that arm IS asserted, by `test_the_DOCS_ONLY_arm_cannot_publish_an_image`. The #420
# revalidate skip fires only on a tree byte-identical to a head that already carried a green combined
# status; that arm is NOT asserted here — it rests on `scripts/ci-detect-already-validated.sh`, which
# carries no mutation proof of its own, so it is a dependency rather than a check. Held by
# `scripts/tests/test_image_build_delegates_the_spa_suite.py`. That guard PINS the command line
# below as text: any edit to it reddens, whatever the edit says. Update the pin in the same commit,
# and do not use it to re-add a suite run.
#
# `lint` and `typecheck` stay. They are gitless-safe with no member that is not, so they carry no
# list and no trap; whether the image build should run them at all is a separate question this
# does not answer.
RUN npm run lint && npm run typecheck && npm run build
FROM --platform=linux/amd64 192.168.1.95:3000/timothy/ersatztv-ffmpeg:8.1.2 AS runtime-base
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
+5 -1
View File
@@ -17,6 +17,7 @@ doc below, or that changes which sections a task signal points to.**
| Signal | Read |
| --- | --- |
| Session startup / "what's next" (no issue named) | `docs/handoffs/chicorytv-issue-queue.md` (standing kickoff — two concurrent tracks: orientation ‖ `scripts/select-queue.sh 5`) |
| Running SEVERAL issues in parallel under one orchestrator (the `orchestrator-prompt.md` kickoff) | `docs/handoffs/orchestration.md` — roles and sizing, one worktree per issue under `~/orca/workspaces/ersatztv/`, the landing order (gate + review before the single push; verdict via `scripts/post-review-verdict.sh`; merge through the consent hook), resuming a paused branch; rule: `process.orchestrated-session`. The queue rules and HARD CONSTRAINTS stay in `chicorytv-issue-queue.md` |
| Named-issue pickup | Skip queue selection; go straight to focused retrieval — see "Knowledge retrieval" below, then the issue body |
| Adding/changing a `/api/*` endpoint | `docs/api-conventions.md` checklist + `docs/endpoint-index.md` |
| Adding a ChicoryTV SPA screen | `docs/spa-conventions.md` |
@@ -27,8 +28,9 @@ doc below, or that changes which sections a task signal points to.**
| Concurrency / optimistic-locking work | `docs/api-conventions.md` §7a/b/c + `docs/decisions/optimistic-concurrency.md` |
| Auth / security-surface work | `docs/decisions/api-auth-security.md` |
| CI / release pipeline work | `docs/ci-cd.md` + `docs/decisions/release-ci-governance.md` |
| Proposing a new guard / CI check / regression test convention | `docs/defect-shapes-773.md` §4 (detector menu + the classes where no detector is plausible), then the rules every guard must satisfy: `docs/decisions/records/testing/guard-derives-population-from-source.md`, `…/guard-ships-with-mutation-proof.md` and `…/mutation-claims-are-executed.md` (a `MUTATION` grade carries a DECLARED clause mutation that is re-run every suite) — plus `…/verification-code-needs-its-own-proof.md`, which extends the same obligation BEYOND guards to the harness, wrapper or checker doing the checking, and says where its proof lives when the checker holds no row |
| Proposing a new guard / CI check / regression test convention | `docs/defect-shapes-773.md` §4 (detector menu + the classes where no detector is plausible), then the rules every guard must satisfy: `docs/decisions/records/testing/guard-derives-population-from-source.md`, `…/guard-ships-with-mutation-proof.md` and `…/mutation-claims-are-executed.md` (a `MUTATION` grade carries a DECLARED clause mutation that is re-run every suite — and so does a PROSE claim that some mutation reddens, or does not redden, a named test, wherever it is written: it is a `CLAIMS` entry in the same manifest, bound to its site and verbatim quote, or it is not written) — plus `…/verification-code-needs-its-own-proof.md`, which extends the same obligation BEYOND guards to the harness, wrapper or checker doing the checking, and says where its proof lives when the checker holds no row. Then `…/guard-pins-the-artifact-not-a-shape.md` for the SHAPE of the predicate itself: over an artifact whose GRAMMAR the predicate does not implement, pin the artifact WHOLE by default — matching a shape inside it is the exception and must carry that record's four-part argument; that record's `rule:` is the one place the qualifying grammars are enumerated |
| Adding or bounding a consequential numeric config field (an FFmpeg profile tunable, a pipeline knob) | `docs/api-conventions.md` §3d — reject out of range with a 422 naming the bound and its consequence, never accept-then-rewrite; validate against the constants the renderer reads, keep the render-time clamp for pre-existing rows, and let an UNCHANGED legacy value through on update. Then `api.ffmpeg-profile-numeric-bounds` |
| Adding or changing a write path that takes a top-level LIST of FK ids (`graphicsElementIds`, `watermarkIds`) | `docs/api-conventions.md` §3b — bound the RAW list through `Validators.IdsMustExist` before `Distinct`, reject an unknown id with a 422 that names the request field, gate only the EXISTENCE half on whatever makes the apply path discard the list, and translate a lost race by re-running the handler's whole `Validate` on a fresh context. Then `api.top-level-id-list-validation`; the repo-wide rollout is #917 |
| Testing a surface gated by config / an env var / a credential | `docs/decisions/records/testing/deny-path-at-production-config-value.md` — cover the setting absent, at its production value, and each opt-out, and assert the DENY branch |
| Touching a full-replace write path or a hand-built request object | `docs/decisions/records/testing/full-replace-asserts-field-list.md` — derive the field list from the DTO and assert set equality; reconcile by id where child state exists. In the SPA the same rule is enforced by the type system: `docs/spa-conventions.md` §4b — build the body as `Complete<T>`, annotating BOTH the wrapper parameter and every construction site |
| Writing or editing any doc, or answering a review finding in prose | `docs/decisions/records/docs/no-session-narrative.md` — the doc records the END STATE; the path to it goes in the commit message. Apply the who-benefits test, and read the carve-out before you cut (dated measurements, stated snapshot boundaries and tested-and-rejected results stay) |
@@ -120,6 +122,8 @@ bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "K
that file's standing kickoff for the two concurrent tracks (orientation ‖ selection). ersatztv#237
is a closed, archival historical tracker (superseded by `startup.parallel-orientation` in
`docs/decisions.md`) — not a live pointer.
- **`docs/handoffs/orchestration.md`** — mechanics of an orchestrated session: roles, isolation, landing a branch through the gate (`process.orchestrated-session`), resume, incidents. Workflow scripts: `.claude/workflows/ersatztv-{pick-next,issue-build,resume-branch}.js`.
- **`docs/handoffs/orchestrator-prompt.md`** — the standing prompt that starts an orchestrated session; the single-issue kickoff stays in `chicorytv-issue-queue.md`.
- **`docs/defect-shapes-773.md`** — root-cause analysis of the recurring defect shapes across the
whole closed-issue corpus (#773): the measured class ranking, the four families they consolidate
into, the cheapest mechanical detector per class, the classes where **no** detector is plausible,
+57 -6
View File
@@ -302,6 +302,32 @@ handler's validation when a lookup fails, so the controller-side mapping falls o
caller) → surface as 422 instead of silently returning a shorter list.
- Unbounded `int`/`TimeSpan` inputs from the request → clamp or validate, per the Logs pagination
pattern above.
- **A top-level LIST of FK ids is an unbounded input too** — the body-size cap is a transport limit,
not a collection limit, and millions of compact integers fit under it. Bound the list with
`Validators.IdsMustExist` (#568), which counts the **raw** list before `Distinct` and before any
database work — deduplication is not what the request costs to parse and materialize — and caps how
many rejected ids the 422 echoes back, so an oversized request does not earn an oversized response.
The count is the REQUEST's bound, not the apply path's: where another field of the same body (a
deco's `DecoMode`) makes the reconcile discard the list, the mode gates the EXISTENCE half only —
a discarded list was still parsed and materialized out of the body. Pass that predicate to
`IdsMustExist` as `idsAreConsumed` rather than short-circuiting the call, which takes the cap with
it. The rollout to the other FK-id validators on these DTOs is #917; the rule and its residuals are
`api.top-level-id-list-validation`.
- **Name the request field in the validation message**, `[GraphicsElementIds] …`, using the same
`[{GetMemberName(expression)}]` prefix `Validators.NotEmpty`/`NotLongerThan`/`AtLeast` already
produce. A full-replace DTO carries several id lists, and `Graphics element(s) do not exist: 42`
does not say which one to fix.
- **Validation and the write are two statements, so an FK the validator confirmed can be gone by
`SaveChangesAsync`** — a background job deleting the principal row (e.g. `RefreshGraphicsElements`
removing elements whose template file is gone) restores the very 500 the validator was added to
prevent. A transaction does not close that window: neither provider locks the rows the validator
merely READ. Catch `DbUpdateException` around the save, re-ask the question on a **fresh** context
(the failed one still tracks the changes it could not write), and return the validator's own 422 if
an id has since gone — re-throwing anything else, so a real fault is not reported as a client error
(#568). **Re-run the handler's whole `Validate`, never a named subset of its fields**: every FK the
save writes loses the same race, and a recovery path that enumerates the fields it knows about
silently omits the next one the DTO gains. Re-running the validator set covers a check added later
by construction.
- **Dereferencing a request `string` (e.g. `request.Name.Length`) is a latent 500** — request DTOs
carry no `#nullable` context (§2), so a `string Name` binds `null` from `name: null`/an omitted field
and there is no implicit `[Required]`; a raw `.Length`/`.Trim()` throws `NullReferenceException` → an
@@ -641,7 +667,11 @@ Runtime `/api/*` JSON is serialized by **Newtonsoft** (`AddNewtonsoftJson` in `S
`ErsatzTV/Serialization/CustomContractResolver.cs``CustomNamingStrategy` (camelCase **plus** a
special case mapping any `FFmpegProfileId` member to `"ffmpegProfileId"`, and honoring any
`[JsonProperty("...")]` attribute, e.g. `ChannelResponseModel.FFmpegProfile`
`[JsonProperty("ffmpegProfile")]`). The OpenAPI document, however, is generated from
`[JsonProperty("ffmpegProfile")]`). The settings themselves live in
`ErsatzTV/Serialization/ApiJsonSettings.cs`, which `Startup` applies — call it from a test that needs to
serialize or bind the way the runtime does rather than restating the settings, which drifts silently
(`OpenApiSerializerContractTests`, `ScriptedScheduleControllerTests`). The OpenAPI document, however, is
generated from
**System.Text.Json** metadata, whose camelCase can differ (it emitted `fFmpegProfileId` /
`fFmpegProfile`). That drift silently gave the SPA the wrong key to read (issue #198).
@@ -931,11 +961,32 @@ for items that have no group, so the SPA can render an "ungrouped" bucket
concept elsewhere, this is the established pattern to follow — but be aware it means `Id` is not a
reliable real-entity id for those synthetic rows.
**Channel graphics (issue #74)**: `ChannelDetailResponseModel`/`UpdateChannelRequest` carry
`graphicsElementIds` (the channel's attached `GraphicsElement` ids, reconciled add/remove on PUT via
`Channel.ChannelGraphicsElements`), and `GraphicsElementResponseModel` exposes a server-derived
`builtIn` (`Path.GetFileName(element.Path) == GraphicsElementDefaults.OnNowNextFileName`) — never
client-settable.
**Channel graphics (issue #74, hardened #568)**: `ChannelDetailResponseModel`/`UpdateChannelRequest`
carry `graphicsElementIds` (the channel's attached `GraphicsElement` ids, reconciled add/remove on PUT
via `Channel.ChannelGraphicsElements`); `UpdateChannelHandler.Validate` rejects any id not present in
`GraphicsElements` with 422 (previously an unhandled 500 from the FK constraint — #568), matching
every other field on this full-replace DTO. The identical shape existed on `PUT /api/v1/decos/{id}`
(`ReplaceDecoRequest.graphicsElementIds`/`watermarkIds`, also top-level fields, not the
`deep-FK-in-a-nested-list` carve-out of §3b above) and is hardened the same way by
`UpdateDecoHandler.Validate` (`GraphicsElementIdsMustExist`/`WatermarkIdsMustExist`, #568). Each
deco validator passes the same `Override`/`Merge` mode predicate that makes the apply path consume
its id list — under `Inherit`/`Disable` the reconcile clears the join and ignores the ids, so
validating them there would 422 a request over ids it was about to discard. The SPA sends both id
lists whatever the mode selector says, so that shape arrives from the real editor. The predicate is
an argument (`idsAreConsumed`) rather than a short-circuit around the call, because it gates the
existence half alone and the raw-count cap applies under every mode.
All three id lists go through one shared validator, `Validators.IdsMustExist`, which is where the
count cap (`MaximumIdListCount`), the request field named in the message and the cap on echoed ids
are written once rather than three times. Both handlers translate a lost race the same way, by
re-running their own `Validate` on a fresh context after a `DbUpdateException` — so every FK on the
DTO is covered, not just the id lists this issue was about.
`GraphicsElementResponseModel` exposes a server-derived `builtIn`, computed by
`GetAllGraphicsElementsForApiHandler` as
`GraphicsElementDefaults.IsOnNowNext(element.Path, element.Kind)` — ordinal equality against the full
seeded path AND `Kind == Text`, not the bare filename, which was folder-agnostic (#568) — never
client-settable. `Kind` is inside that predicate rather than a filter each caller adds: the seeder
requires it, and an API that did not would report a wrong-kind row at the seeded path as
`builtIn:true` while the seeder refused to treat it as the built-in element (#568).
## 9. Authentication — session-or-key posture (fail-closed)
+118 -31
View File
@@ -101,6 +101,19 @@ commit in the range (see the `decisions.md` header) — routine lifecycle metada
1. Confirm `main` CI is green; run the full local gate plus `dotnet list package --vulnerable
--include-transitive`; then push a `vYY.N.P` tag on that exact `main` commit.
> **`v*` tags are protected — push as `timothy`.** Since ersatztv#885 the repo carries one
> tag-protection rule (`name_pattern: v*`, `whitelist_usernames: ["timothy"]`,
> `release.tag-protection-v-star`), because a `v*` push builds and publishes `:prod`. A push from
> any other account is expected to be refused — configured, not exercised, since only the
> `timothy` credential is available here. **The positive half is UNVERIFIED too**: the rule was
> applied 2026-09-04 and only its negative half was measured (a non-`v*` tag still pushes), because
> pushing a `v*` tag outside a real cut would publish `:prod`. This cut is its first real
> exercise. If the tag push is refused, unblock with
> `DELETE /api/v1/repos/timothy/ersatztv/tag_protections/1`, push, then re-`POST` the rule
> (prefer a `PATCH` adding the account if the cut has moved to a different operator, so the
> protection is never left off) — and record the outcome on `release.tag-protection-v-star`,
> which is waiting for it.
2. Wait for tag CI to build `:prod` + the immutable `:<version>` + `:<sha>` images. Run
`scripts/security-scan.sh` on jazz against **the immutable `:<version>` image**, not a
moving tag, and triage every ZAP/semgrep finding.
@@ -170,9 +183,15 @@ grants `releases: write`, which nothing here uses. The two read units are not sp
`/issues/{n}/timeline` (the ersatztv#706 retarget fence) and, through
`scripts/pr-changed-files.sh`, `/pulls/{n}` and `/pulls/{n}/files` (the ersatztv#698 exemption path).
No workflow needs a `packages:` unit. All five `container:` blocks in `docker-build.yml` carry
explicit `credentials:` using the scoped `REGISTRY_*` PAT, so no job pulls the toolchain image with
the injected token.
No workflow needs a `packages:` unit. Since ersatztv#885 the five `container:` blocks in
`docker-build.yml` carry no `credentials:` at all: they run on the `pull_request` route, so they may
hold no stored secret, and the runner pulls the toolchain image ANONYMOUSLY — this registry issues a
pull token for a public package with no credential (measured at the HTTP level 2026-09-04, and at the
daemon level 2026-09-05 — a `docker pull` of the pinned tag with an empty docker config succeeds on
the runner host, which is the same daemon that creates the job containers; see
`ci.pr-route-carries-no-stored-credential`, whose `mechanics:` names both measurements and the two
things neither exercises). So no job pulls that image with the injected
token, and none pulls it with the `REGISTRY_*` PAT either.
### The binding was measured, not assumed (2026-08-27, Gitea 1.27.1)
@@ -736,7 +755,11 @@ the image build.
for `192.168.1.95:3000` — **BuildKit does not inherit the host daemon's
`insecure-registries`**, so without this, cache/base-image/push over the HTTP
registry fails (`http: server gave HTTP response to HTTPS client`).
3. `docker/login-action` with repo secrets `REGISTRY_USER` / `REGISTRY_PASSWORD`.
3. `docker/login-action` with repo secrets `REGISTRY_USER` / `REGISTRY_PASSWORD`. **`build` is the
ONLY job in this workflow that names them** (ersatztv#885,
`ci.pr-route-carries-no-stored-credential`): it is gated `if: github.event_name != 'pull_request'`,
and every job that IS on the PR route now reads the registry and the status API anonymously,
because a `pull_request` run executes head-supplied YAML.
**`REGISTRY_PASSWORD` is a scoped PAT (`write:package` + `read:repository`), not an account
password** — deliberately, so head-resolved PR code cannot use it to forge a commit status
(`ci.actions-credential-scoping`, ersatztv#697). If a job ever fails with `token does not have at
@@ -901,8 +924,11 @@ the docs-only detect), and every heavy step gains an added `&& steps.revalidate.
squash, rebase, fast-forward, or a direct push have no `HEAD^2`, so they run);
- `git rev-parse HEAD^{tree}` equals `HEAD^2^{tree}` — main did not advance since the PR's last run,
a byte-identical tree;
- `HEAD^2` has a **green Gitea combined commit status**, queried via the API with
`ETV_STATUS_AUTH`. Trusting the aggregate `.state` is sound: a `skipped` context does **not** drag
- `HEAD^2` has a **green Gitea combined commit status**, queried via the API **anonymously**
(ersatztv#885: the three jobs that call `scripts/ci-detect-already-validated.sh` also run on the
`pull_request` route, so they hold no stored secret; `timothy/ersatztv` is public and answers the
combined-status GET without a credential — measured 2026-09-04). Trusting the aggregate `.state`
is sound: a `skipped` context does **not** drag
the combined state below `success` (verified live against this instance — a real merge commit with
four `skipped` PR-only contexts still reported `.state == success`), and the two required jobs
never report `skipped` (they always run and report a real `success`/`failure`), so
@@ -1148,8 +1174,10 @@ one case that's easy to forget and easy to detect: a PR that touches a SPA scree
`::warning::` annotation.
**2. The session-narrative reminder** (ersatztv#784) runs `scripts/check-doc-narrative.py --diff`
over the lines this PR **adds** to `docs/**/*.md` (minus `docs/decisions/**`, exempt wholesale) and
root-level `*.md`, flagging text that narrates the document's own revision history —
over the lines this PR **adds** to `docs/**/*.md` (minus `docs/decisions/**`, exempt wholesale),
root-level `*.md`, and — since ersatztv#876 — every tracked file under `.claude/`, `.gitea/`, `.husky/`
and `scripts/` regardless of extension (minus the detector and its own test, whose fixtures are the
phrasings), flagging text that narrates the artifact's own revision history —
`docs.no-session-narrative`. It is advisory **by design and permanently**: a narrative detector is a
string predicate over prose, the class `docs/defect-shapes-773.md` §4 argues must never be
load-bearing, so the script exits 0 on every path including a bad argument or an unresolvable base
@@ -1430,8 +1458,14 @@ re-opens the hole this section exists to close.
Practical consequences: **every** change to `main` goes through a PR, including a one-line docs fix;
and the client-side Husky guards (H6/H11/H13) remain useful friction but were never the control —
they are fail-open and `--no-verify` bypasses them. Tag pushes are unaffected (separate mechanism;
`tag_protections` is empty), so the release cut in "Cutting a release" still works unchanged.
they are fail-open and `--no-verify` bypasses them. Tag pushes go through a separate mechanism and
are meant to stay unaffected for the release operator: since ersatztv#885 `tag_protections` carries
one rule, `v*` whitelisted to `timothy` (`release.tag-protection-v-star`), which is intended to leave
the release cut in "Cutting a release" working unchanged while refusing a `v*` tag from the `renovate`
bot — a push that would otherwise have published a `:prod` image. Both halves of that are EXPECTED,
UNVERIFIED: only the `timothy` credential exists here, so neither a real release cut nor a refused bot
push has been exercised. `release.tag-protection-v-star` carries the same caveat and waits on the
next cut.
**A fourth arm: guard-scope freshness (ersatztv#787).** `scripts/tests/test_ci_dropped_step_guard.py`
decides which CI jobs must carry per-step execution markers by DERIVING that scope from
@@ -1747,6 +1781,16 @@ arranging two real merges, or adding a throwaway trigger; both cost more than th
because nothing branches on it. The `pull_request_target` half the fence actually relies on was **not**
re-measured either and is 1.25.4-dated too.
The two facts this paragraph rests on — that no workflow triggers on a push to a non-`main` branch,
and that `main` refuses direct pushes — were **re-checked on 2026-09-02 (ersatztv#869)** and both still
hold. Note what that does and does not cover: it re-checks the REASON, not the claims. The
`push`-supersession and `pull_request_target` measurements above remain 1.25.4-dated and were not
re-run by #869 either. Re-checking the reason matters on its own, because a reason decays exactly like
the claim it excuses. One limit is worth stating so this is not over-read: the enumeration covers
`push:` triggers only. Four of the six workflows carry `workflow_dispatch:`, so a disposable run can
still be raised on demand — what is gone is the cheap scratch-BRANCH push probe, not every route to a
throwaway run.
Separately, after posting **any** status the job re-reads the per-POST status history and, if a human
`Review-verdict:` row appeared during the write window, overwrites its own status with `pending` and
logs an error. It ran only for a `success` write until ersatztv#849, on the claim that a `pending`
@@ -1818,6 +1862,18 @@ validate at most 950 rows, since the twentieth must be the empty terminator. The
per endpoint and they differ: `/statuses/{sha}` returns `[]`, `/issues/{n}/timeline` a bare `null`,
`/commits/{sha}/status` an object with `statuses: null`.
**The two walks terminate by OPPOSITE rules, and that asymmetry is deliberate** (ersatztv#870, settled
by ersatztv#893 on 2026-09-02). `count_pr_mutations` reads the timeline to its cap and trusts the
counts only when the LAST page came back empty; `page_statuses` still returns on its FIRST empty page.
They differ because the ENDPOINTS differ, established from the v1.27.1 source: `getCommitStatuses`
serializes every row the paged query returns — an unconditional `append` loop, the only filter being a
SQL `WHERE` the database evaluates in the same query as the `LIMIT`/`OFFSET` — so a serialized page is
exactly as long as its database page, and an empty page really is the end. `ListIssueCommentsAndTimeline` instead drops
`CommentTypeCode` rows AFTER paging, which is what makes a fully filtered page byte-identical to the
end of the list. The same source explains the header: `/statuses/{sha}` sets `X-Total-Count` from a SQL
COUNT, so it is a true total (page 1 of 50 on a head reporting 63, measured 2026-09-02), where the
timeline reports its post-filter page length. **Do not "tidy" the two walks into agreement.**
**The page-2 probe is gone.** ersatztv#751 treated "there are rows I did not read" as "assume raced".
That fired on Renovate PR #761: a head that grew past one page over ordinary CI re-runs had its
exemption repaired away, with an `::error::` asserting a human verdict was overwritten when the head
@@ -1895,8 +1951,10 @@ as establishing that the gate cannot be forged (see the residual below, and ersa
`docs/decisions/records/ci/gate-trigger-base-resolved.md`. **This closes the rewrite route through
this workflow, not the class:** `docker-build.yml` is also head-resolved and must stay on
`pull_request` because it builds the PR's code, so it got the read-only status identity instead —
its `ETV_STATUS_AUTH` is now a PAT scoped `write:package` + `read:repository`, which the status
endpoint refuses (`ci.actions-credential-scoping`, ersatztv#697). The inventory was never that one
the `ETV_STATUS_AUTH` it passed was a PAT scoped `write:package` + `read:repository`, which the
status endpoint refuses (`ci.actions-credential-scoping`, ersatztv#697). Since ersatztv#885 it
passes no credential there at all: the status read is anonymous, so the PR route materialises
nothing to refuse. The inventory was never that one
workflow, though: Gitea injects a write-capable `GITEA_TOKEN` into every job and branch protection
binds the *context*, not its issuer. Gitea >=1.26 with the Actions default set to **Restricted**
(server-management#714) binds the injected token, but does not close the class either — not against
@@ -2249,16 +2307,27 @@ documented API paths mention "environment", and the config file the running serv
restricting dispatch would close nothing: the `pull_request:` row above is cheaper than any
dispatch, because it runs **attacker-authored YAML**, which can name any secret in the repo store —
not only the ones the committed workflows reference. That is what makes it cover `renovate.yml`'s
`RENOVATE_TOKEN` without dispatching `renovate.yml` at all. Concretely today, six jobs in
`docker-build.yml` hold `REGISTRY_PASSWORD` and run on the PR route (`toolchain-preflight`, `test`,
`migrations`, `functional-e2e`, `api-docs`, `format`), two of them required contexts — carry that as
*every job on the PR route that names `secrets.REGISTRY_PASSWORD`*, not as the list, and not as
"every `container:` job" either: `toolchain-preflight` is container-free and would fall out of that
predicate. "Push a branch, open a PR" therefore reaches the credential with **no act outside the
ordinary contribution flow**, where a dispatch costs one. (`dependency-scan.yml` references no
secrets at all.) Full reasoning and the probe: `ci.workflow-dispatch-ref-unrestricted`. The live
residuals are that PR route and the `v*` tag push, tracked in #885 — along with `tag_protections`,
which is still empty.
`RENOVATE_TOKEN` without dispatching `renovate.yml` at all. (`dependency-scan.yml` references no
secrets at all.) Full reasoning and the probe: `ci.workflow-dispatch-ref-unrestricted`.
Six jobs in `docker-build.yml` used to hold `REGISTRY_PASSWORD` on that route, two of them required
contexts. Since ersatztv#885 **no job on the `pull_request` route names any stored secret**
(`ci.pr-route-carries-no-stored-credential`): the toolchain image is pulled through the registry's
anonymous bearer-token flow and the commit-status API is read unauthenticated, leaving the PAT to
`build`, which the route cannot reach. The invariant is held by
`scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`,
which derives the population from the git index by the predicate *every job of a
`pull_request`-triggered workflow that names a `secrets.*`* — never a list, and never "every
`container:` job" either, which names five of the six because `toolchain-preflight` is
container-free. The `v*` tag-push row is closed against the bot by `release.tag-protection-v-star`.
What that does **not** close, and must not be read as closing: `REGISTRY_PASSWORD` is still in the
repo's Actions store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or
`SERVERMGMT_DEPLOY_KEY` in a job of its own. "Push a branch, open a PR" still reaches every secret in
the store with **no act outside the ordinary contribution flow**. Bounding the store needs
per-environment secret scoping, which Gitea 1.27.1 does not have. What is removed is the routine
materialisation of a write-capable credential into six PR-run environments — blast radius, not the
route.
**A `v*` tag push does not fire `ci-image.yml`.** There is no `tags:` key, and a `branches:` filter
is compared against a branch ref — so a release cut no longer republishes the toolchain image as a
@@ -2307,7 +2376,14 @@ through a rebase.
> recovery — are #854, deferred on exactly these grounds.
**A change that lives only in `ci-image.yml` publishes nothing.** That file is no longer one of its
own trigger paths, nor one of `ci-image-pin`'s `expected` paths. That pairing is a **decided
own trigger paths, nor one of `ci-image-pin`'s `expected` paths. Those two lists must otherwise name
the same sources, and `scripts/tests/test_ci_image_paths_pin_agreement.py` holds them to it — it
derives both from the workflow documents and compares them for set equality, so editing one alone
reddens `script-tests`. That job is not a required context, so the red does not block server-side; it
denies the merge-consent auto-grant through the combined status. **Read that guard before taking the
reversal described next**: it models a `<dir>/**`-against-`<dir>` pair only, so putting
`ci-image.yml` back into both lists is a file-against-file pair it refuses, and would need a file arm
added there first (ersatztv#855). That pairing is a **decided
tradeoff, not a necessity** — keeping the file in `expected` still works, since the branch dispatch
can publish the ci-image.yml commit itself and the pin then matches. What it costs is the reason it
went: that route charges a full ~2 GB publish and a five-pin bump for *every* edit to the file, a
@@ -2336,7 +2412,7 @@ sha tag once 15 newer versions of the package exist. `ci-image.yml` publishes a
and on every push to `main` touching `docker/ci/**` (plus every branch dispatch), while the pin only
moves when a human bumps it — so a pin ages toward eviction on its own. That is what happened between 2026-08-11 and
2026-08-13 (ersatztv#772): the tag vanished, and every `container:` job — **both required contexts
included** — died after 12s with
included** — died at image pull, before running a step, with
```
Error response from daemon: failed to resolve reference ".../ersatztv-ci:<pin>": not found
@@ -2382,16 +2458,27 @@ because they send you to different places:
| HTTP 200 with a manifest body | green | resolves |
| HTTP 404 | **red** | `IS GONE` — rebuild the tag (recovery above) |
| HTTP 200, body is not a manifest | **red** | something is answering for the registry (proxy, login page) |
| 401 / 403 | **red** | the credentials were rejected — fix the secrets |
| 401 / 403 that survives a bearer the run actually obtained | **red** | the registry `refused an ANONYMOUS read ... even after a Bearer token was obtained` — about the PACKAGE: check that the `ersatztv-ci` package is still PUBLIC. It is linked to no repository (measured 2026-09-05: every version reports `"repository": null`), so this is the package's own visibility and NOT `timothy/ersatztv`'s |
| 401 whose token leg ANSWERED and yielded no bearer — no `Www-Authenticate` at all, a challenge naming no realm, or a token endpoint answering with no token | **red**, asked once | `could NOT OBTAIN an anonymous pull token ... after 1 token-leg attempt(s)` — about the registry's TOKEN ENDPOINT, not the package's visibility. The endpoint said something, so asking again cannot change it |
| 401 whose token endpoint could not be REACHED, or answered 5xx | **red** after `ETV_CI_ATTEMPTS` token-leg tries | the same message, naming the attempts it actually made. An endpoint that said nothing is the same transport blip a flaky manifest read gets to survive — the two legs of one read must not have opposite flake tolerances when a red here denies a merge |
| 403 on the FIRST read, before any token leg — `probe` enters it on a `401` only | **red** | `refused an ANONYMOUS read ... and NO TOKEN WAS EVER REQUESTED` — it reports what the RUN did (never followed this answer as a challenge), not what the registry sent. About ACCESS to the registry: visibility, or something answering for it |
| anything else (5xx, unreachable, no `curl`) | **red** after `ETV_CI_ATTEMPTS` tries | `could NOT VERIFY` — check the registry's health, NOT the pin |
| `ETV_REGISTRY_AUTH` unset, malformed, or either half empty | **red**, before any query | an absent secret interpolates to `":"`, which is not a credential |
The last two rows are the ones worth defending, because warning on them and exiting 0 is the natural
way to write this check and it is wrong: a missing `curl`, a moved registry and a DNS change all land
there, and a green-with-a-warning job is indistinguishable from a healthy pin forever after. The
**The refusal rows say only what actually ran**, and that is the point of wording them apart
rather than a nicety: a message naming a step the run skipped is evidence for a diagnosis nobody
performed. So the arm branches on `token` first — a bearer was obtained — then on whether the token
leg was attempted at all, and only then reports the never-asked case; and the token-leg message
carries its own attempt count rather than leaving the reader to infer one from the retry policy.
Those refusal rows and the `could NOT VERIFY` row are the ones worth defending, because warning
on them and exiting 0 is the natural way to write this check and it is wrong: a missing `curl`, a
moved registry and a DNS change all land in the last of them, and a green-with-a-warning job is
indistinguishable from a healthy pin forever after. The
unknown arm retries first (`ETV_CI_ATTEMPTS`, default 3, `ETV_CI_RETRY_SECONDS` apart) so an ordinary
registry blip does not redden a PR — that pause is what makes failing on unknown affordable, and
shortening it silently trades this guard for flake.
shortening it silently trades this guard for flake. **An unreachable token endpoint is in that
class, not in the refusal class**, and is retried on the same budget: the split is by what the
endpoint SAID, never by which leg of the read it happened on.
**It is not a `needs:` of anything, but it is not consequence-free either.** The merge-consent hook
reads the PR's **combined** status and denies on a non-`success` combined state (a `skipped` context
@@ -2433,7 +2520,7 @@ check and takes no arguments. Run it from the CURRENT checkout, not the pin work
`$repo` is kept above:
```bash
ETV_REGISTRY_AUTH=user:pass scripts/ci-toolchain-image-resolves.sh
scripts/ci-toolchain-image-resolves.sh
```
**What this rebuild does and does not restore.** It restores a *working* toolchain at that tag, built
+2 -2
View File
@@ -99,7 +99,6 @@ leaving the shared tree dirty is the one outcome that would make this script a n
**Negative control (inherited from #231/#250).** A dedicated test hammers unbalanced releases on an empty pool while reader threads sample the count; none may ever observe a value below zero. Reinstating the pre-#539 decrement-first body makes it fail (`sawNegative > 0` — the readers catch the transient `1`); verified. As with the #536 tests, break the primitive by reverting the real body, **not** `if (true)` (CS0219 under warnings-as-errors leaves `--no-build` running a stale, still-fixed dll).
**SPA is id-fields-first; the API is complete ahead of the UI.** The Channel Builder + Auto-Tune DetailPanel re-add a real "None" option to the five id selects (watermark + fillers) — the pickers #89 had degraded to "Inherit"-only — routed through a `CLEAR` overrides sentinel folded into `advanced.clear` at request-build time (`applyOverridesToRequest`, so the sentinel never leaks as a field value). The three string clear-fields are covered by the backend enum for machine clients (MCP) but the SPA text inputs keep "empty = inherit"; adding a tri-state to those inputs is deferred, not blocked. This is the deliberate "REST API is a real audience" posture (`rest-api-purpose-mcp-and-new-ui`).
`security.iptv-browser-token`.
external-process pipeline remains #563's.
picker; TZ-independence holds only for divisors of 60. See #77 (prior art) and #392.
@@ -202,7 +201,7 @@ another doc or an old issue comment should land here and then follow the link.
- 2026-07-21 — `WorkAheadSlots.Release()` clamps before decrementing and reports unbalance in-band (#539) — [`ffmpeg.work-ahead-slot-release-never-negative`](decisions/records/ffmpeg/work-ahead-slot-release-never-negative.md)
- 2026-07-21 — `from-lineup` advanced overrides express "clear to none" via a typed `clear` enum list (#135) — [`api.from-lineup-clear-to-none`](decisions/records/api/from-lineup-clear-to-none.md)
- 2026-07-22 — Channel-level graphics-element attachment + seeded On Now/Next text element (#74) — [`graphics.channel-level-attachment`](decisions/records/graphics/channel-level-attachment.md)
- 2026-07-22 — Sequential (YAML) playout gets a golden; Scripted is excluded from the golden net by construction (#381) — [`testing.scripted-playout-golden-deferred`](decisions/records/testing/scripted-playout-golden-deferred.md)
- 2026-07-22 — Sequential (YAML) playout gets a golden; Scripted is excluded from the golden net by construction (#381) — [`testing.scripted-playout-golden-deferred`](decisions/archive/testing/scripted-playout-golden-deferred.md) (superseded by `testing.scripted-engine-in-process-net`)
- 2026-07-22 — per-schedule clock-boundary padding is a synthetic content-less Pad over the existing per-episode machinery (#392) — [`sched.clock-padding-schedule-toggle`](decisions/records/sched/clock-padding-schedule-toggle.md)
- 2026-07-23 — Channel health = a server-derived `health` object on the channel DTOs, built-timeline detection (#415) — [`api.channel-health-object`](decisions/records/api/channel-health-object.md)
- 2026-07-23 — Channel origin is immutable creation-provenance, stamped at insert, not a health signal (#414) — [`channel.origin-marker`](decisions/records/channel/origin-marker.md)
@@ -216,3 +215,4 @@ another doc or an old issue comment should land here and then follow the link.
- 2026-07-25 — The rationale-edit marker is a git trailer, not a substring anywhere in the commit range (#609) — [`ci.decisions-edit-trailer`](decisions/records/ci/decisions-edit-trailer.md)
- 2026-07-25 — UI-E2E: headless Playwright flows in the existing `functional-e2e` job, browser baked into the CI image (#445) — [`ci.ui-e2e-harness`](decisions/records/ci/ui-e2e-harness.md)
- 2026-07-26 — Facet-value typeahead restated: every artist source covered; the JSON-column source is paged by row position with no residual SQL predicate (#578) — [`api.search-field-values-sources`](decisions/records/api/search-field-values-sources.md)
- 2026-09-05 — Scripted playout is covered in-process, engine plus controller adapter; the Cli.Wrap process and the HTTP transport are permanently out of the automated suite (#563) — [`testing.scripted-engine-in-process-net`](decisions/records/testing/scripted-engine-in-process-net.md)
File diff suppressed because one or more lines are too long
@@ -1,13 +1,13 @@
---
key: testing.scripted-playout-golden-deferred
title: 2026-07-22 — Sequential (YAML) playout gets a golden; Scripted is excluded from the golden net by construction (#381)
status: active
status: superseded
since: '2026-07-22'
supersedes: none
superseded-by: none
rule: 'The `PlayoutBuildGoldenTests` in-memory golden net covers Sequential (YAML) as of #381. Scripted''s *end-to-end pipeline* is excluded — `ScriptedPlayoutBuilder` runs a user-authored external program that drives the engine over HTTP loopback, which the in-memory harness can''t pin — so that full-pipeline (integration) harness is deferred to #563. But the scheduling *behavior* those scripts drive lives entirely in the in-process `SchedulingEngine` (the `ScriptedScheduleController` is a 1:1 pass-through to it), which IS directly unit/golden-testable; the earlier "Scripted is un-golden-able by construction" framing overstated the constraint by conflating transport with engine. #395 extracts that shared switch to `ContentEnumeratorBuilder` and adds a direct regression net (`ContentEnumeratorBuilderTests`) over it.'
superseded-by: testing.scripted-engine-in-process-net@2026-09-05
rule: '(superseded) The `PlayoutBuildGoldenTests` in-memory golden net covers Sequential (YAML) as of #381. Scripted''s *end-to-end pipeline* is excluded — `ScriptedPlayoutBuilder` runs a user-authored external program that drives the engine over HTTP loopback, which the in-memory harness can''t pin — so that full-pipeline (integration) harness is deferred to #563. But the scheduling *behavior* those scripts drive lives entirely in the in-process `SchedulingEngine` (the `ScriptedScheduleController` is a 1:1 pass-through to it), which IS directly unit/golden-testable; the earlier "Scripted is un-golden-able by construction" framing overstated the constraint by conflating transport with engine. #395 extracts that shared switch to `ContentEnumeratorBuilder` and adds a direct regression net (`ContentEnumeratorBuilderTests`) over it.'
signals: 'why is there no scripted golden; scripted transport vs engine; SchedulingEngine is in-process testable; Cli.Wrap external process is transport only; ScriptedScheduleController 1:1 pass-through; engine-level scripted regression net; EnumeratorForContent · paths: `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs`, `ErsatzTV.Core.Tests/Scheduling/Engine/SchedulingEngineTests.cs`, `ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs`, `ErsatzTV/Controllers/Api/ScriptedScheduleController.cs`, `ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs` · issues: #381, #163, #395, #563'
mechanics: docs/testing.md → Golden-file nets
mechanics: superseded by `testing.scripted-engine-in-process-net` (ersatztv#563), which stands up the deferred coverage in-process and corrects the "1:1 pass-through" description of `ScriptedScheduleController`
---
**Sequential (YAML) is golden-able and TZ-independent.** `SequentialPlayoutBuilder` reads a YAML schedule
@@ -0,0 +1,45 @@
---
key: api.top-level-id-list-validation
title: '2026-09-05 — A top-level list of FK ids on a full-replace write DTO is bounded, existence-checked, named in its own 422, and re-asked after a lost race (#568)'
status: active
since: '2026-09-05'
supersedes: none
superseded-by: none
rule: 'A list of foreign-key ids that is a TOP-LEVEL field of a full-replace write DTO (`UpdateChannelRequest.graphicsElementIds`, `ReplaceDecoRequest.graphicsElementIds`/`watermarkIds`) is validated like every other FK field on that DTO, through the one shared primitive `Validators.IdsMustExist`. Four properties, in this order: (1) the RAW list length is capped at `Validators.MaximumIdListCount` before `Distinct` and before any database work, because deduplication is not what the request cost to parse and materialize; (2) an id with no matching row is a 422, never the FK constraint reaching `SaveChangesAsync` as an unhandled 500; (3) the message carries the request field name in brackets, `[GraphicsElementIds] ...`, the prefix `Validators.NotEmpty`/`NotLongerThan`/`AtLeast` already produce, and echoes at most ten missing ids plus a count; (4) where a SIBLING field of the same request makes the apply path discard the list — a deco `DecoMode` outside `Override`/`Merge` — that predicate is passed in as `idsAreConsumed` and gates the EXISTENCE half ONLY. It never gates the cap: a discarded list was parsed and materialized out of the request body all the same. Because validation and the write are two statements, a handler that writes any FK also catches `DbUpdateException` around the save and re-runs ITS OWN `Validate` on a FRESH context, returning the validator''s own 422 when a principal row has since gone and re-throwing otherwise. The whole validator set is re-asked, never a named subset of fields. This is NOT the deep-FK carve-out of api-conventions.md 3b, which stays: ids nested inside item-list request bodies are still not existence-checked at that depth.'
signals: 'unknown graphicsElementIds returns 500 · unbounded client-supplied id array · which id list does this 422 mean · FK deleted between validation and SaveChangesAsync · deco mode gate skips the count cap · IdsMustExist · MaximumIdListCount · idsAreConsumed · ApplyUpdateRequestTranslatingLostRace · paths: `ErsatzTV.Application/Validators/IdListValidation.cs`, `ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs`, `ErsatzTV.Application/Scheduling/Commands/UpdateDecoHandler.cs`, `docs/api-conventions.md` 3b · issues: #568, #917, #74'
mechanics: '`Validators.IdsMustExist<T>(input, expression, noun, idsAreConsumed, findExisting, ct)` in `ErsatzTV.Application/Validators/IdListValidation.cs`; the field name comes from `GetMemberName(expression)`, so it cannot drift from the DTO. Pinned by `UpdateChannelGraphicsElementsTests` and `UpdateDecoGraphicsElementsTests`; the clause mutations and their measured red sets are rows 35-47 of the table in `docs/graphics-elements.md`.'
---
- **The cap belongs to the request, the existence check belongs to the apply path.** These are two
different questions and the first attempt at the mode gate conflated them: the deco validators
short-circuited the whole `IdsMustExist` call when `DecoMode` was `Inherit`/`Disable`, which was
right about existence and wrong about the count. A validator may not reject an id the reconcile
was going to throw away — the SPA sends both id lists whatever the mode selector says, so a draft
holding a since-deleted element must still be able to save the deco back to `Inherit` — but the
array was still bound, allocated and materialized before any of that was known. Hence
`idsAreConsumed` as a parameter of the shared primitive rather than an `if` around the call site:
the primitive decides what the flag may switch off, and no call site can widen it.
- **A recovery path that names its own fields is the defect it is recovering from.** Re-asking only
the graphics-element question after a `DbUpdateException` left `WatermarkId`, `FFmpegProfileId`,
`FallbackFillerId` and `MirrorSourceChannelId` — every one of them written by the same
`SaveChangesAsync`, every one of them a real FK — surfacing the same unhandled 500 the catch exists
to remove. Re-running the handler's own `Validate` is what makes the coverage derive from the
validator set instead of from whoever last edited the catch. A transaction is not an alternative:
neither provider locks rows the validator merely READ, so the concurrent delete commits either way.
The context must be a fresh one — the failed context still tracks the changes it could not write,
and would answer the existence query out of them.
- **Bounded, but only here — and deliberately.** Three validators on two DTOs carry this cap; the
other FK validators on the same DTOs, and the `O(existing x desired)` reconcile loops the ids then
feed, do not. That is not an oversight and it is not a finished rule: capping three sites and not
the rest is a per-field constant, and #917 owns the repo-wide question (where the bound lives, how
the limit is derived, and bounding the apply paths as well as the validators). Cite this record for
the SHAPE a bounded id list takes; expect #917 to replace the mechanism.
- **`MaximumIdListCount` is a ceiling on abuse, not a product limit.** 512, against tables an
operator curates by hand where a few dozen rows is a large install. Nothing reachable from the
editor can approach it, which is what lets it be a hard rejection rather than a clamp.
- **A 422 that echoes every rejected id turns an oversized request into an oversized response.** Ten
ids and a count: enough to fix the payload by hand, bounded by construction.
@@ -5,9 +5,9 @@ status: active
since: '2026-08-05'
supersedes: none
superseded-by: none
rule: 'Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`''s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo''s checkouts now set it — 15 in #746 and `ci-image.yml`''s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The `workflow_dispatch` half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because `docker-build.yml`''s head-resolved `pull_request:` runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the `v*` tag push, not dispatch, are the live residuals (#885).'
rule: 'Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`''s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo''s checkouts now set it — 15 in #746 and `ci-image.yml`''s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The `workflow_dispatch` half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because `docker-build.yml`''s head-resolved `pull_request:` runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the `v*` tag push, not dispatch, were the live residuals; both were addressed in #885 — the tag push is whitelisted to the release operator (`release.tag-protection-v-star`) and NOTHING on the `pull_request` route names a stored secret any more — no job and no workflow scope (`ci.pr-route-carries-no-stored-credential`), so the `container:` pull, `ETV_REGISTRY_AUTH` and `ETV_STATUS_AUTH` are all gone from that route and `REGISTRY_PASSWORD` reaches only `build`. That bounds the blast radius; it does not close the route, because head-supplied YAML can still name any secret in the store.'
signals: 'admin password in CI secrets, registry credential scope, ETV_STATUS_AUTH can write statuses, forge review-verdict/h10, head-resolved workflow holds credentials, persist-credentials on actions/checkout, credential left in .git/config, masked git fetch yields empty changed set, Gitea token scopes, no status scope, write:package vs write:repository, permissions key no-op, GITEA_TOKEN default read/write, Restricted default token permissions, orphan secret, deploy key in secret store, toolchain image overwrite, prod floating tag write · paths: `.gitea/workflows/docker-build.yml`, `.gitea/workflows/pr-checks.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/ci-image.yml`, `.gitea/workflows/renovate.yml`, `scripts/ci-detect-already-validated.sh` · issues: #697, #672, #698, #742, #743, #746, #748, #835, #420, server-management#714'
mechanics: 'PAT `ci-registry-scoped-697`, scopes `write:package,read:repository`, stored as repo Actions secret `REGISTRY_PASSWORD`; `REGISTRY_USER` remains `timothy`. Verified 2026-08-05 on Gitea 1.25.4: registry push of a probe tag SUCCEEDED; `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` REFUSED HTTP 403 `token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository`. Probe artifacts deleted, confirmed 404. NOT measured with this token: the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea''s scope model (write implies read per category, read at tag `v1.25.4`) — INFERRED. Note WHICH run proves which: only the `container:` pull is exercised by a PR. `cache-to`/`cache-from` and the base-image pull are confined to the `build` job, which carries `if: github.event_name != ''pull_request''`, so they are first exercised on the post-merge push to `main` AFTER the merge gate has passed. A wrong inference there reddens main, not the PR.'
mechanics: 'PAT `ci-registry-scoped-697`, scopes `write:package,read:repository`, stored as repo Actions secret `REGISTRY_PASSWORD`; `REGISTRY_USER` remains `timothy`. Verified 2026-08-05 on Gitea 1.25.4: registry push of a probe tag SUCCEEDED; `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` REFUSED HTTP 403 `token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository`. Probe artifacts deleted, confirmed 404. RE-PROBED 2026-09-02 on Gitea 1.27.1 (#869) in a throwaway repo with a freshly minted PAT of the same two scopes: same 403 with the same message, plus a read control (same token GET -> 200) and a write control (admin POST -> 201); repo and token deleted, deletion confirmed. NOT COVERED BY EITHER PROBE (2026-08-05 or the #869 re-probe): the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea''s scope model (write implies read per category, re-read at tag `v1.27.1` 2026-09-02) — INFERRED. Note WHICH run exercises which, and that this CHANGED on 2026-09-04 (#885). The buildcache write and the base-image pull are confined to the `build` job, which carries `if: github.event_name != ''pull_request''`. The `container:` pull is not in `build` at all — `build` declares no `container:` — and it is the one a PR used to exercise with this credential; the five `container:` blocks now declare no `credentials:` and pull ANONYMOUSLY, so there is no credential left there to infer about (`ci.pr-route-carries-no-stored-credential`). No PR run therefore exercises this PAT at all, and the two credentialed uses are first exercised on the post-merge push to `main`, AFTER the merge gate has passed, so a wrong inference reddens main, not the PR.'
---
**What was wrong.** `REGISTRY_USER`/`REGISTRY_PASSWORD` were the **admin account's** basic auth, and
@@ -102,16 +102,50 @@ count is now **16 of 16 with no exemption**. That is what let #835 ship the guar
exemption list at all: a one-entry list justified by a temporary condition elsewhere keeps passing
after that condition expires, leaving the guard permanently blind to the file it was written for.
**Measured vs inferred.** Measured here: the `v1.25.4` scope enum (`access_token_scope.go`) has no
**Measured vs inferred.** Measured here: the scope enum (`access_token_scope.go`) has no
`status` entry; the `reqRepoWriter` gate (`routers/api/v1/api.go`); the probes in `mechanics`. Read from
docs, NOT verified (2026-08-05): `permissions:` landed in 1.26.0 (Gitea PR #36173); no `app.ini` lever
at any version; Gitea rejects GitHub's `statuses`/`checks` scopes.
**MEASURED 2026-08-27 on 1.27.1 (#748) — this block is NOT covered by the 1.25.4 caveat below.** It
re-probes exactly ONE of that caveat's items, the `permissions:` question, and supersedes the
"whether `permissions:` is honored here is UNPROBED" claim. It does NOT discharge the caveat: the
scope enum, the `reqRepoWriter` gate and the `write:package` 403 are still 1.25.4-dated and
un-re-run. Method: a matched pair of scratch-base probe PRs, identical except for one permission
**RE-ESTABLISHED ON 1.27.1, 2026-09-02 (#869) — the three items the version caveat below NAMES.**
It discharges those three and only those three. It does NOT discharge every dated statement in this
record: "no container tag immutability in Gitea 1.25" (above) is still INFERRED and untouched, and
the caveat is kept rather than deleted because it names what each item rests on.
- **Scope enum — no `status` scope.** Read at tag `v1.27.1`, `models/auth/access_token_scope.go`
the same path it occupies at `v1.25.4`, checked in both trees rather than assumed, because this
record had only ever named the file and not its directory.
Nine categories — ActivityPub, Admin, Misc, Notification, Organization, Package, Issue,
Repository, User — and the string `status` does not occur in the file at all. `write:package`,
`read:repository` and `write:repository` remain distinct scopes, which is what keeps the registry
credential scopable off status-write at no cost.
- **The `reqRepoWriter` gate.** Read at tag `v1.27.1`, `routers/api/v1/api.go`: the `/statuses`
group registers `m.Combo("/{sha}").Get(repo.GetCommitStatuses).Post(reqToken(),
reqRepoWriter(unit.TypeCode), bind(api.CreateStatusOption{}), repo.NewCommitStatus)`. Unchanged in
substance from the 1.25.4 read: writing a commit status is still gated on repository-code WRITE,
so any credential that can write the repository can still forge `review-verdict/h10`.
- **The `write:package` 403 — re-probed live, with both controls.** A PAT scoped exactly
`write:package,read:repository`, minted against the live 1.27.1 instance, `POST
/repos/{o}/{r}/statuses/{sha}` -> **403**, message byte-for-byte the 1.25.4 one: `token does not
have at least one of required scope(s), required=[write:repository], token
scope=write:package,read:repository`. Two controls, because a bare 403 is equally consistent with a
dead token or a dead route: the SAME token `GET`ting the same path -> **200** (the token is live and
the read scope works), and an admin credential `POST`ing the same path -> **201** (the route accepts
a POST). **Be precise about what the second control licenses** — it changes the credential, so scope
and identity vary together and the PAIR alone cannot separate a SCOPE refusal from a
unit-permission refusal. What actually discriminates is the 403 BODY, which names the scope it
wanted and the scope it got; the admin 201 only rules out "the route rejects every POST". Cite the
message as the evidence and the control as its backstop, not the other way round. Run in a
throwaway private repo created for the probe; repo and token both deleted afterwards and the
deletion confirmed (repo `GET` -> 404, token `DELETE` -> 204), so nothing was written to a real
head.
**MEASURED 2026-08-27 on 1.27.1 (#748).** It re-probed exactly ONE of the old 1.25.4 caveat's items,
the `permissions:` question, and supersedes the "whether `permissions:` is honored here is UNPROBED"
claim. It did **not** by itself discharge that caveat — the scope enum, the `reqRepoWriter` gate and
the `write:package` 403 were still 1.25.4-dated and un-re-run when this paragraph was written; #869
re-established all three on 2026-09-02, which is what finally discharged it (see the block above and
"Version history" below). Method: a matched pair of scratch-base probe PRs, identical except for one permission
unit, the block carried **at job level on `set-verdict-status`** — the same level and job as the
shipped `review-verdict.yml`, so the gate's own shape is what was measured.
@@ -137,24 +171,32 @@ controls ran under `permissive`); and the TOP-LEVEL declaration form the other f
which is inferred rather than measured. That inference fails safe (a top-level key Gitea ignored is a
security no-op, never a brick). Probe branches and PRs (#851, #852) deleted after reading.
**Version caveat — this record's measurements are pinned to 1.25.4, the instance is now 1.27.1.**
The instance was upgraded mid-session on 2026-08-05 (#743). Everything above measured on 1.25.4 is
therefore a *dated* claim, not a current one: the scope enum, the `reqRepoWriter` gate and the 403
probe were all taken pre-upgrade and have NOT been re-run. They are recorded honestly as of their
date and are the best evidence available, but do not cite them as current behaviour without
re-probing. #747 re-probed SOME of the 1.25.4-pinned CI claims on 2026-08-28 —
`ci.docs-only-skip-steps`, `ci.shared-pr-file-enumeration`, the REST-route half of
`ci.batch-pushes-no-cancel-route`, the `compare` half of `ci.exemption-provenance` and the
`branches: [main]` item in `ci.gate-trigger-base-resolved` — and it was **not** a sweep of the
corpus. Other 1.25.4-dated CI claims remain un-re-run, the `creator`-attribution measurement the H10
allow-list rests on (`review-verdict.yml`) among them. It **did not** discharge this record's own
remaining items either: the `v1.25.4` scope enum, the `reqRepoWriter` gate and the `write:package` 403
all stay 1.25.4-dated. Only the 403 needs a scoped PAT minted against the live instance; the scope enum
and the `reqRepoWriter` gate were established by reading Gitea's source at the version tag (see
"Measured vs inferred" above), so re-running those needs only a source read at `v1.27.1`. #747 did
neither. What #747 did settle elsewhere is the merge-path half — see `release.main-direct-push-disabled`
for the measured
`block_admin_merge_override` semantics and for why `enable_bypass_allowlist` is not a substitute.
**Version history — the 1.25.4 caveat, and what discharged it.** The instance was upgraded mid-session
on 2026-08-05 (#743), which left this record's measurements dated against a version it no longer ran.
#747 re-probed SOME of the 1.25.4-pinned CI claims on 2026-08-28 — `ci.docs-only-skip-steps`,
`ci.shared-pr-file-enumeration`, the REST-route half of `ci.batch-pushes-no-cancel-route`, the
`compare` half of `ci.exemption-provenance` and the `branches: [main]` item in
`ci.gate-trigger-base-resolved` — and it was **not** a sweep of the corpus. #869 then swept the corpus
by deriving the population with `git ls-files` rather than from an issue's item list, and re-established
most of it — but "finished" would be too strong. Several findings remain 1.25.4-dated by decision, and
**nothing here tracks which ones, deliberately.** Two attempts at that tracking both failed inside the
commit that wrote them: an enumerated list named three residuals and omitted three the same commit
created, and its replacement — a `git grep` pointer — missed the residual that same commit created,
because the commit had introduced a second spelling the grep did not match. A second copy of a set,
whether written out or derived by a pattern, is one more thing to keep in step with the set. Each
residual states its own reason where it lives, which is the copy that cannot drift from its subject.
This record's own
three items are re-established on 1.27.1 above (2026-09-02, #869), so they may now be cited as current. What #747 settled elsewhere is the merge-path half — see
`release.main-direct-push-disabled` for the measured `block_admin_merge_override` semantics and for why
`enable_bypass_allowlist` is not a substitute.
**Read the two source items as source-attested, not instance-attested, and know the difference.** The
scope enum and the `reqRepoWriter` gate are established by reading Gitea's source at tag `v1.27.1`,
which is the version this instance reports (`GET /api/v1/version` -> `1.27.1`, 2026-09-02). That is a
strong claim about the code and a weaker one about the deployment: a patched or vendored binary
reporting `1.27.1` would satisfy the version read and not the source read. Nothing suggests one here,
and the third item — the live 403, whose error string enumerates the required scope — is what ties the
source to this instance. Cite the trio together rather than the source pair alone.
**Surviving routes — this record is not a mitigation for any of them.** `RENOVATE_TOKEN` is a
`write:repository` bot PAT in the same store and still cannot be scoped down, because Renovate needs
@@ -176,9 +218,13 @@ inherited, which is the residual the allow-list explicitly does not touch. **Whi
was settled in #853** (`ci.workflow-dispatch-ref-unrestricted`): not the four unrestricted
`workflow_dispatch` triggers but `docker-build.yml`'s head-resolved `pull_request:`, which runs
attacker-authored YAML and therefore reaches EVERY secret in the store, not only the ones the
committed workflows name — six of its jobs already hold `REGISTRY_PASSWORD` on that route, two of them
required contexts. Residuals in #885. `docker-build.yml` publishes `:prod` from a `v*` tag push and a tag
may point at ANY commit — a prod image with no PR, review or status (tag protections are empty).
committed workflows name. Six of its jobs held `REGISTRY_PASSWORD` on that route, two of them required
contexts; #885 removed all six (`ci.pr-route-carries-no-stored-credential`), which narrows the blast
radius and leaves the ROUTE exactly as cheap as before — the store, not the committed references, is
what head YAML reaches. `docker-build.yml` publishes `:prod` from a `v*` tag push and a tag may point
at ANY commit — a prod image with no PR, review or status. Since #885 that push is whitelisted to
`timothy` (`release.tag-protection-v-star`), which closes it against the bot and not against the
admin.
**And none of it was necessary: direct pushes to `main` were server-side permitted, so the gate was
bypassable with no forgery at all (#743).** That route is now closed — `main` carries
`enable_push: false` (`release.main-direct-push-disabled`), which removes `main` as a destination for
@@ -1,12 +1,12 @@
---
key: ci.batch-pushes-no-cancel-route
title: '2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4; REST route re-probed on 1.27.1 (#542)'
title: '2026-07-21 — Batch your pushes: no agent-side cancel route; REST absence confirmed in the v1.27.1 router source, web UI still 1.25.4-dated (#542, #869)'
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes.
signals: 'cancel run 404 · Gitea 1.25.4 · Gitea 1.27.1 · `POST /api/v1/.../actions/runs/{id}/cancel` · MCP `actions_run_write` `cancel_run` · session+CSRF · `dispatch_workflow` · orphaned run · 4-slot runner · paths: n/a · issues: #542'
signals: 'cancel run 404 · Gitea 1.25.4 · Gitea 1.27.1 · `POST /api/v1/.../actions/runs/{id}/cancel` · MCP `actions_run_write` `cancel_run` · session+CSRF · `routers/api/v1/api.go` · `dispatch_workflow` · orphaned run · 4-slot runner · paths: n/a · issues: #542, #869'
mechanics: Gitea Actions REST + MCP `actions_run_write`; operator-only cancel in the browser UI.
---
@@ -18,7 +18,9 @@ session cookie).
**Re-probed on 1.27.1, 2026-08-28 (#747) — the REST route only.** `POST .../cancel` still returns
**404**, and the live swagger shows the absence is structural rather than one 404: the only POST routes
under `actions/runs/{run}` are `rerun`, `rerun-failed-jobs` and `jobs/{job_id}/rerun`. The MCP
`cancel_run` and web-UI/CSRF findings above were **not** re-run and stay 1.25.4-dated. Swagger also
`cancel_run` and web-UI findings above were **not** re-run and stay 1.25.4-dated. What is said about
each at the end of this record differs, deliberately: the MCP one has a stated but SESSION-LOCAL
reason (#869), and the web-UI one has none beyond being unchanged in kind. Swagger also
lists `DELETE /actions/runs/{run}` (delete a run record); it was not probed — do not assume it cancels
a run or frees a runner slot. **Only the operator can cancel, in the browser** — so if you must supersede a live
run, say so explicitly instead of leaving it burning. (`dispatch_workflow` is a different route and
@@ -26,3 +28,34 @@ still works for re-triggering a **main** run.)
This corrects the older "superseded runs drain on their own" framing: they do finish, but they hold
one of the 4 runner slots while doing it, which is a real cost with several parallel sessions.
**Confirmed from SOURCE at tag `v1.27.1`, 2026-09-02 (#869), which upgrades what the claim rests on.**
`routers/api/v1/api.go` registers the entire `/repos/{o}/{r}/actions/runs/{run}` group and it holds no
cancel route in any method: `GET ""`, `GET /attempts/{attempt}`, `GET /attempts/{attempt}/jobs`,
`DELETE ""` (`repo.DeleteActionRun`), `POST /rerun`, `POST /rerun-failed-jobs`, `GET /jobs`,
`POST /jobs/{job_id}/rerun`, `GET /artifacts`. The only occurrence of `cancel` in that entire router is
`repo.CancelScheduledAutoMerge`, an auto-merge route and unrelated. Reading the source is what draws a
distinction a status code cannot: a 404 is equally consistent with an absent route, a mistyped path and
a permission refusal, and only the first justifies "there is no route".
**Scope that read honestly: it covers the REST API surface and nothing else.** `routers/api/v1/api.go`
cannot speak for the web UI's own routes, for the runner protocol, or for anything an agent driving an
authenticated browser session could reach. "Operator-only in the browser" therefore still rests on the
1.25.4 web-UI finding, not on this source read.
**Why the other two stay dated (#869, 2026-09-02) — and the honest reason, which is not the tidy one.**
The MCP `cancel_run` tool was NOT re-invoked this session because the agent harness refused the call.
That is a session-local constraint, not a property of this repo, and it is recorded as such rather
than dressed up as an impossibility.
An earlier draft of this paragraph did dress it up — it claimed there was "no disposable run to spend"
because nothing triggers on a non-`main` push. **That reason is false and cold review caught it.**
Four of the six workflows carry `workflow_dispatch:`, and `dependency-scan.yml` is read-only while
`renovate.yml` defaults its dispatch to `dryRun: full` (log only, no PRs), so either raises a
disposable run on demand. A scratch PR raises runs too, which is how #747 and #748 probed. And the
premise does not even need a disposable run: invoking `cancel_run` against an already-FINISHED run id
costs nothing, and that is the right re-probe whenever a session can make the call.
What IS established: the REST surface carries no cancel route at v1.27.1 (above), which explains the
tool's 404 mechanistically — but that is an inference about a wrapper whose own source was not read
here, so it is a strong explanation and not a measurement. The web-UI finding is unchanged in kind.
@@ -23,9 +23,14 @@ behind a green required check. Closed unmerged, branches deleted, no forged `h10
**Why a base BINDING and not a pinned diff.** Diffing two immutable shas would close it outright;
Gitea cannot serve that: `compare/{base}...{head}` returns an object whose only keys are `commits`
and `total_commits`, no `files` — measured on 1.25.4 and **re-confirmed on 1.27.1, 2026-08-28 (#747)**,
probed on `b16ec15d6c1e...9b4844c1a8a9`, 5 commits. Separately, and **not re-probed since 1.25.4**, a
`--depth=1` fetch of the two shas has no merge base, so three-dot is impossible and two-dot
over-reports everything `main` gained since the branch point. So the base is read before the first page
probed on `b16ec15d6c1e...9b4844c1a8a9`, 5 commits. Separately, a `--depth=1` fetch of the two shas has
no merge base, so three-dot is impossible and two-dot over-reports everything `main` gained since the
branch point. **Re-probed 2026-09-02 (#869), and the re-probe corrected how it is FILED: this is a GIT
property, not a Gitea one, so it was never 1.25.4-dated in the first place.** Carrying a Gitea version
stamp on it invited a re-run against the wrong axis — a Gitea upgrade cannot change it, and a git
upgrade can. Measured on git 2.55.0 in a disposable local repo: after `git fetch --depth=1 origin
<base> <head>`, `.git/shallow` is present, `git merge-base` exits **1**, and `git diff <base>...<head>`
exits **128** with `fatal: ...: no merge base`. Re-probe it against a GIT version. So the base is read before the first page
and after the last, and **the gap is stated plainly**: a retarget opening *and* closing strictly
between the files call and the re-read stays invisible from inside the enumeration.
@@ -28,6 +28,58 @@ and the base definition posted `h10=pending` on `opened` and `synchronize` alike
establishing the residual below — is counted because omitting it turns an honest partial into an
overclaim. Probes posted only probe-named contexts, never a forged `h10`. Full results in #699.
**The DEFINITION-RESOLUTION half re-established FROM THE v1.27.1 SOURCE, 2026-09-02 (#869)** — the
mechanism behind the measurement, without spending four more scratch PRs. **Be precise about which
half**, because an earlier draft of this block overclaimed and cold review caught it: `notify()`
establishes only WHICH COMMIT each trigger reads its definition from. It says nothing about which
`types` produce a run, and nothing about secrets availability — the file's only other
`pull_request_target` reference is `ifNeedApprovalWith`, an APPROVAL gate, not a secrets gate.
**What IS established on 1.27.1, stated no wider than it was measured:** `pull_request_target` fires
for `review-verdict.yml` continuously — **210 such runs across 189 distinct head shas**, counting only
runs started strictly after the 2026-08-05 upgrade (measured 2026-09-02). All 210 belong to this
workflow. The WINDOW is part of the claim: the all-time REPO-WIDE figure is 258 runs / 233 shas (this
workflow's own all-time count is 250 — the difference is **8 runs** from three since-deleted probe
workflows, all pre-upgrade), which splits 44 before the upgrade date + 4 ON it + 210 after, so citing
258 here would import 1.25.4 evidence into a 1.27.1 sentence. The 4 same-day runs are excluded rather
than assigned — the upgrade landed mid-session and their 17:41-22:36 **UTC** start times cannot be
placed either side of it without the exact upgrade time, so they go to the side that weakens the
claim. Read those times as UTC deliberately: this endpoint returns `started_at` Z-suffixed, while
other Gitea timestamps render in the host zone, Europe/Brussels — `+02:00` in August, `+01:00` in
winter — so a UI comparison shows different clock times. **What that
deliberately does NOT establish:** WHICH `types:` fire, since a run count identifies no event type;
and whether the STORED secret store is reachable under this trigger, since the job references only the
injected `secrets.GITEA_TOKEN`. Both stay 1.25.4-dated.
Two earlier attempts at this paragraph are worth not repeating. The first re-dated BOTH halves —
over-retreating past what production already shows. The second over-corrected into a per-PR
completeness claim ("every PR merged since the upgrade carries this job's context"), which is false
and refuted by this record's own PR #863 observation — that PR merged carrying "no review-verdict
context of any kind", because a PR into a scratch base does not match `branches: [main]`, the very
filter this record exists to explain. (Cited by content rather than by position: a "two paragraphs
down" pointer was wrong the one time it was written, and a positional reference breaks whenever
anything is inserted above it.) A blunt run count needs no per-PR census and cannot rot that way.
Now the mechanism itself. In `services/actions/notifier_helper.go`, one function resolves both. For a
PR event `input.Ref` resolves
to the PR's own HEAD ref — `WithPullRequest` sets `input.Ref = git.RefName(pr.GetGitHeadRefName())`
when the caller has not already supplied one; that ref's commit is passed to
`DetectWorkflows`, and every workflow found there is kept **only if** its trigger is NOT
`pull_request_target` (`if wf.TriggerEvent.Name != actions_module.GithubEventPullRequestTarget`).
Then, separately and only when `input.PullRequest != nil`, it resolves `baseRef := git.BranchPrefix +
input.PullRequest.BaseBranch`, calls `DetectWorkflows` again on that commit, and from THAT set keeps
only workflows whose trigger **is** `pull_request_target`. So the two triggers read their definitions
from two SEPARATELY RESOLVED refs — head and base — and a head rewrite cannot reach a
`pull_request_target` definition at all. (Separately resolved, not necessarily different: nothing
forbids the two refs resolving to the same commit, which is simply the uninteresting case where the
rewrite is not a rewrite.)
The same lines make the `branches: [main]` filter's necessity concrete rather than cautionary: the
base is resolved by BRANCH NAME (`BranchPrefix + BaseBranch`), not by a pinned sha, so "the base
definition" is whatever that branch points at when the event fires. That is why a PR opened into an
attacker-pushed base branch would run that branch's gate, and it is the same re-resolution the
retarget fence exists to fence (`ci.verdict-write-retarget-fence`).
That third item was **re-confirmed at the STATUS level on 1.27.1, 2026-08-28 (#747)**, as a by-product
of an unrelated probe (PR #863): a PR from a scratch head into a scratch base carried thirteen `pull_request`
contexts and no review-verdict context of any kind. The run list itself was not re-enumerated, so the
@@ -0,0 +1,74 @@
---
key: ci.image-build-delegates-the-spa-suite
title: '2026-08-30 — the image build builds the SPA and does not test it (#887)'
status: active
since: '2026-08-30'
supersedes: none
superseded-by: none
rule: '`docker/Dockerfile` runs no vitest suite. Its web-build stage lints, typechecks and BUILDS the SPA; the suite runs once, unfiltered, in `docker-build.yml`''s `test` job on a real checkout, and `build` carries `needs: [test, migrations, scan]` so no image is published past a red suite. THAT EDGE IS NOW THE ONLY LAYER, so the guard checks it is real and not merely present — and it does so by PINNING TEXT rather than parsing it. The commands each SPA-carrying Dockerfile stage runs, and the gating step''s `run:` body and `if:`, are compared as strings against a declared pin; the step and its job must carry no `continue-on-error` in any spelling, the job no job-level `if:`, and the publish step must keep its own `docs_only` gate. A guard that asks what a command MEANS was wrong nine times in three review rounds. A pin is immune to a different SPELLING of the command, which is that whole class; it is not immune to the same text meaning something else, so the routes to that are pinned or refused too: `working-directory` and the step''s own `shell:`, the workflow `defaults.run.shell` and the job-level `defaults` overriding it, a stage `SHELL`, `web/package.json`''s script map (pinned WHOLE — selecting on the literal `vitest` missed `npm run test`, `npm t` and the `prebuild`/`preinstall` lifecycle hooks), `web/vite.config.ts` PINNED WHOLE — it decides what the suite collects and what `vite build` loads, and pinning a BLOCK of it was defeated seven measured ways across three rounds, so the partial match was withdrawn rather than respelled an eighth time; and the ABSENCE of any config file that outranks it — `vitest.config.*` for vitest, and `vite.config.js`/`.mjs` for `vite build`, whose `DEFAULT_CONFIG_FILES` lists them BEFORE `.ts`. Pinning one file is worthless while a second can outrank it, and that family went short twice. The gating job''s `container:` image is left to `ci.image-pin-population` rather than guarded twice. What is unmodelled is a LIST, not an "only": an `ENV` changing what a pinned `RUN` resolves, the plugin BODIES, and a publish through another action. The count of such routes lives in the `docs/guard-inventory.md` row and nowhere else — it briefly existed in three places with two different values, which is how a number stays wrong. It is a running total, wrong at every count so far. They share one shape, which is the transferable part: A PIN ASSUMES IT IS PINNING THE ARTIFACT THAT STILL DECIDES, and every route found so far is authority moving to another file, another occurrence, another workflow, or a hook the pinned command invokes. Do NOT restore a filtered in-image run by naming the specs that cannot run there: that list is a population nothing derives, and the red it produces when it goes stale is unreachable on a PR — `Build & push image (amd64)` is `if: github.event_name != ''pull_request''` — so it lands on `main` and on the `v*` tag path, where it fails the release cut. Held in both directions by `scripts/tests/test_image_build_delegates_the_spa_suite.py`.'
signals: 'image build fails on main only · every image build fails · latest not republished · release cut fails at the image build · gitless build STAGE not context · node:22-bookworm-slim ships no git · virtual:etv-tracked-source-files could not read the git index · hand-maintained exclude list · exclusion list is the underived population · suite members needing the git binary vs a checkout · COPY .git would not help · needs edge is the only gate · unfiltered gating run · paths: `docker/Dockerfile`, `.gitea/workflows/docker-build.yml`, `scripts/tests/test_image_build_delegates_the_spa_suite.py`, `web/vite-plugins/trackedSourceFiles.ts` · issues: #887, #883, #819, #806, #420'
mechanics: 'Populations are DERIVED from the git index (tracked Dockerfiles and workflows, and the stages within them carrying the SPA source); the command lines in those places are PINNED as text. A population decides what is CHECKED, so a hand-written one goes silently short; a pin decides what is EXPECTED, so a stale one goes loudly red. Publishing jobs come from the `docker/build-push-action` step and the Dockerfile each builds from that step''s `file:` input, so `ci-image.yml` is out of scope by derivation rather than by an exemption. 73 mutants, 0 missed (development battery).'
---
The web-build stage is gitless twice over. It copies only `web/` and `design-system/`, so it holds
no `.git`. That is a property of the STAGE, not of the build context — the context is the repository
root (`context: .`) and `.dockerignore` does not exclude `.git`, so the directory is available to be
copied and simply is not; stating it the other way invites a reader to check, find `.git` in the
context, and conclude the whole note is stale. And `node:22-bookworm-slim` ships no git binary. Members of the SPA
suite need one or the other: `pageSizeCallSites.guard.test.ts` and
`completeAnnotations.guard.test.ts` derive their file population from `git ls-files`
and refuse to fall back to a directory walk (`testing.guard-derives-population-from-source`), and
`trackedSourceFiles.realgit.test.ts` builds a real temporary repository. Because the two
prerequisites OVERLAP rather than divide, adding a `COPY .git` fixes neither: the binary is still
missing.
So running the suite there at all costs a list of the members that cannot run, maintained by hand
beside a suite that grows. That list went stale the first time a guard was added without updating
it, and the failure it produced was invisible where it could have been cheap and fatal where it was
not: green on every PR, red on `main` and on the release tag.
**Why not the alternatives.** Each was considered and rejected on its own evidence, not by analogy:
* **Add a third `--exclude`.** Re-arms the same trap for the next guard, which is what the issue was
filed to prevent.
* **Derive the exclusions from source.** Buildable, but it requires a predicate for "needs git" over
spec text — two unrelated shapes today (a virtual-module import, a `git` spawn) and no reason to
think that stays two. A string predicate over source is the wrong instrument for a set whose
members are defined by what they DO at runtime.
* **Give the container git** (`apt-get install git` plus a real `.git`). It works, and it costs a
cache-busting `COPY` of VCS metadata on every commit, so the whole lint/typecheck/test/build layer
re-runs for changes that do not touch `web/`. Synthesising a repository in the image instead —
`git init && git add -A` — is worse than it looks: the resulting index is a filesystem walk with
extra steps, which is precisely the population the guards refuse.
**What the removal gives up, enumerated rather than waved through**
(`process.enumerate-workaround-behaviors-before-deleting`):
1. *Blocking a publish on a red suite* — retained, by the `needs:` edge. Two skips exist inside
`test`. `docs_only` also gates `Build and push`, so that arm ships nothing — ASSERTED, by
`test_the_DOCS_ONLY_arm_cannot_publish_an_image`, after cold review pointed out it had been
stated as fact with nothing checking it. The #420 revalidate skip fires only on a tree
byte-identical to a head that already carried a green combined status; that arm is a DEPENDENCY
on `scripts/ci-detect-already-validated.sh` rather than something asserted here, and that script
is graded `MUTATION: NONE`. Said plainly because #887 is what made the second arm load-bearing:
before it, the in-image run covered that case.
2. *Running the suite under `node:22-bookworm-slim` specifically***lost**, and this is the real
cost. It is small because the suite is jsdom and pure JS, and because the artifact the image
actually ships is the output of `vite build`, which still runs there: a test that passes in the
CI toolchain image and fails in bookworm-slim would be telling us about the test environment, not
about the image.
3. *`docker build` running the suite for a developer locally* — lost; `npm test -- --run` is the
direct route and is what `docs/testing.md` documents.
**The guard's own mechanism was replaced once, and the reason belongs here rather than only in the commit log, because the next person to widen it needs it.** Three versions asked whether a given command runs the suite and whether its failure can be swallowed. That predicate was wrong nine times across three cold-review rounds — executed heredocs treated as data, `#` truncating a command mid-word (including the live `${#reports[@]}` idiom), compound punctuation welding commands, `npm t` and `./node_modules/.bin/vitest` and `timeout`/`su -c` wrappers unrecognised, `true || npm test` counted as a run it never performs, and `continue-on-error: ${{ … }}` slipping a two-literal check. Twice, a clause added to remove a FALSE RED opened a FALSE GREEN on the guard's headline assertion. The mechanism was withdrawn rather than patched a tenth time: the risky command lines are now compared against a pin, so no spelling has to be recognised in order to be rejected. The cost is real and is the point — a legitimate edit to those lines reddens the guard and its author updates the pin deliberately.
A fourth cold review then attacked the pin itself and found the honest limit: the replacement removes the SPELLING problem outright, but the same pinned text can be made to mean something else, and two mutants re-armed this very defect through `web/package.json``RUN npm run build` executes whatever that file says — with every pin still matching. A fifth round then found the same mistake inside the fix: the package.json check SELECTED on the literal `vitest` where a PIN was available, and four one-line edits that never spell it — `npm run test`, `npm t`, and the `prebuild`/`preinstall` lifecycle hooks — each re-armed the defect with every other pin matching. The whole script map is pinned now, and so is `web/vite.config.ts` — WHOLE, after three further
rounds each defeated one spelling of a partial match of it, and two more routes bypassed the marker
entirely because `defineConfig` is the identity function and a later spread replaces what was
matched.
The general lesson is worth more than any of the fixes: replacing a predicate with a pin moves the problem from "can the checker recognise this?" to "is the checked text still the whole story?". The second question has a short, workable answer where the first did not — but it is still a question, the route list has been wrong at every count so far, and the honest form of that is a running total with its history attached rather than a closure claim.
`lint` and `typecheck` stay in the stage. They are gitless-safe with no member that is not, so they
carry no list and no trap. Whether the image build should run them at all is a separate question
this does not answer, and leaving them is not an argument that it should.
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -41,7 +41,7 @@ context, which gives the login specs a signed-out browser without a logout dance
CI — a retry lets a flaky flow merge looking green. Coupling worth knowing: vitest's default `include`
glob would run `web/e2e/*.spec.ts` under jsdom, so `vite.config.ts` excludes `e2e/**` by spreading
`configDefaults.exclude` — not by narrowing `include` to `src/**`, which would silently stop collecting
the real vitest test under `web/scripts/`.
the real vitest tests under `web/scripts/`.
**Lifecycle correctness in `scripts/e2e-ui.sh`** — all four found by adversarial review, **none by a
passing run**; that is the transferable lesson (green runs never exercise the failure/interrupt paths).
@@ -21,6 +21,13 @@ closes the permanent case is the post-POST re-count recorded at `ci.verdict-unve
**The `push`-supersession half stays 1.25.4-dated (#747, 2026-08-28), and the cheap probe route is
gone.** No workflow triggers on a push to a non-`main` branch, and `main` refuses direct pushes, so the
only remaining route is two `push`es to `main` — i.e. two PR merges — landing close together.
Both halves of that reason were RE-CHECKED on 2026-09-02 (#869) rather than carried forward, because a
reason a claim stays dated goes stale exactly like the claim does. All six workflows were enumerated
from the tracked tree and the trigger half still holds; the protection half was re-read live the same
day. Note the scope: that re-check covers the REASON, not the two measurements themselves, which were
not re-run. And it licenses only the "no PUSH-triggered scratch run" half — four of the six workflows
carry `workflow_dispatch:`, so a disposable run IS raisable on demand; what is gone is the cheap
scratch-BRANCH push probe these particular measurements used.
Supersession is per-ref, so a `v*` tag push is not one: `docker-build.yml` triggers on it, but each
release tag is a ref pushed once, with nothing to supersede. That route is real and arrangeable, but it
is not the disposable scratch-branch probe this record's other push measurements used, and the
@@ -272,16 +279,58 @@ here rewritten because what it leaves behind still bounds the fence.
**The two walks do NOT now agree, and an earlier draft of this record said they did.** What they
share is only the cap treatment — "the 20th request has to be the EMPTY terminator". Their
TERMINATION rules are now opposite: `page_statuses` still returns on the FIRST empty page, and
this walk changed away from that. Whether it should follow is not settled here, because the
defect that forced this change has not been shown to exist on `/statuses/{sha}`: measured
2026-08-30, that endpoint returns a TRUE `X-Total-Count` (105 on a 105-row head, at both
`?limit=1` and `?limit=50`), where `/issues/{n}/timeline` returns the post-filter page length.
A true total is consistent with counting before filtering and so with a terminator that means
what it says — evidence, not proof, and the reason it is only evidence is that no filtering
predicate on that endpoint has been exhibited either way. Tracked rather than asserted:
ersatztv#893. "Require K consecutive empty pages" was rejected: K is arbitrary and only moves the
bar to 50K filtered rows.
TERMINATION rules are opposite: `page_statuses` returns on the FIRST empty page, and this walk
changed away from that.
**The asymmetry is CORRECT, and it is correct because the two ENDPOINTS differ — established from
the v1.27.1 source on 2026-09-02 (ersatztv#893), which closes the question this record previously
only tracked.** `GET /repos/{o}/{r}/statuses/{sha}` is `repo.GetCommitStatuses` ->
`getCommitStatuses` in `routers/api/v1/repo/status.go`. It calls
`db.FindAndCount[git_model.CommitStatus]` with `CommitStatusOptions{ListOptions, RepoID, SHA,
SortType, State}`, then builds the response with an UNCONDITIONAL loop —
`for _, status := range statuses { apiStatuses = append(apiStatuses, convert.ToCommitStatus(ctx,
status)) }`. There is no `continue`, no predicate and no nil-drop: `convert.ToCommitStatus` returns
a struct pointer for every row it is handed. `CommitStatusOptions.ToConds()` builds the ONLY filter
`repo_id`, `sha`, and an optional `state` — and it is a SQL `WHERE`, evaluated by the database in
the SAME query that carries the `LIMIT`/`OFFSET`, never as a pass over the rows after they come
back. So the serialized page length equals the database page length, and an empty page really does
mean "past the end".
That is the precise property `/issues/{n}/timeline` lacks. `ListIssueCommentsAndTimeline` calls
`FindComments` with the `ListOptions` (so LIMIT/OFFSET at the database level) and then appends
CONDITIONALLY — `if comment.Type != issues_model.CommentTypeCode &&
isXRefCommentAccessible(ctx, ctx.Doer, comment, issue.RepoID)`. Note that is TWO predicates, not
one: the row type AND a per-viewer accessibility check, so the number of rows dropped is not even a
property of the issue alone. That is what makes a full page of filtered rows byte-identical to the
end of the list. The defect #870 fixed therefore does not exist on `/statuses/{sha}`, and adopting
#870's treatment here would buy nothing for 20 requests per walk on the post-write path, where
latency is least welcome.
**The same two handlers explain the terminator SHAPES this record measured per endpoint, which
until now were an empirical table.** `getCommitStatuses` builds its slice with
`make([]*api.CommitStatus, 0, len(statuses))` — a non-nil empty slice, which serializes as `[]`.
`ListIssueCommentsAndTimeline` declares `var apiComments []*api.TimelineComment` — a nil slice when
nothing appends, which serializes as bare `null`. So "`/statuses/{sha}` returns `[]`,
`/issues/{n}/timeline` returns `null`" is a source-level property of how each handler initialises
its slice, not a coincidence to be re-measured after every upgrade. It still has to be re-read if
either handler is rewritten — but a reader can now check it in the source instead of constructing a
past-the-end request.
**The header measurement is now explained rather than merely suggestive.** Measured 2026-08-30,
`/statuses/{sha}` returns a TRUE `X-Total-Count` (105 on a 105-row head, at both `?limit=1` and
`?limit=50`) where `/issues/{n}/timeline` returns the post-filter page length. The source says why,
on both sides: `getCommitStatuses` calls `ctx.SetTotalCountHeader(maxResults)`, where `maxResults`
is `FindAndCount`'s SQL COUNT over the same conditions — computed by the database, never from the
serialized page — while the timeline calls
`ctx.SetTotalCountHeader(int64(len(apiComments)))`, literally the length of the filtered page it
just built, which is why that header cannot derive a page count there. Re-confirmed live on
2026-09-02 on a head where the two necessarily differ: page 1 returned 50 rows with
`X-Total-Count: 63`. Previously this was recorded as "evidence, not proof" because no filtering
predicate had been exhibited either way; the absence is now read off the handler directly, so it is
settled. **Do not "tidy" the two walks together** — they differ because their endpoints differ.
"Require K consecutive empty pages" was rejected: K is arbitrary and only moves the bar to 50K
filtered rows.
Three measurements at Gitea 1.27.1 (2026-08-30) rule out the cheaper fixes, and are recorded
because each is the kind of thing a later reader would otherwise re-propose. `X-Total-Count` on
@@ -6,8 +6,8 @@ since: '2026-08-30'
supersedes: none
superseded-by: none
stale-after: '2027-02-28'
rule: 'Gitea 1.27.1 offers NO mechanism to restrict `workflow_dispatch` by ref, and has no protected-environment concept at all — PROBED across the REST API, the loaded config and the CLI, not assumed (the WEB UI was not swept; the body says why that is acceptable here and where it would matter). The dispatch body schema `CreateActionWorkflowDispatch` makes `ref` a required free-form string with no allow-list or pattern field; zero of the 308 documented API paths contain "environment", and Actions secrets exist only at org/repo/user scope with no per-ref or per-environment gate; `/api/v1/settings/actions` 404s; the config file the running server actually loads (`/etc/gitea/app.ini`, named by its own `--config`) sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`; and the `gitea` CLI exposes exactly ONE Actions subcommand, `gitea actions generate-runner-token`, which registers a runner and restricts nothing. Treat the VERSION, not the `stale-after` date, as the real trigger to re-probe: an upgrade past 1.27.1 invalidates every capability claim here the day it lands, months before the date fires. The four unrestricted dispatches (`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml`, `renovate.yml`) are therefore ACCEPTED — but the operative reason is NOT "repository write access is the boundary", which is the argument to avoid because it is unfalsifiable and it hides the real route. The operative reason is that **dispatch is not the cheapest route to ANY of it**: `docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that route executes ATTACKER-AUTHORED YAML — and such YAML can name any secret in the repo store, not merely the ones the committed workflows happen to reference (`ci.gate-trigger-base-resolved`, verbatim: "any PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store"). Label that step honestly: it is INFERRED from the repo-scoped secret model plus that record, NOT measured here, because the measurement would print a live credential into a run log. That generalizing step is what makes the argument cover all four rather than just the registry pair: `renovate.yml`''s `RENOVATE_TOKEN`/`GH_COM_TOKEN` are reachable from a PR without dispatching `renovate.yml` at all, and `dependency-scan.yml` references no `secrets.` whatever — which corrects #853''s own table row for it. On the registry credential as it stands, SIX jobs in `docker-build.yml` hold `REGISTRY_PASSWORD` and run on the PR route (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them — `test` and `migrations` — branch-protection required contexts per `.gitea/required-status-contexts.json`; carry that as the INVARIANT "every job on the PR route that NAMES `secrets.REGISTRY_PASSWORD`", never as the six-name list, because a remediation scoped to a stale list misses whatever lands next. Resist the tempting "every `container:` job" — `toolchain-preflight` is deliberately container-free and takes the credential through `ETV_REGISTRY_AUTH`, so that predicate names five of the six and reproduces on day one the exact staleness it was written to prevent. "Deliberate act" throughout carries `ci.toolchain-image-publish-is-a-dispatch`''s sense — an act OUTSIDE the ordinary contribution flow, not a raw step count: opening a PR costs zero such acts and a dispatch costs one. Restricting dispatch would therefore close the more visible route and change nothing. The residuals worth tracking are the PR route AND the `v*` tag push — a single act, explicitly outside `release.main-direct-push-disabled`both in #885, not dispatch.'
signals: 'workflow_dispatch ref restriction, restrict dispatch by branch, protected environment, environment-gated secret, Gitea 1.27.1 actions capabilities, settings/actions 404, attacker-supplied workflow YAML, head-resolved pull_request trigger, PR run gets repo secrets, REGISTRY_PASSWORD reachable from a PR, tag_protections empty, renovate bot write scope · paths: `.gitea/workflows/ci-image.yml`, `.gitea/workflows/docker-build.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/renovate.yml`, `docs/ci-cd.md` · issues: #853, #744, #885, #697, #742, #748, #845'
rule: 'Gitea 1.27.1 offers NO mechanism to restrict `workflow_dispatch` by ref, and has no protected-environment concept at all — PROBED across the REST API, the loaded config and the CLI, not assumed (the WEB UI was not swept; the body says why that is acceptable here and where it would matter). The dispatch body schema `CreateActionWorkflowDispatch` makes `ref` a required free-form string with no allow-list or pattern field; zero of the 308 documented API paths contain "environment", and Actions secrets exist only at org/repo/user scope with no per-ref or per-environment gate; `/api/v1/settings/actions` 404s; the config file the running server actually loads (`/etc/gitea/app.ini`, named by its own `--config`) sets only `ENABLED` and `DEFAULT_ACTIONS_URL` under `[actions]`; and the `gitea` CLI exposes exactly ONE Actions subcommand, `gitea actions generate-runner-token`, which registers a runner and restricts nothing. Treat the VERSION, not the `stale-after` date, as the real trigger to re-probe: an upgrade past 1.27.1 invalidates every capability claim here the day it lands, months before the date fires. The four unrestricted dispatches (`ci-image.yml`, `docker-build.yml`, `dependency-scan.yml`, `renovate.yml`) are therefore ACCEPTED — but the operative reason is NOT "repository write access is the boundary", which is the argument to avoid because it is unfalsifiable and it hides the real route. The operative reason is that **dispatch is not the cheapest route to ANY of it**: `docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that route executes ATTACKER-AUTHORED YAML — and such YAML can name any secret in the repo store, not merely the ones the committed workflows happen to reference (`ci.gate-trigger-base-resolved`, verbatim: "any PR-added workflow can reference `RENOVATE_TOKEN`, a `write:repository` bot PAT in the same store"). Label that step honestly: it is INFERRED from the repo-scoped secret model plus that record, NOT measured here, because the measurement would print a live credential into a run log. That generalizing step is what makes the argument cover all four rather than just the registry pair: `renovate.yml`''s `RENOVATE_TOKEN`/`GH_COM_TOKEN` are reachable from a PR without dispatching `renovate.yml` at all, and `dependency-scan.yml` references no `secrets.` whatever — which corrects #853''s own table row for it. On the registry credential, SIX jobs in `docker-build.yml` held `REGISTRY_PASSWORD` on the PR route (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them — `test` and `migrations` — branch-protection required contexts per `.gitea/required-status-contexts.json`. That is CLOSED as of 2026-09-04 (#885, `ci.pr-route-carries-no-stored-credential`): nothing on the `pull_request` route names any stored secret — no job and no workflow scope — the toolchain image is pulled anonymously and the commit-status API read unauthenticated, and the PAT survives in `build`, which the route cannot reach. The invariant it is held by is "no part of a `pull_request`-triggered workflow may name a `secrets.*`" — every job the trigger reaches AND the workflow scope outside `jobs:`, since a root `env:`/`defaults:` is materialised into every job and no job-level `if:` can take it off the route — DERIVED from the git index by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`, never the six-name list, and never "every `container:` job" — `toolchain-preflight` was deliberately container-free and took the credential through `ETV_REGISTRY_AUTH`, so that predicate names five of the six and reproduces on day one the exact staleness it was written to prevent. NOTE WHAT THAT DID NOT CHANGE, which is this record''s whole point: head-supplied YAML can still name every secret in the store, so the PR route is still cheaper than any dispatch. "Deliberate act" throughout carries `ci.toolchain-image-publish-is-a-dispatch`''s sense — an act OUTSIDE the ordinary contribution flow, not a raw step count: opening a PR costs zero such acts and a dispatch costs one. Restricting dispatch would therefore close the more visible route and change nothing. The residuals were the PR route AND the `v*` tag push — a single act, explicitly outside `release.main-direct-push-disabled`and both were addressed in #885 rather than by restricting dispatch: the tag push is now whitelisted to the release operator (`release.tag-protection-v-star`) and the PR route carries no stored credential. Neither makes dispatch worth restricting, because the remaining exposure — head YAML naming any secret in the store — is reached by opening a PR and is unaffected by either.'
signals: 'workflow_dispatch ref restriction, restrict dispatch by branch, protected environment, environment-gated secret, Gitea 1.27.1 actions capabilities, settings/actions 404, attacker-supplied workflow YAML, head-resolved pull_request trigger, PR run gets repo secrets, REGISTRY_PASSWORD reachable from a PR, tag_protections, v* tag whitelist, renovate bot write scope · paths: `.gitea/workflows/ci-image.yml`, `.gitea/workflows/docker-build.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/renovate.yml`, `docs/ci-cd.md` · issues: #853, #744, #885, #697, #742, #748, #845'
mechanics: '`docs/ci-cd.md` -> "CI toolchain image" -> "Publishing from a branch is a dispatch, not a push"'
sources: 'Gitea 1.27.1 probe, 2026-08-30 — `swagger.v1.json` (308 paths, 0 matching "environment"); `CreateActionWorkflowDispatch` schema; live `tag_protections` = `[]`; live `branch_protections` = one `main` rule; `/api/v1/admin/users` = exactly `timothy` (admin) + `renovate` (non-admin), with `permission: write` from `/repos/timothy/ersatztv/collaborators/renovate/permission`; host `app.ini` `[actions]`; differential dispatch-authorization probe with anonymous and authenticated-non-writer controls'
---
@@ -47,16 +47,21 @@ sources: 'Gitea 1.27.1 probe, 2026-08-30 — `swagger.v1.json` (308 paths, 0 mat
into a run log. **That is the step that makes this
cover all four dispatches**, `renovate.yml` included, even though `renovate.yml` has no
`pull_request:` trigger of its own; without it the argument would reach only the registry pair.
Concretely on the registry credential today, SIX jobs hold `REGISTRY_PASSWORD` and run on the PR
route — `toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format` — of
which `test` and `migrations` are both branch-protection required contexts
(`.gitea/required-status-contexts.json`). **Carry the invariant, not the list:** *every job on the PR
route that names `secrets.REGISTRY_PASSWORD`*, because a remediation scoped to today's six names
silently misses the seventh. Not "every `container:` job" — `toolchain-preflight` is deliberately
container-free (`runs-on: small`, credential via `ETV_REGISTRY_AUTH`), so that predicate names five of
six and is stale before it ships.
So "push a branch, open a PR" reaches the credential with **no act outside the ordinary contribution
flow**, where a dispatch costs one — that is this repo's sense of "deliberate"
On the registry credential, SIX jobs held `REGISTRY_PASSWORD` on the PR route —
`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format` — of which
`test` and `migrations` are both branch-protection required contexts
(`.gitea/required-status-contexts.json`). That was closed in #885
(`ci.pr-route-carries-no-stored-credential`), by the invariant and not by the list: *no part of a
`pull_request`-triggered workflow may name a `secrets.*`* — neither a job the trigger reaches nor
the workflow scope outside `jobs:`, whose `env:`/`defaults:` are materialised into every job —
derived from the git index, because a remediation scoped to those six names silently misses the
seventh. Not "every `container:` job" either — `toolchain-preflight` is deliberately
container-free (`runs-on: small`) and took the credential through `ETV_REGISTRY_AUTH`, so that
predicate names five of six and is stale before it ships.
**The route itself is untouched by that fix**, which is why this record still stands: head-supplied
YAML can name any secret in the store, so "push a branch, open a PR" still reaches all of them with
**no act outside the ordinary contribution flow**, where a dispatch costs one — that is this
repo's sense of "deliberate"
(`ci.toolchain-image-publish-is-a-dispatch`), not a raw step count, which would read 2 vs 2. Any
control that restricted dispatch and left this open would close the more visible route and report
progress that did not happen. Per-`if:` ref gates on the publish steps do not change this: they live
@@ -76,11 +81,10 @@ sources: 'Gitea 1.27.1 probe, 2026-08-30 — `swagger.v1.json` (308 paths, 0 mat
with `"not found"` while `renovate` on this repo reaches ref resolution with `"ref ... doesn't
exist"` — the same response a known writer gets — so `renovate` passes the dispatch authorization
gate.
- **One cheap control exists and was deliberately NOT taken here: `tag_protections` is empty.** Gitea
1.27.1 does support tag protection (`name_pattern` plus a username/team whitelist), and a `v*` rule
whitelisting the release operator would close `docker-build.yml`'s tag-push row against the bot at
no operational cost, since only the operator cuts releases. It is not applied in this change because
it is live branch-protection-class configuration whose failure mode is a broken release cut, which
deserves its own change and its own verification that a legitimate tag push still succeeds — not a
rider on a decision record. It carries no weight against the admin actor either way. Tracked with
the `pull_request:` residual in #885.
- **One cheap control was identified here and applied in #885: the `v*` tag rule.** Gitea 1.27.1
supports tag protection (`name_pattern` plus a username/team whitelist), and `tag_protections` was
empty when this record was written (2026-08-30), leaving `docker-build.yml`'s tag-push row open
against the bot. It was deliberately not applied as a rider on a decision record — its failure mode
is a broken release cut, so it earned its own change and its own verification. It now exists as
`v*` whitelisted to `timothy` (`release.tag-protection-v-star`), and it carries no weight against
the admin actor, which was true when it was proposed and is true now.
File diff suppressed because one or more lines are too long
@@ -6,7 +6,7 @@ since: '2026-07-22'
supersedes: none
superseded-by: none
rule: A channel can attach `GraphicsElement`s directly via a new `ChannelGraphicsElement` join table (a base layer under deco/playout-item elements), and a built-in text element (`on-now-next.yml`) is seeded once per database so the On Now/Next overlay works out of the box.
signals: 'ChannelGraphicsElement, Channel graphics attachment, GraphicsElementSelector base layer, on-now-next seeded element, GraphicsElementDefaults.OnNowNextFileName, builtIn discriminator · paths: `ErsatzTV.Core/Domain/ChannelGraphicsElement.cs`, `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs`, `ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs`, `ConfigElementKey.GraphicsOnNowNextSeeded`, `GraphicsElementResponseModel.BuiltIn` · issues: #74'
signals: 'ChannelGraphicsElement, Channel graphics attachment, GraphicsElementSelector base layer, on-now-next seeded element, GraphicsElementDefaults.OnNowNextFileName, GraphicsElementDefaults.OnNowNextSeededPath, builtIn discriminator · paths: `ErsatzTV.Core/Domain/ChannelGraphicsElement.cs`, `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs`, `ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs`, `ConfigElementKey.GraphicsOnNowNextSeeded`, `GraphicsElementResponseModel.BuiltIn` · issues: #74, #568'
---
#74 asked for a transient "On Now / Next" text bug burned onto the transcoded stream at each
@@ -40,8 +40,22 @@ four joins (composite key `{ChannelId, GraphicsElementId}`), added via a dual-pr
preset).
- The API needed a way for the SPA to find the built-in element without a fragile name-match — the
direct #67 lesson (`WatermarkResponseModel.imageSource`). `GraphicsElementResponseModel` gained a
server-derived `BuiltIn` bool, computed by comparing the row's `Path` filename to
`GraphicsElementDefaults.OnNowNextFileName` rather than trusting the element's editable `Name`.
server-derived `BuiltIn` bool, computed from the row's `Path` rather than the element's editable
`Name`. The test is `GraphicsElementDefaults.IsOnNowNext` — ordinal equality against the full
seeded path, plus `Kind == Text`. #568 sharpened it from the original bare-filename comparison,
which was folder-agnostic: a user element named exactly `on-now-next.yml` in another template
folder also reported `builtIn:true`. `Kind` is inside the predicate rather than a filter each
caller adds, because the seeder's lookup requires it: split across the two sites, an Image row at
the seeded path was `builtIn:true` on the wire while the seeder refused to resolve it. Case sensitivity is kept, deliberately: the remedy #568 prescribes — the
full seeded relative path — is exactly as case-sensitive as the filename match it replaces, and a
case-INsensitive test would hand the built-in identity to a user element differing from the seeded
path only in case. The built-in element is the exact file the seeder wrote, at the path it wrote it
to. Every site applies that predicate **in memory**, never as a `Where` clause — including the
seeder's own "does the row exist yet?" check, which calls `GetBuiltInElementId` rather than asking
the question a second way. `GraphicsElement.Path` carries no explicit collation, so a SQL `Path ==`
comparison answers case-sensitively under SQLite and normally case-INsensitively under MySQL: one
site in SQL and another in memory disagree on MySQL alone, which the SQLite suite cannot show. See
`graphics.on-now-next-on-by-default` and `docs/graphics-elements.md`.
- The channel editor's Branding-tab "Show On Now / Next overlay" switch follows the exact pattern
of the existing logo-bug toggle: on adds the built-in element's id to `graphicsElementIds`, off
removes it; disabled (with an explanatory caption) when the channel is HLS-Direct.
@@ -60,8 +60,10 @@ is simply absent, indistinguishable from never having enabled it — so the one-
re-attaches it. That is inherent to "enable it on all channels by default" rather than a defect. The
never-re-attach guarantee therefore holds *from the marker onwards*, not across the upgrade boundary.
Identity is the element's **filename** (`GraphicsElementDefaults.OnNowNextFileName`), never the
user-editable `Name` the #67 lesson carried through #74.
Identity is the element's **full seeded path and `Kind == Text`** `GraphicsElementDefaults.IsOnNowNext`,
ordinal equality against `OnNowNextSeededPath` — never the user-editable `Name`: the #67 lesson carried
through #74, sharpened from a bare filename to the full path by #568 because a filename-only match
was folder-agnostic (see `graphics.channel-level-attachment` and `docs/graphics-elements.md`).
**HLS Direct is excluded at both sites.** ErsatzTV is not transcoding there, so `GraphicsElementSelector`
returns empty and the editor disables the toggle; an attachment would be inert while still reading as
@@ -5,9 +5,9 @@ status: active
since: '2026-08-30'
supersedes: none
superseded-by: none
rule: 'A hook resolves every path whose CONTENT it sources, executes, or consults to decide, from `$repo_root` — derived from the hook''s own `${BASH_SOURCE[0]}` — and never from `$CLAUDE_PROJECT_DIR` or any other environment variable. There is NO telemetry exemption, and the attempt to write one is instructive: the first draft of this record exempted `ETV_HOOK_FIRE_LIB` on the grounds that "a wrong log destination is not a wrong verdict", and cold review refuted it by execution — that path is `. `-SOURCED, so whatever it names runs as CODE inside the hook before stdin is read and before `decide` exists; a file there that prints an `allow` decision and exits 0 grants the merge having bypassed every check in the file. A path''s PURPOSE does not bound its authority; how the hook consumes it does. The failure that matters is not an attacker — the same variable already names the hook binary in `.claude/settings.json`, so a hostile value has chosen which hook runs and the gate is moot before any inner path is read — it is LAUNCHER DIVERGENCE, and it is reachable: husky invokes the prepush hooks as `./.claude/hooks/…`, a relative path wholly independent of `$CLAUDE_PROJECT_DIR`, so the two roots genuinely disagree there. A wrong path holding a plausible file returns a confident answer about another tree; a missing path only asks, so the silent direction is the dangerous one. Two halves of one comparison must never come from two roots.'
rule: 'A hook resolves every path whose CONTENT it sources, executes, or consults to decide, from `$repo_root` — derived from the hook''s own `${BASH_SOURCE[0]}` — and never from `$CLAUDE_PROJECT_DIR` or any other environment variable. There is NO telemetry exemption, and the attempt to write one is instructive: the first draft of this record exempted `ETV_HOOK_FIRE_LIB` on the grounds that "a wrong log destination is not a wrong verdict", and cold review refuted it by execution — that path is `. `-SOURCED, so whatever it names runs as CODE inside the hook before stdin is read and before `decide` exists; a file there that prints an `allow` decision and exits 0 grants the merge having bypassed every check in the file. A path''s PURPOSE does not bound its authority; how the hook consumes it does. THE SAME TEST DRAWS THE OTHER BOUNDARY, and it is not optional: a path the hook consumes as its SUBJECT — the tree it is asked to JUDGE — is caller-supplied by design, and binding it to `$repo_root` would break the guard, since the hook lives in one tree and must judge whichever worktree is being acted in. Two live cases, both deliberate: `pretooluse-bom-guard.sh` takes the committed tree from the command''s `cd`, then the payload `cwd`, then `${CLAUDE_PROJECT_DIR:-$PWD}` as a last-resort default; and `decisions-guard.sh` `cd`s to `git rev-parse --show-toplevel` and EXECUTES that tree''s `scripts/decisions_validate.py`, whose exit code is the hook''s decision — the stronger case, since it is code and not data, and it is admissible for the same reason: a pre-commit validator must validate the tree being committed, and under husky, its only launcher, git sets the cwd to that tree so the two roots agree anyway. The distinction is not first-two-sources-versus-third and not data-versus-code: it is WHOSE QUESTION the path answers. Ban the variable in the assignment naming the AUTHORITY — the code that decides — never every mention of it in a hook. The failure that matters is not an attacker — the same variable already names the hook binary in `.claude/settings.json`, so a hostile value has chosen which hook runs and the gate is moot before any inner path is read — it is LAUNCHER DIVERGENCE, and it is reachable: husky invokes the prepush hooks as `./.claude/hooks/…`, a relative path wholly independent of `$CLAUDE_PROJECT_DIR`, so the two roots genuinely disagree there. A wrong path holding a plausible file returns a confident answer about another tree; a missing path only asks, so the silent direction is the dangerous one. Two halves of one comparison must never come from two roots.'
signals: 'env var as a security input · `$CLAUDE_PROJECT_DIR` vs `$repo_root` · `${BASH_SOURCE[0]}` self-location · sourced file is code not configuration · which checkout''s code decided this · launcher divergence · husky relative path vs harness env · a wrong path returns a confident answer, a missing one asks · both halves of a comparison from one root · paths: `.claude/hooks/pretooluse-merge-consent.sh`, `scripts/hook-fire-log.sh`, `scripts/check-review-verdict.sh`, `scripts/check-required-contexts.sh` · issues: #858, #891, #787, #629'
mechanics: 'No linter can spot "this path should have come from `$repo_root`". Enforcement is review plus a behavioural test per site: point `$CLAUDE_PROJECT_DIR` at a decoy tree whose copy of the resolved file returns the OPPOSITE decision, and assert the outcome is unchanged. That test needs a NEGATIVE CONTROL or it proves nothing — the passing outcome is also what an inert decoy produces — so run the same decoy again with the hook COPIED INTO it, making the decoy genuinely `$repo_root`, and assert the decision DOES flip. Two worked pairs live in `test_merge_consent_required_check.py`: `test_CLAUDE_PROJECT_DIR_cannot_choose_which_verdict_classifier_runs` and `test_the_SOURCED_fire_log_library_cannot_be_chosen_by_CLAUDE_PROJECT_DIR`, each beside its `..._IS_honoured/sourced_when_it_genuinely_is_the_repo_root` control.'
mechanics: 'No linter can spot "this path should have come from `$repo_root`". Enforcement is review plus a behavioural test per site: point `$CLAUDE_PROJECT_DIR` at a decoy tree whose copy of the resolved file returns the OPPOSITE decision, and assert the outcome is unchanged. That test needs a NEGATIVE CONTROL or it proves nothing — the passing outcome is also what an inert decoy produces — so run the same decoy again with the hook COPIED INTO it, making the decoy genuinely `$repo_root`, and assert the decision DOES flip. Since #891 that test is PARAMETRIZED OVER THE DERIVED HOOK POPULATION in `test_hook_fire_log.py``test_CLAUDE_PROJECT_DIR_cannot_choose_the_SOURCED_sink` beside `test_the_sink_decoy_IS_sourced_when_it_genuinely_is_the_repo_root`, and the husky RELATIVE launch as its own pair, since that construct resolves through the CWD — so a hook added tomorrow arrives covered rather than needing someone to remember it. The STATIC half is not a pattern over the line but BYTE-IDENTITY of the preamble''s two lines (a lexical rule was tried and withdrawn; see the body), proved by `test_a_LATER_reassignment_the_regex_cannot_see_is_DETECTED` for the shape only it catches, with `test_an_ENV_VAR_resolved_sink_path_is_DETECTED` and `test_the_NEXT_env_var_to_be_invented_is_DETECTED` pinning the diagnostic arms, each asserting its OWN arm''s phrase — repair for `test_an_ENV_VAR_resolved_sink_path_is_DETECTED`, whose assertion the byte-identity message''s echo of the offending line did satisfy by itself, and prudence for the others. The sink''s own root resolution additionally requires the root to OWN it, proved by `test_the_report_REFUSES_a_root_that_does_not_OWN_this_sink`. Two worked single-site pairs live in `test_merge_consent_required_check.py`: `test_CLAUDE_PROJECT_DIR_cannot_choose_which_verdict_classifier_runs` and `test_the_SOURCED_fire_log_library_cannot_be_chosen_by_CLAUDE_PROJECT_DIR`, each beside its `..._IS_honoured/sourced_when_it_genuinely_is_the_repo_root` control.'
---
`.claude/hooks/pretooluse-merge-consent.sh` resolved three decision-bearing paths from two different
@@ -40,7 +40,40 @@ tree's code into a gate that can block or allow the push. That is ordinary, non-
produces a confident wrong answer rather than a visible failure.
**Scope actually shipped, so nobody reads more into it.** #858 fixed the verdict classifier and the
fire-log resolution **in `pretooluse-merge-consent.sh` only**. The other twelve tracked hooks still
carry the env-var-first fire-log line; sweeping them needs its own derived population and its own
review, and is #891. One copy was fixed because leaving a total gate bypass above the gate that PR
was hardening would have made the rest of it decorative — not because one hook is special.
fire-log resolution in `pretooluse-merge-consent.sh` only — because leaving a total gate bypass above
the gate that PR was hardening would have made the rest of it decorative, not because one hook is
special. #891 then swept the other twelve tracked hooks **together, in one change**, and reconciled
the second resolution inside `scripts/hook-fire-log.sh` itself: its report preferred
`$CLAUDE_PROJECT_DIR` when deriving the hook POPULATION, which reads as harmless because a report
decides nothing — but the report's two halves ARE that population and the log, so resolving them
from two roots earns a NEVER-FIRED row for a hook that fired. Every tracked hook now carries that
assignment byte-identically. The twelve above is a dated fact about what #891 swept, not an
invariant: the INVARIANT is "every tracked hook", and no count of it is pinned anywhere, because the
population is derived from `git ls-files` by the guard — a new hook is covered the moment it exists
rather than when someone remembers to update a number. (That derivation is the GUARD's. `etv_hook_fire_report`'s own
population is a filesystem glob over the tree it roots at — a report, not a gate — so the two are
not the same population and should not be cited as one.)
**Byte-identical is the property, not an aesthetic.** Diverging one copy is how this repo acquired
the defect that extracting `scripts/lib/branch-rule-classifier.jq` was meant to end, so the sweep
was all-or-nothing, and the check is a comparison over the derived population rather than a comment
in each hook — one machine-checked rule beats a paragraph repeated once per hook and drifting in all
but one of them.
**The check pins BYTE-IDENTITY, and arriving there took a withdrawal.** The obvious rule is lexical
— ban `$CLAUDE_PROJECT_DIR` in the assignment — and it is worth exactly the one name in it. Three
review rounds walked the successors: `${NEXT_VAR:-$(… BASH_SOURCE …)}` satisfies a containment test
while the variable still wins; then backticks and `$((…))`; then `$(printenv VAR)`, `$1`, `$?`, an
INDENTED or `export`ed reassignment further down that a column-anchored regex cannot see, and `$'…'`
quoting that makes the required token literal. Each fix admitted the next shape, which is this
repo's recorded signal to stop rather than patch again — a guard was WITHDRAWN from that same test
file after four iterations of pattern-matching shell source. A shell assignment is not recognisable
by regex, so the guard stopped trying: every non-comment line mentioning the variable must be one of
the two canonical lines, byte for byte. The lexical arms over the preamble survive only to say WHY a
line is wrong — that includes the pattern matching the sourcing line, which byte-identity
necessarily matches and which therefore decides nothing on its own. The arms judging other things —
a self-disabled hook, and the begin call's PRESENCE, name, mode and ordering — are not subsumed and
still decide. Each is pinned by its own test asserting its own fault message, so this sentence is a
map and not the guard: deleting an arm on the strength of this list reddens the suite.
The cost is that a future rewrite of the line must edit one constant and all the hooks together,
which is the property #891 exists to hold.
@@ -0,0 +1,34 @@
---
key: process.orchestrated-session
title: 2026-09-04 — An orchestrated session runs several issue slots under one referee, and each slot is still a whole kickoff session (#907)
status: active
since: '2026-09-04'
supersedes: none
superseded-by: none
rule: A session may run several issues at once only as an orchestrated session — an orchestrator that never picks, claims, codes or pushes; one worktree per issue under `~/orca/workspaces/ersatztv/`; the local gate and cold review inside the worktree BEFORE the single push; the referee alone ticks `## Done-when` boxes and posts the verdict. Two scopings hold across every slot — a rebase onto `origin/main` pushed with `--force-with-lease` is the ONE sanctioned rewrite of a pushed branch (the commits are unchanged, only the base moved; H11 refuses the alternative and merging main in is forbidden), and it voids the verdict, so the rebased head is re-reviewed. Live-E2E needs no machine-wide lock — the launcher's conflict is its per-worktree `wwwroot`, so slots run on distinct ports and the launcher's pre-flight refuses a busy one.
signals: 'orchestrator · referee · slots · parallel issues · worktree per issue · force-with-lease · rebase voids verdict · who ticks Done-when · E2E per worktree port · paths: `docs/handoffs/orchestration.md`, `docs/handoffs/orchestrator-prompt.md`, `.claude/workflows/ersatztv-issue-build.js` · issues: #907'
mechanics: '`docs/handoffs/orchestration.md` owns roles, isolation and the landing order; the Workflow scripts under `.claude/workflows/` encode it. Box-ticking: the implementer writes the evidence per box into the `## Closing record`; the orchestrator ticks after reading it and the review evidence, then `scripts/post-review-verdict.sh`.'
---
The kickoff (`docs/handoffs/chicorytv-issue-queue.md`) binds a session that closes one issue.
Running several at once does not relax any of it; what it adds is a referee, and two places where
the single-session rules needed a stated scope rather than a contradiction.
**Force-with-lease.** `process.pr-routine-sequence` forbids amending or force-pushing a pushed branch;
`release.format-as-you-touch-rebase` (H11) refuses to push a branch behind `origin/main` and forbids
merging main in. With several slots merging, a pushed branch is behind main as the normal case, so the
only path that satisfies both is a rebase whose commits are byte-for-byte the reviewed ones, pushed
with `--force-with-lease`. That is not the rewrite the first rule targets, which is a fix folded into
an already-pushed commit. The cost is the verdict: `review-verdict/h10` binds to a sha, so the rebased
head is reviewed again before a new verdict is posted. Rebase once, right after the review loop, and
`git patch-id --stable` before and after proves the patch did not change.
**Who ticks the boxes.** `release.merge-consent-autogrant` derives consent from the ticked boxes, so a
box ticked by the agent whose work it certifies is self-consent. The implementer supplies the evidence
per box; the referee ticks.
**No E2E lock.** A `mkdir` lock around `scripts/e2e-local.sh` was tried and rejected 2026-09-04: the
script is a launcher that returns with the server still running, so the lock released before the
assertions ran, and its stale-holder path double-acquired in 4 of 91 measured races. The conflict the
launcher documents is its `wwwroot` under the repo root, which is per worktree; two worktrees on two
ports do not share it.
@@ -5,9 +5,9 @@ status: active
since: '2026-08-05'
supersedes: none
superseded-by: none
rule: 'Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are unaffected (`tag_protections` governs those separately), so the release cut still works.'
rule: 'Branch protection on `main` carries `enable_push: false` AND `block_admin_merge_override: true`. Both halves are required and neither is sufficient. `enable_push: false` removes the direct-push path, leaving the PR merge path — the only path on which Gitea evaluates `status_check_contexts`, and therefore the only path on which `review-verdict/h10` is consulted at all. `block_admin_merge_override: true` then closes the force-merge bypass on that remaining path: with it false (the default), `CanBypassBranchProtection` returns true for a repo admin, so `POST /pulls/{n}/merge` with `force_merge: true` merges a PR whose `h10` is missing or red — one API call, no forgery, no PATCH. Do NOT "soften" the push half to a push WHITELIST: measured here, a whitelist naming `timothy` still admits the push, and `timothy` is the identity every agent session, PAT and injected `GITEA_TOKEN` already acts as, so the whitelist form closes nothing while reading in review as a control. Same reasoning is why the admin-override half is needed: an admin-shaped control that exempts the only admin exempts everybody. What remains open: a credential that can PATCH branch protection off can still undo either half — an accepted residual, not a closed route. Tag pushes are governed by `tag_protections`, an entirely separate mechanism that does NOT inherit from this rule; since #885 it carries one entry, `v*` whitelisted to `timothy` (`release.tag-protection-v-star`), which is intended to leave the release cut working for the operator while refusing a `v*` push by the `renovate` bot that would publish a `:prod` image. Both halves are EXPECTED, UNVERIFIED: only the `timothy` credential exists here, so neither a real release cut nor a refused bot push has been exercised, and that record carries the same caveat.'
signals: 'direct push to main, push whitelist, enable_push false, branch protection bypass, review-verdict/h10 bypassable without forging, merge consent derived not asserted, pre-receive hook declined, Not allowed to push to protected branch, protected branch, tag_protections, release tag push, GITEA_TOKEN repo write, RENOVATE_TOKEN, site admin bypass, PR-only flow · paths: `docs/ci-cd.md` · issues: #743, #697, #698, #622, #672, #706, #742, server-management#714'
mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returns `[]`; repo is `fork: false`, `mirror: false`. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout. MEASURED 2026-08-28 on 1.27.1 (#747), superseding the earlier source-attested-only note on this field. Four SEPARATE throwaway scratch bases, each with its own protection rule, head branch and PR (they differ by construction, not by mutating one rule in place — arm A merged, so its PR could not be re-used); `main` was never merge-probed and its rule never PATCHed, `updated_at` unchanged at 2026-08-05. Each base required one probe-named context that was never posted, except in the control step below. `scratch/747-base` (#863), field at its `false` default, ORDINARY merge: REFUSED HTTP 405 `Not all required status checks successful`; posting only that context as `success` and repeating the ordinary merge on the same PR returned HTTP 200 — the one attribution control in the set, and the only variable between those two calls is the context. `scratch/747a-base` (#864), field `false`, admin `force_merge`: MERGED HTTP 200. `scratch/747b-base` (#865), rule identical but for the field set `true`, same call: REFUSED HTTP 405 — the A/B pair is the evidence that the field governs the force path; B''s 405 has no control of its own. `scratch/747c-base` (#866), rule as A but with `enable_bypass_allowlist: true` and an EMPTY `bypass_allowlist_usernames`: MERGED HTTP 200, so in THAT configuration the allowlist does not substitute for `block_admin_merge_override`; a non-empty list was not tested, and this cannot distinguish `does not govern admins` from `empty list treated as unconfigured`. Probing never required merging an unreviewed PR into `main`: a scratch BASE branch carries its own rule, so the whole experiment is disposable. All #747 probe artifacts (four scratch bases — `scratch/747-base` for the ordinary-merge pair plus `scratch/747{a,b,c}-base` for the three force-merge arms — each with its own protection rule and head branch, so four rules and eight branches, and PRs #863-#866) deleted and confirmed gone; `origin/main` head unchanged at `b16ec15d6` throughout, and `main`''s own rule was never PATCHed.'
mechanics: 'Gitea 1.27.1. `PATCH /api/v1/repos/timothy/ersatztv/branch_protections/main` with `{"enable_push": false, "block_admin_merge_override": true}`; whitelist fields left off (`enable_push_whitelist: false`, empty arrays), `enable_force_push: false`, `enable_merge_whitelist: false`, `required_approvals: 0`. MEASURED 2026-08-05 against a throwaway `probe-743-*` rule rather than against `main`: with `enable_push: false` a push by `timothy` (site admin) was REFUSED — `pre-receive hook declined`, `Not allowed to push to protected branch`; after PATCHing the same rule to `enable_push: true` + `enable_push_whitelist: true` + `push_whitelist_usernames: ["timothy"]` the identical push SUCCEEDED. Separately probed on a second throwaway rule: a contents-API write (`PUT /repos/{o}/{r}/contents/{path}` with `branch` set to the protected branch) was REFUSED HTTP 403 `user cannot commit to repo [user: timothy]` — so the web-editor/API file-write surface does not bypass it either. Then on `main` itself: `git push origin HEAD:main` REFUSED, and a tag-only push SUCCEEDED from the same worktree. `GET .../tag_protections` returned `[]` at that probe; since #885 it carries one `v*` rule whitelisted to `timothy` (`release.tag-protection-v-star`). Repo is `fork: false`, `mirror: false`. All probe artifacts (two rules, two branches, one tag) deleted and confirmed gone; `origin/main` head unchanged at `08e95f9ec` throughout. MEASURED 2026-08-28 on 1.27.1 (#747), superseding the earlier source-attested-only note on this field. Four SEPARATE throwaway scratch bases, each with its own protection rule, head branch and PR (they differ by construction, not by mutating one rule in place — arm A merged, so its PR could not be re-used); `main` was never merge-probed and its rule never PATCHed, `updated_at` unchanged at 2026-08-05. Each base required one probe-named context that was never posted, except in the control step below. `scratch/747-base` (#863), field at its `false` default, ORDINARY merge: REFUSED HTTP 405 `Not all required status checks successful`; posting only that context as `success` and repeating the ordinary merge on the same PR returned HTTP 200 — the one attribution control in the set, and the only variable between those two calls is the context. `scratch/747a-base` (#864), field `false`, admin `force_merge`: MERGED HTTP 200. `scratch/747b-base` (#865), rule identical but for the field set `true`, same call: REFUSED HTTP 405 — the A/B pair is the evidence that the field governs the force path; B''s 405 has no control of its own. `scratch/747c-base` (#866), rule as A but with `enable_bypass_allowlist: true` and an EMPTY `bypass_allowlist_usernames`: MERGED HTTP 200, so in THAT configuration the allowlist does not substitute for `block_admin_merge_override`; a non-empty list was not tested, and this cannot distinguish `does not govern admins` from `empty list treated as unconfigured`. Probing never required merging an unreviewed PR into `main`: a scratch BASE branch carries its own rule, so the whole experiment is disposable. All #747 probe artifacts (four scratch bases — `scratch/747-base` for the ordinary-merge pair plus `scratch/747{a,b,c}-base` for the three force-merge arms — each with its own protection rule and head branch, so four rules and eight branches, and PRs #863-#866) deleted and confirmed gone; `origin/main` head unchanged at `b16ec15d6` throughout, and `main`''s own rule was never PATCHed.'
---
**Why a whitelist was the wrong shape.** #743 proposed "a push whitelist on `main` (or disable direct
@@ -63,8 +63,21 @@ forced one. `main` carries the field `true`, which is what closes the force path
differs from row A's only in `enable_bypass_allowlist`, and its PR merged just the same — so enabling it did not substitute
for `block_admin_merge_override`. A NON-empty username list was not tested, and this cannot distinguish
"the allowlist does not govern admins" from "an empty list is treated as unconfigured". Enough to
refuse the swap; not enough to describe the field's semantics. The claim that it postdates 1.25.4 comes
from #747's issue body, not from a probe here.
refuse the swap; not enough to describe the field's semantics.
**That `enable_bypass_allowlist` postdates 1.25.4 was an INHERITED claim and is now a checked one
(#869, 2026-09-02).** It came from #747's issue body rather than from any probe, which is the shape
this repo keeps getting burned by — a claim whose only provenance is another artifact's prose. Read
off `modules/structs/repo_branch.go` at both tags: `EnableBypassAllowlist` does not occur anywhere in
the file at `v1.25.4` and is present at `v1.27.1`, where this instance also returns
`enable_bypass_allowlist` in a live branch-protection read. So the claim holds, and the reason it is
believed is now the source rather than a sentence someone typed into an issue.
Note the neighbouring field does NOT share that history, which is worth stating because the paragraph
above could invite the inference: `BlockAdminMergeOverride` / `block_admin_merge_override` is present
at `v1.25.4` too — in all three structs (`BranchProtection`, `CreateBranchProtectionOption`,
`EditBranchProtectionOption`). It is not a 1.27 addition, and nothing here should be read as saying it
is. What was new in this repo was our USE of it, not the field.
**Trap: `mergeable` does not answer the gate question.** On PR #863 — row 1, the ordinary-merge arm —
the API reported `mergeable: true` at the moment that merge was being refused 405, so there it did not
@@ -96,11 +109,13 @@ mirror (not applicable: `fork: false`, `mirror: false`). Merge remains the one i
**Why the release cut does not deadlock.** #743 flagged that the tag path had to keep working, and
#719 documents H11 blocking a tag-only push on every release cut. Branch protection is scoped to
`refs/heads/main`; tags are governed by an entirely separate mechanism, and `tag_protections` on this
repo is empty, so tag pushes are unrestricted by anything except ordinary write permission. Demonstrated
rather than assumed: from one worktree, the branch push to `main` was refused and a tag push succeeded.
Do not conflate the two mechanisms — disabling branch push says nothing about tags, and a future
tag-protection rule would not inherit from this one.
`refs/heads/main`; tags are governed by an entirely separate mechanism. `tag_protections` was empty
when this record was written (measured 2026-08-24), so tag pushes were unrestricted by anything except
ordinary write permission. Demonstrated rather than assumed: from one worktree, the branch push to
`main` was refused and a tag push succeeded. Do not conflate the two mechanisms — disabling branch
push says nothing about tags, and the tag-protection rule #885 later added
(`release.tag-protection-v-star`, `v*` whitelisted to `timothy`) does not inherit from this one in
either direction.
**The `docker-build.yml` `persist-credentials` question (#743's fourth box), since resolved in #746.**
Head-resolved jobs kept a write-capable credential in `.git/config` because their `actions/checkout`
@@ -0,0 +1,34 @@
---
key: release.tag-protection-v-star
title: '2026-09-04 — `v*` tag pushes are whitelisted to the release operator, closing the tag-push route against the bot (#885)'
status: active
since: '2026-09-04'
supersedes: none
superseded-by: none
rule: 'The repository carries one tag-protection rule: `name_pattern: v*`, `whitelist_usernames: ["timothy"]`. This matters because `docker-build.yml` triggers on `push.tags: [v*]` and a `v*` tag push BUILDS AND PUBLISHES the `:prod` image — the tag the `jazz-media` Komodo stack follows. `release.main-direct-push-disabled` closed every route to `main`, and left this one open: a tag push is a separate mechanism that branch protection does not govern, so it was the cheapest remaining single act that reaches production. It buys NOTHING against the admin actor (`timothy` is on the whitelist, and no self-administered control bounds an owner) and everything it buys is against `renovate`, the repo''s other write-capable account (`permission: write`, `is_admin: false` from `GET /repos/timothy/ersatztv/collaborators/renovate/permission`, measured in #853 2026-08-30 and re-read 2026-09-04): the H10 verdict gate and the Renovate exemption rule bound what that bot can get MERGED, and nothing bounded what it could TAG. The rule''s failure mode is a BROKEN RELEASE CUT, so it is verified in both directions before it is trusted: the negative half is done and recorded below; the POSITIVE half — a real `v*` release-cut tag push by `timothy` still succeeding — is DEFERRED to the operator''s next real cut, because pushing a `v*` tag from any working session publishes `:prod` and a release is the operator''s act, not a verification step. Treat that as OPEN until a cut lands. There is no committed mirror of this rule: it lives in Gitea, and this record''s `mechanics:` carries the exact call and the read-back, the same shape `release.main-direct-push-disabled` uses.'
signals: 'tag_protections empty, v* tag push publishes prod, release cut blocked, renovate can push a tag, tag protection whitelist, release operator whitelist, prod image published by tag, deferred positive verification · paths: `.gitea/workflows/docker-build.yml` · issues: #885, #853, #743, #698, #742'
mechanics: 'Applied 2026-09-04 with `POST /api/v1/repos/timothy/ersatztv/tag_protections` body `{"name_pattern": "v*", "whitelist_usernames": ["timothy"]}`, which returned `{"id": 1, "name_pattern": "v*", "whitelist_usernames": ["timothy"], "whitelist_teams": [], "created_at": "2026-09-04T23:18:50+02:00"}`. Read back the same day with `GET .../tag_protections` -> the one rule, fields as echoed. It is reversible with `DELETE .../tag_protections/1`. NOTE THE ROUTE NAME: `/repos/{owner}/{repo}/tags/protection` is NOT it — measured 2026-09-04, that path resolves `protection` as a TAG NAME and answers 404 `{"message": "tag doesn''t exist: protection"}`. Before this the endpoint returned `[]` (measured #743 2026-08-24 and again 2026-09-04). NEGATIVE VERIFICATION available from this session and performed: a NON-`v*` tag (`probe-885-tagpush`) pushed by `timothy` succeeded and was deleted, confirmed gone by `git ls-remote --tags` — so tag pushes still work at all and the rule is not mis-scoped to `*`. NOT VERIFIED, and it cannot be from a working session: that a real `v*` release-cut push still succeeds, and that a push by `renovate` is refused (only the `timothy` credential is available here). The first is the operator''s next cut; the second would require the bot''s credential.'
---
**What this closes.** `release.main-direct-push-disabled` made every change to `main` go through a PR
with a sha-bound review verdict, and said in the same breath that "tag pushes are unaffected
(separate mechanism)". That sentence was accurate and was also the gap: `docker-build.yml` publishes
`:prod` on a `v*` tag, so one tag push reaches the image prod's stack follows, with no PR, no review
verdict and no merge gate anywhere in the path. `tag_protections` was empty, so nothing stood in it.
**What it does not close, said plainly.** Against `timothy` it is decoration: the whitelist contains
that account, and an owner can delete the rule with one API call anyway. No self-administered control
is a boundary against the actor who administers it. The rule is worth having because the actor set
has a *second* member — `renovate` holds repository write, opens PRs on its own, and is bounded on
the merge path by `review-verdict/h10` and the exemption rule (#698, #742, #845) while being
completely unbounded on the tag path. This makes the two paths agree.
**Why it was split out of #853 rather than bundled.** Its failure mode is not a leaked credential, it
is a release cut that will not push — a failure that surfaces at the worst moment, when someone is
trying to ship. That earns its own change and its own verification, which is why the positive half is
recorded above as deferred rather than quietly assumed. A rule whose only failure mode is untested is
a rule that gets deleted in a hurry by whoever hits it first.
**How to unblock a release cut that this rule breaks.** `DELETE /api/v1/repos/timothy/ersatztv/tag_protections/1`,
push the tag, re-`POST` the rule. Prefer adding the pushing account to `whitelist_usernames` with a
`PATCH` if the cut is moving to a different operator, so the protection is never left off.
@@ -0,0 +1,100 @@
---
key: spa.dismissible-write-failure-reporting
title: '2026-08-29 — A write failure reports to a surface that OUTLIVES the dismissible surface that started it (#830)'
status: active
since: '2026-08-29'
supersedes: none
superseded-by: none
rule: 'A component that starts an async WRITE from inside a dismissible surface (`Dialog`, `SlideOver`, `ConfirmDialog`) must report failure through `useDismissSafeError` (`web/src/hooks.ts`), which renders the message INLINE while the surface is mounted and hands it to a caller-supplied `onFailed` once the surface is gone. Guarding the `setError` with an is-mounted check and stopping there is NOT sufficient: it converts a silent data-loss risk into a silent NO-OP the user reads as success. All three primitives dismiss through Escape and a backdrop/scrim click (`useOverlayBehavior`) plus a header close button, and NONE of those consult a busy flag — disabling the footer Cancel button, which every one of these dialogs does, looks like it closes the hole and does not. Report the OUTCOME past dismissal in both directions — a success callback gated on an is-mounted check makes a completed write silent too (measured on `AddToCollectionDialog`, #877) — but gate the DISMISS request (`onClose`) separately, because closing a surface that is no longer yours closes whatever replaced it. The surviving surface belongs to the PARENT, so the mechanism is a PROP CONTRACT rather than a rendering decision; as of 2026-08-29 exactly ONE screen is wired (`CollectionsScreen`, into its screen-level `role="alert"` banner), and the `notice`+`Toast` pair on `MediaBrowseScreen`/`SearchScreen` is a CANDIDATE second surface, not a wired one; there is no global toast host in this SPA and this decision does not add one. The reporting prop is REQUIRED where the host has a surface (`AddItemsDialog.onAddFailed`), so a failure cannot be dropped by forgetting to wire it; it is optional only where some host genuinely has nowhere to report, and there an omitted callback drops the failure exactly as before — a KNOWN remaining gap, not a claim of coverage.'
signals: 'failed add silently swallowed · dialog closed mid-request · Escape backdrop close button ignore adding flag · error banner unmounts with the dialog · useDismissSafeError inline vs onFailed · success outlives dismissal but failure does not · onDone has no failure counterpart · paths: `web/src/hooks.ts`, `web/src/screens/CollectionsScreen.tsx`, `web/src/media/addTo/`, `web/src/components/overlay.tsx` · issues: #830, #877, #740, #685'
mechanics: 'Pinned five ways. (1) `web/src/hooks.test.tsx` → "useDismissSafeError (#830)" pins both branches directly: mounted reports INLINE and does not call `onFailed`, unmounted calls `onFailed`, and the report goes through the LATEST callback rather than the one captured on first render. (2) `CollectionsScreen.test.tsx` → "reports a failed add on the screen when the dialog was dismissed before the request settled (#830)" drives the whole path — it parks the POST in flight, dismisses via Escape, then settles the request and asserts the message is on the screen and NOT inside a dialog. Executed: deleting `reportRef.current(message)` alone reddens it on `Unable to find an element with the text: Request failed with status 500`. That one clause is shared, so the same mutation reddens THREE tests (this one plus the two divert tests in (1)) — expect three reds, not one, when re-running it. (3) `CollectionsScreen.test.tsx` → "a late SUCCESS does not close the dialog the user reopened after dismissing (#830)" pins the OTHER half of the split: it dismisses mid-request, reopens the picker, then settles the request 204, and asserts the reopened dialog is still there — with an anti-vacuity check that the late response was actually processed (`onAdded` is `load`, so a second GET of the items endpoint must have happened), because otherwise "the dialog is still open" holds trivially. Executed: deleting the `if (mountedRef.current)` around `onClose()` reddens it alone. The POSITIVE direction is pinned separately, in the Song add test, because the negative one does not cover it: review measured that deleting the `onClose()` call ENTIRELY — so an ordinary successful add never closes the picker — left the whole suite green, so that test now asserts the dialog closes. (4) `CollectionsScreen.guards.test.tsx` counts is-mounted reads and moved from 2 to 1 when the catch''s guard migrated into the hook; its `...actual` module mock cannot see the hook''s internal `useIsMountedRef()`, which is why (1) exists. Executed: removing the surviving `finally` guard takes that count to 0 and reddens. (5) `CollectionsScreen.test.tsx` → "renders a failed add INSIDE the dialog while it is still open (#830)" pins which ARM the call site reaches, which (1) cannot: (1) proves the hook HAS an inline branch, not that this screen calls into it. It fails the POST with the dialog still up and asserts the message sits inside `[role="dialog"]` and appears exactly once in the tree. Executed 2026-09-04: swapping `reportFailure(...)` for `onAddFailed(...)` in the catch — the divert-while-open shape this record names as its own defect — reddens that test and NOTHING else (1 failed / 490 passed across the 42 `src/screens` files).'
---
**This record applies to ONE site, and the reason the rest were dropped is the useful part.**
An earlier draft claimed SUCCESS already outlives dismissal "at every one of these sites", reasoning
from `AddItemsDialog` — which does report success past dismissal, and says so in a comment — and
generalising to the four `web/src/media/addTo/` dialogs without reading them. Adversarial review
probed `AddToCollectionDialog` and MEASURED `onAdded` called 0 times after dismissal; the other
three carry a visibly identical gate (read, not probed). So those four gate BOTH halves of the
outcome behind their own `activeRef`, and dismiss-then-succeed is as silent there as
dismiss-then-fail.
Extending the mechanism to them was attempted and WITHDRAWN, which is why they are #877 and not this
record. Removing that gate reports success correctly but also un-gates `onClose()`, and the two mean
different things: `onAdded` is "tell the parent what happened", `onClose` is "close me" — addressed
to a surface that no longer exists. Measured against the real `AddToMenu`: a late success from a
DISMISSED dialog closed a dialog the user had since reopened. Read, not measured: the success
handlers on `SearchScreen` / `MediaBrowseScreen` also call `clearSelection()` unconditionally, so
the same late success would wipe a multi-select the user had rebuilt. Un-gating both is wrong,
gating both is wrong, and gating only `onClose` still
needs the parents to stop nulling the dialog themselves — three coupled edits across five files,
plus an unresolved question about whether `clearSelection()` should fire for a write the user walked
away from. That is a design decision, not a bug fix, so it goes to #877 with the measurements
attached rather than riding along here.
`AddToMenu` also has `onDone` and no failure counterpart at all, which is why a failed "Add to
collection" from a media card reports nowhere. Same issue.
**The outcome and the dismissal are different callbacks, and the exemplar proves it.** Review
measured the cost of conflating them twice: gating both makes a completed write silent, un-gating
both makes a late success close the dialog the user reopened to retry. `AddItemsDialog` now does
each correctly — `onAdded()` unguarded, because it reloads the parent's list and that must survive;
`onClose()` guarded, because after dismissal it is `setPickerOpen(false)` aimed at whatever the user
opened next. That split is only this simple where the parent has no competing closer: here
`onAdded` is `load`, which never touches `pickerOpen`. In the `media/addTo/` layer
`AddToMenu.handleAdded` closes the dialog itself, so the same one-line gate is NOT sufficient there
— measured — which is the concrete reason that half is #877 and not this record.
**Why the inline branch is kept rather than always reporting to the parent.** While the dialog is
up, inline is the better surface: it keeps the user's selections and the context they are looking
at. Diverting to a parent banner in that case would be its own defect — the message would surface
somewhere else while the dialog the user is staring at stays blank. Behind the dialog, in fact:
the panel is a `createPortal` with `aria-modal="true"`, so the screen banner is hidden from AT and
covered for everyone else. Pinned rather than argued — mechanics (5).
**Why not simply gate dismissal on the busy flag.** That was considered and rejected: gating
Escape/backdrop/close on `adding` traps the user behind an in-flight request with no cancel path,
which is exactly why `Dialog` does not do it by default. The request is also genuinely still
running — cancelling the UI would not cancel the write.
**#740 guarded this and did not fix it, correctly.** That issue added the is-mounted guard so the
`setError` could not fire on an unmounted tree. That is right and stays. Its scope was the async
guards, and it deliberately did not change the UX — which left the guard making the drop
*deliberate* rather than accidental. A deliberate drop is still a user who believes twelve items
were added when none were, and that is the half this record closes.
**Two limits of the mechanism, recorded rather than left to be rediscovered.** (1) `useIsMountedRef`
clears its flag in a PASSIVE effect cleanup, so there is a narrow window in which the DOM node is
detached but the flag still reads true — the message then renders inline into a dead tree instead of
diverting. `useLayoutEffect` would close it deterministically, but that hook is shared by every
async caller in the SPA (#578) and changing its timing is not something to do inside a bug fix;
tracked with the rest in #877. (2) A limit the SECOND surface will have, stated now so it is
not discovered when it is wired: `CollectionsScreen` reports into `role="alert"`, but the candidate
surface on `MediaBrowseScreen`/`SearchScreen` is `Toast`, which is `role="status"` (polite) and a
single last-writer-wins slot — so a failure diverted there would be announced less assertively and
could be overwritten by a later success. Nothing diverts to those screens today; they receive no
reporting callback.
**The hook has exactly one consumer, and that is worth saying.** `useDismissSafeError` is a shared
module with a single call site today. It earns the shape three ways: it is unit-tested directly in
`hooks.test.tsx`, in a way that would be awkward to keep if the hook were inlined into
`CollectionsScreen`; §3c prescribes it for the next site; and #877 is a queued second consumer. The
caveat:
#877 may conclude that A2's right answer is a shared reporting SURFACE rather than a per-site prop,
in which case this hook may never gain that second consumer and stays a one-off with a convention
section attached. That is an accepted risk, not an oversight.
**Scope, stated rather than implied.** A sweep of all 68 `Dialog`/`ConfirmDialog`/`SlideOver` call
sites (2026-08-29; 67 caller-owned plus `ConfirmDialog`'s own internal `<Dialog`) found three
shapes: (A1) the surface hard-unmounts and takes its own error state
with it — the shape fixed here, at `AddItemsDialog` ONLY — the four `web/src/media/addTo/` dialogs
are the same shape and are NOT fixed (#877, see above);
(A2) the error state lives in a parent that never unmounts, but the JSX rendering it is nested
inside the same `open`/`target` condition dismissal just cleared, so it has no DOM node to render
into — the majority pattern, mostly delete-confirm flows; (B) a genuinely surviving screen-level
banner, which `DecoTemplatesScreen` and `BlocksScreen` already have. Only ONE A1 site is fixed here.
A2 is
LEFT OPEN deliberately: it is a larger, mostly-delete-confirm population whose right answer may be
a shared reporting surface rather than a per-site prop, and folding it in would have made this PR a
rewrite of twenty screens. It is tracked in #877, which carries the sweep's verified-vs-inferred
split forward, rather than being recorded as done here.
@@ -0,0 +1,69 @@
---
key: testing.guard-pins-the-artifact-not-a-shape
title: '2026-09-05 — a predicate over an artifact whose grammar it does not implement pins the artifact WHOLE; matching a shape inside it is an exception that must argue for itself (#901)'
status: active
since: '2026-09-05'
supersedes: none
superseded-by: none
rule: 'A predicate that decides something by matching a SHAPE inside an artifact whose GRAMMAR it does not implement pins the artifact WHOLE instead — the whole file, or a whole named span, compared for equality under a STATED normalisation. The boundary is the grammar and never the file type: shell source, a workflow `run:` body, TOML, YAML, a jq expression and JSON5 all qualify, and a shape matched over the OUTPUT of a real parser for that grammar does not. Extracting a `run:` value with a real YAML parser and then matching shell inside it IS in the class, because the shell is the unparsed half. THE PIN IS THE DEFAULT FOR A NEW PREDICATE; REJECTED is the alternative of writing the shape-matcher and pinning only once it has been defeated. The defeat a remedy policy waits for is a false GREEN, so it is found by a reviewer or by an incident and never by the guard: "not defeated yet" is a statement about who has looked, not about the input space. A SHAPE-MATCHER IS THE EXCEPTION, and its argument carries FOUR things, written where the predicate is and re-read whenever a clause of it is widened. (1) The GRAMMAR named, and the parser that implements it — if the shape is matched over a real parse there is no exception to write. (2) The INPUT SPACE as a CLOSED enumeration with the reason it is closed: a generator whose output form is fixed, or a canonicaliser the guard itself runs. A list of the spellings the author thought of is not that reason, and is the thing that was defeated in all three sequences below. (3) The FAILURE DIRECTION measured rather than asserted: each defeat the matcher claims to catch is a DECLARED, executed mutation under `testing.mutation-claims-are-executed`, because a shape-matcher''s miss is a false green and an unrun claim about a fail direction is not evidence. (4) WHAT IT BUYS over the pin, priced in a cost the pin actually charges — the artifact is edited routinely or written by tooling, so a pin would redden as background noise and train its readers to ignore it; or the artifact''s population is DISCOVERED rather than fixed, so there is nothing to pin. Fewer than four is a refusal, and neither elegance nor an unwillingness to update a constant is one of the four. TWO OTHER BARS ARE REJECTED for this exception: a NUMERIC one ("survives N adversarial spellings"), which measures the reviewer''s imagination rather than the input space and would have licensed the parser above through most of those nine defects; and a REVIEWER SIGN-OFF, which makes the exception depend on the very signal the threshold below says arrives late. THE THRESHOLD for withdrawing a shape-matcher already shipped is not a count of spellings: it is the moment the NEXT spelling is found by the REVIEWER rather than by the author, which is evidence that the author''s model of the input space is not the input space. TWO RIDERS, both measured. A pin assumes it is pinning the artifact that still DECIDES — ask what else could decide this and whether the pin would still match: another FILE that outranks it, another OCCURRENCE in the same file, another WORKFLOW, or a HOOK the pinned command invokes. And WIDENING A CLAUSE RETIRES THE PROOFS CALIBRATED AGAINST THE NARROW ONE: a canary asserting that a finer mutation still SURVIVES has as its precondition that the guard is deliberately coarse there, so widening the main clause turns the canary into a tautology, which reads exactly like a passing proof — re-derive every proof calibrated against the narrow clause in the same commit that widens it. THE COST IS STATED RATHER THAN DISCOVERED LATER: any edit to a pinned artifact reddens, and the pin is updated deliberately in the same commit, with a reason. That red IS the review trigger the pin exists to be. A pin also declares its NORMALISATION and what the normalisation cannot see, because "pinned whole" invites a reader to assume byte equality.'
signals: 'pin the artifact whole · shape-matcher is the exception that argues for itself · the boundary is the grammar not the file type · predicate does not implement the grammar it parses · defeated by the next spelling · the reviewer found the next spelling · false RED versus false GREEN · nine defects one mechanism · seven respellings of a partial config match · three successive lexical rules over a hook preamble · the four-part exception argument: grammar and parser, closed input space, measured fail direction, what it buys · authority moves where the pin is not looking · an outranking config file · a decoy occurrence · an npm lifecycle hook · widening a clause retires its own canary · a survived-clause canary becomes a tautology · a pin declares its normalisation · instances: `ci.image-build-delegates-the-spa-suite`, `process.hook-resolves-inputs-from-repo-root` · paths: `scripts/tests/test_image_build_delegates_the_spa_suite.py`, `scripts/tests/test_hook_fire_log.py`, `docs/guard-inventory.md`, `docs/defect-shapes-773.md` · issues: #901, #887, #891, #774, #790, #881, #773'
mechanics: 'Two worked pins. `scripts/tests/test_image_build_delegates_the_spa_suite.py` pins command TEXT (`PINNED_STAGE_COMMANDS`), `web/package.json`''s script map (`PINNED_PACKAGE_SCRIPTS`) and `web/vite.config.ts` entire (`PINNED_VITE_CONFIG`), the last two after a SELECTION and then a partial match were defeated. The three normalise DIFFERENTLY, so a residual read off one of them NEED NOT transfer to the others — and one of them does transfer, because two share a primitive: the stage commands are compared after line continuations are joined and whitespace within each command is collapsed (`_normalise`), so a reflow is free and a line boundary is not; the script map is dict equality over parsed JSON, so the file''s own formatting and key ORDER are free while every script body is exact; and of the three only `PINNED_VITE_CONFIG` runs through `_normalise_lines`, which additionally drops blank lines. `_normalise_lines` IS `_normalise` applied per line, so the whitespace collapse — including inside a QUOTED STRING, where it can change meaning — belongs to both TEXT pins and not to the vite one alone; of the two only the vite test states it, in place of claiming byte equality, which is what the rule above asks of a pin. `scripts/tests/test_hook_fire_log.py` pins the hook preamble''s two lines byte for byte (`CANONICAL_SINK_ASSIGNMENT`, `CANONICAL_SINK_SOURCE`) — but its normalisation is a SELECTION, and the rule above obliges this record to say what that selection cannot see, because the pin itself does not. The compared set is the lines that contain the literal `ETV_HOOK_FIRE_LIB` and are not comment-led, so the pin reaches exactly the two preamble lines and nothing else in the file: a later reassignment of that variable which never spells the literal — one composed at runtime and `eval`ed, say — is outside the selection, and therefore outside the pin, however the checker then behaves. Byte-identity WITHIN a selection is not byte-identity over the file — the pin''s reach ends where its selector does. That is stated here as a structural fact about the selector and NOT as a measured checker outcome, because an outcome claim about a mutation is a declared, harness-executed `CLAIMS` entry under `testing.mutation-claims-are-executed` or it is not written, wherever it is written — this record included. The residual is #891 code and predates this record; stating it is this record''s own rule applied to one of its own worked pins. Each of the three image-build pins faults with a request for the reason in the same commit, and two of them name the CONSTANT to update (`PINNED_STAGE_COMMANDS`, `PINNED_VITE_CONFIG`) while the script-map fault names the FILE and prints both maps. The hook-preamble fault asks for neither: it reports the divergent lines, since the two canonical strings ARE what a deliberate change edits.'
---
**The class, and what approximating it costs.** A predicate over an artifact with a grammar is a partial
reimplementation of that grammar, defeated not by a clever attacker but by an ordinary respelling — and each defeat
looks local, one more case to handle, so the sequence stops only when someone stops it. Measured 2026-08-30 across
two unrelated subjects: a guard parsing shell text to decide whether a command runs the SPA suite was wrong **nine**
ways from one mechanism (#887), its replacement — a partial match of `web/vite.config.ts`**seven** further ways,
and over a hook preamble **three** successive lexical rules each fell to the next shape (#891). Every fix was locally
correct; none of the three sequences converged. All three ended in the same move, and what the move buys is a fail
DIRECTION: a pin's failure direction is a false RED, which a human reads in the diff; a shape-matcher's is a false
GREEN, which nobody reads. Which edits a pin accepts is its NORMALISATION's business, so every pin — whole-file or
selected — declares that normalisation and what it cannot see, which is what `mechanics:` does for both worked pins.
**Why the pin is the DEFAULT and not the remedy — the first thing #901 left open.** The rejected alternative is the
tempting one: write the shape-matcher, and pin only once it has been defeated. It is rejected because of what the
trigger event is made of. A shape-matcher's failure is a false GREEN, which the guard by construction does not
report; the defeat therefore arrives from a reviewer or from an incident, so its absence measures who has looked
rather than what the input can be. Both measurements above are that asymmetry: neither predicate withdrew because a
count got high, and both had the precedent available for every round. The second reason is that switching later
costs more than starting there: a withdrawal costs the rounds already spent AND the proofs calibrated against the
narrow clause (rider 2), so "pin it when it breaks" understates its own bill.
**What a shape-matcher's exception argument must contain — the second thing #901 left open.** The four requirements
are chosen so a reviewer can REFUSE one: each is a thing the author either wrote or did not. Two alternatives are
rejected. A NUMERIC bar ("survives N adversarial spellings") measures the reviewer's imagination, not the input
space, and rises while the defect stands — it would have licensed #887's parser through most of those nine defects.
A REVIEWER SIGN-OFF bar is worse: it makes the exception depend on the very signal the threshold says arrives late.
What survives is requirement 2, the only candidate that is a claim about the ARTIFACT rather than about the effort
spent on it, and requirement 3, which forces the fail-direction claim to be executed, not reasoned.
**Rider 1 — a pin assumes it pins the artifact that still DECIDES.** This is where the pinned form fails, silently,
so it is the question to ask of every new pin. In #887 every hole found after the first withdrawal was authority
moving where the pin was not looking: another FILE that OUTRANKS the pinned one (the vite config ordering, a
VERSION-bound reading, is dated in `docs/guard-inventory.md`'s `test_image_build_delegates_the_spa_suite.py` row
rather than copied here), another OCCURRENCE in the same file, another WORKFLOW (a `needs:` edge naming a different
job called `test`), a HOOK the pinned command invokes (a vite plugin's `buildStart`, an npm `prebuild`/`preinstall`
script). A pin turns the SPELLING problem into its normalisation's and reduces the MEANING problem to a list that can
be worked through; a pin sold as immune to both stops being re-examined.
**Rider 2 — widening a clause can retire its own evidence.** A canary asserting that a finer mutation still SURVIVES
is calibrated against a deliberately coarse clause. Widen that clause and the canary does not go red, it goes
vacuous — indistinguishable from a passing proof at the point of reading — so the widening commit re-derives every
proof calibrated against the narrow form. Same shape as the `DETECTOR` grade in
`testing.mutation-claims-are-executed`: a surviving mutation is worth something only while the precondition that
made it survive still holds.
**Where this sits against its neighbours, so it is not read as a duplicate.** The two incidents keep their own
records — `ci.image-build-delegates-the-spa-suite` and `process.hook-resolves-inputs-from-repo-root` — and this is
the class they instantiate, while `testing.guard-derives-population-from-source` answers the case where the
authoritative source is MISSING (create one, never approximate it with a predicate over text); this one, the case
where the artifact IS present and authoritative and its GRAMMAR is what gets approximated.
`docs/defect-shapes-773.md` does NOT carry this rule: its §3.6 partition lists the nearest class, string-predicate
churn, as `no detector proposed`, and §3.7 argues that class away as a cross-cutting property. Only §4's
meta-finding supports the FORM — a class-level rule beats one record per instance — so the rule is new here,
resolvable BY TOPIC where `docs/guard-inventory.md` carries its precedent per incident, findable only inside one.
**What this does not cover.** Predicates over structured input a real parser produces — an AST, the git index,
parsed workflow YAML consumed as data — are not approximations and keep their shape matching. And a pin is not
mandatory for an artifact edited continuously by many hands: that is requirement 4's own escape hatch, and a pin
reddening as background noise is a guard nobody reads.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,77 @@
---
key: testing.scripted-engine-in-process-net
title: '2026-09-05 — Scripted playout is covered in-process, engine plus controller adapter; the Cli.Wrap process and the HTTP transport are permanently out of the automated suite (#563)'
status: active
since: '2026-09-05'
supersedes: testing.scripted-playout-golden-deferred@2026-07-22
superseded-by: none
rule: 'Scripted playout is characterized at two in-process levels and no further. (1) `SchedulingEngineTests` drives the engine build API directly with substituted repositories — the same setup order `ScriptedPlayoutBuilder` uses (`WithPlayoutId`/`WithMode`/`WithSeed`/`BuildBetween`/`WithReferenceData`/`RestoreOrReset`, and `WithReferenceData` MUST precede the last two or `PlayoutReferenceData.PlayoutHistory` NREs). (2) `ScriptedScheduleControllerTests` replays a committed script fixture (`ErsatzTV.Tests/Controllers/Fixtures/scripted-build.json`) through the REAL `ScriptedScheduleController` + `ScriptedPlayoutBuilderService.MockSession` + `SchedulingEngine` and pins the resulting `PlayoutItem`s as a snapshot in the golden line format — so exactly ONE action-to-engine mapping exists in the repo, the production one. Fixture bodies in that replay are deserialized with `ApiJsonSettings`, the configuration `Startup` applies to `AddNewtonsoftJson`, invoked from the same function rather than mirrored — and two tests WITNESS that choice instead of asserting it, each binding a body that separates the production binder from one plausible replacement: `Production_Body_Binder_Ignores_Required_Members` omits a C# `required` member, which Newtonsoft defaults and System.Text.Json rejects; `Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null` sends an explicit `"order": null`, which `NullValueHandling.Ignore` drops and a bare `JsonSerializerSettings` writes through into a 400. What is shared is the CONFIGURATION, never MVC''s settings OBJECT: `ApiJsonSettings.Create()` applies it to a bare settings object, so MVC''s `MaxDepth` of 32 and its `ProblemDetails`/`ValidationProblemDetails` converters are absent (measured 2026-09-05, pinned by `ApiJsonSettingsTests`); neither is reachable from a scripted request body, and no test may generalize from `Create()` to production past that. What no test observes is the `Startup` registration itself, so the extraction REMOVES the duplicate rather than detecting its drift. The `Cli.Wrap` launch of the user-authored program (exit code, timeout, stdout capture), the Kestrel/middleware/`ApiAuthorizationFilter` transport it calls back over, and MVC model binding as a WRAPPER (the input formatter, model validation and the `[ApiController]` automatic 400 either produces before an action runs — the serializer inside that wrapper is covered, the wrapper around it is not) are NOT tested and are not scheduled to be; that residue is named below rather than argued away. Both fixtures must aim every trimming instruction at an instant BETWEEN two content boundaries: a pad or duration that happens to land on one never enters the engine''s trim branch, so that action''s `trim` argument reaches nothing the snapshot can show. The controller is a thin adapter, NOT a 1:1 pass-through: an unparseable playback order is a 400, an unparseable filler kind SILENTLY degrades to `FillerKind.None`, an unknown build id is a 404, and the engine no-progress `InvalidOperationException` is translated to a 400 — each pinned by a test, because a prose claim of equivalence decays.'
signals: 'why is there no scripted golden; scripted integration harness; scripted transport vs engine; SchedulingEngine is in-process testable; Cli.Wrap external process is transport only; shell-out scoped out; MockSession seam; in-process stand-in for a scripted script; engine build API characterization; committed script fixture replay; ScriptedScheduleController is an adapter not a pass-through; scripted filler kind silently falls back to None; scripted playout no-progress halt maps to 400; scripted build 401 blind spot; EnumeratorForContent · paths: `ErsatzTV.Core.Tests/Scheduling/Engine/SchedulingEngineTests.cs`, `ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests.cs`, `ErsatzTV.Tests/Controllers/Fixtures/scripted-build.json`, `ErsatzTV.Tests/Serialization/ApiJsonSettingsTests.cs`, `ErsatzTV/Serialization/ApiJsonSettings.cs`, `ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs`, `ErsatzTV/Controllers/Api/ScriptedScheduleController.cs`, `ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs`, `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs` · issues: #563, #381, #395, #163'
mechanics: docs/testing.md → "Scripted playout coverage" and "Golden-file nets"
---
**What carries over from the predecessor.** Sequential (YAML) is golden-able and TZ-independent, and
`PlayoutBuildGoldenTests` covers Classic / Block / Sequential. Scripted's *end-to-end pipeline* stays out
of that net: `ScriptedPlayoutBuilder` builds nothing itself, it `Cli.Wrap`-executes a user-authored
program, hands it `http://localhost:{Settings.UiPort}` plus a build id, and reads the result straight off
the in-process engine afterwards. #395's `ContentEnumeratorBuilderTests` remains the direct net over the
enumerator-construction switch Scripted and Sequential share.
**What changed.** The predecessor deferred the whole harness to #563 and described the controller as a
"1:1 pass-through". Both are now settled: the coverage exists in-process, and the pass-through wording was
wrong — the four adapter mappings above are real behaviour that only a test can hold.
**Why not the full Kestrel + `Cli.Wrap` harness.** Four measured obstacles, not a preference:
1. *The production transport is not "a controller behind Kestrel".* `Startup` 404s any request whose Host
is not `localhost` and whose `Connection.LocalPort != Settings.UiPort` (a mutable process-wide static),
and `ApiAuthorizationFilter` fail-closes every mutating verb. A hand-rolled minimal host would test a
transport the product does not have; the real `Startup` drags in the whole DI graph (TvContext, ffmpeg
workers, search index, background services), which is what #381 deferred in the first place.
2. *The external program is user-authored by definition.* `Playout.ScheduleFile` is an arbitrary command
line, so any script we commit is a stand-in we wrote either way. The fork's own driver,
`scripts/scripted-schedules/entrypoint.py`, imports `etv_client`, a generated package that exists only
in the deploy environment, so it cannot run in the .NET test job without adding a client-generation step.
3. *It would put an interpreter and process spawning inside the pure in-memory .NET suite*, with the 30s
`Cli.Wrap` timeout nested inside CI's `--blame-hang-timeout 2m` — a new flake class on a shared runner.
4. *The stand-in loses nothing the harness would have added at the engine level.* Replaying the fixture
through the real controller and the real service keeps a single action-to-engine mapping under test;
what is absent is the two transport hops plus MVC binding as a wrapper, and the wrapper is the shallow
half — the serializer inside it is configured by `ApiJsonSettings`, the production configuration (not
MVC's settings *object*, whose remaining defaults the rule enumerates).
**The residue, named.** Uncovered: process launch, exit code and timeout handling; Kestrel plus the
forwarded-headers / compression / SPA-hosting middleware; MVC model binding as a *wrapper* — the input
formatter (content-type selection, malformed-body handling), model validation (a non-nullable reference
type picks up an implicit required check there) and the `[ApiController]` automatic 400 either produces
before an action runs, since every test hands an action an already-bound object; and
`ApiAuthorizationFilter`. The *serializer* inside that wrapper is deliberately not on this list — it is
`ApiJsonSettings` itself, and two tests hold the two ways it can be replaced. `ApiJsonSettings` exists so
that binder is *defined* once; it is not a drift **detector**, and the branch may not claim one, because
nothing observes `Startup.ConfigureServices` and a byte-equal hand-copy of `Apply` is indistinguishable
by construction. What the suites do separate is a mirror that has *lost* something. The mechanics doc
tabulates which suite separates which half of the configuration, and what neither reaches.
**Where the calibrating measurements are, and why not here.** `testing.mutation-claims-are-executed`
admits a sentence asserting that a specific mutation reddens — or does not redden — a named test only as
a `CLAIMS` entry in `scripts/tests/mutation_manifest.py`, executed every run; otherwise it is not
written. That harness resolves a proof to `scripts/tests/<node id>` and runs pytest, so an NUnit proof
cannot be declared in it and this coverage is entirely NUnit. The binder and `trim` mutants run while
building these tests therefore stay in ersatztv#563 — its pull request and the commit messages that
ran them — dated. Durable prose carries
the mechanism each test is built on instead — which serializer difference, which engine branch — because
that is re-checkable by reading the code rather than by trusting a remembered outcome.
`ApiAuthorizationFilter` on that list is not hypothetical — `ScriptedScheduleController` carries no
`[SkipApiAuthorization]` while
`ScriptedPlayoutBuilder` passes the script only host, build id and mode, with no `X-Api-Key`, and
`entrypoint.py` builds its client with no credential. Whether a scripted build therefore 401s on its first
POST in this fork is unverified against a live instance and is tracked in ersatztv#913; it is recorded here
as the concrete shape of what an engine-level net cannot see, so the scope-out is not read as a claim of
equivalence.
**Determinism constraints on the fixtures.** Both use Chronological order and only instant-preserving
instructions (`AddCount`, `AddAll`, `AddDuration`, `PadUntilExact`). `WaitUntil(TimeOnly)` and
`PadUntil(string)` read the LOCAL day and time-of-day, and `PadToNext` becomes offset-sensitive once a
call has localized `CurrentTime`, so introducing any of them — or a `shuffle` order — reopens the
timezone and seed question the fixtures were built to close.
+1 -1
View File
@@ -96,7 +96,7 @@ another doc or an old issue comment should land here and then follow the link.
- 2026-07-21 — An infra-shaped red under host load is not a code failure (#542) — [`ci.infra-shaped-red-under-load`](records/ci/infra-shaped-red-under-load.md)
- 2026-07-21 — Arm the CI monitor at PR-open, via the commit-status endpoint (#542) — [`ci.monitor-armed-at-pr-open`](records/ci/monitor-armed-at-pr-open.md)
- 2026-07-21 — BOM/format pre-push detection: use the `xxd` loop, and run `dotnet format --include` under `bash -c` (#542) — [`process.bom-format-detection-recipe`](records/process/bom-format-detection-recipe.md)
- 2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4; REST route re-probed on 1.27.1 (#542) — [`ci.batch-pushes-no-cancel-route`](records/ci/batch-pushes-no-cancel-route.md)
- 2026-07-21 — Batch your pushes: no agent-side cancel route; REST absence confirmed in the v1.27.1 router source, web UI still 1.25.4-dated (#542, #869) — [`ci.batch-pushes-no-cancel-route`](records/ci/batch-pushes-no-cancel-route.md)
- 2026-07-21 — Before deleting a "workaround", enumerate every behavior it provided (#542) — [`process.enumerate-workaround-behaviors-before-deleting`](records/process/enumerate-workaround-behaviors-before-deleting.md)
- 2026-07-21 — Bound parallel builds by FREE RAM, never regenerate goldens (#542) — [`process.build-concurrency-limits`](records/process/build-concurrency-limits.md)
- 2026-07-21 — Build and verify locally, then trust it; CI confirms (#542) — [`ci.verify-locally-ci-confirms`](records/ci/verify-locally-ci-confirms.md)
+1 -1
View File
@@ -68,7 +68,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
| **Seasonal / date-conditional scheduling** (#73) | Holiday/seasonal channels are **not a separate feature** — they are the existing date predicate on `IAlternateScheduleItem`, implemented by `ProgramScheduleAlternate` (Classic) and `PlayoutTemplate` (Block), evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match by `Index`, catch-all last). **Leaving `StartYear`/`EndYear` empty makes the range repeat every year** — the "set once, works every December" switch; explicit years (required in pairs) mean a one-off window and disable wrap-around detection. Wrap-around (Nov→Feb) and invalid/leap dates (Feb 31) are handled. No *soft* prioritization primitive exists (binary first-match-wins); that ask belongs to #70's weighting work. See `channels.md` → "Recipe: seasonal / holiday programming" and `decisions.md` 2026-07-17. | `IAlternateScheduleItem`, `ProgramScheduleAlternate`, `PlayoutTemplate` | `/app/playouts/{id}/alternate-schedules`, `/app/playouts/{id}/templates` |
| **Playback order** | How a schedule item's source(s) are sequenced (`PlaybackOrder`). Note three that are easily confused: **`Shuffle`** is FisherYates over the flattened items, so airtime is implicitly proportional to collection size (a 200-episode show swamps a 20-episode one). **`ShuffleInOrder`** is a balanced shuffle (keyj) that pads sources to equal length with non-emitting spacers — it plays every item exactly once per cycle, so it prevents *clumping* but leaves airtime proportional to size; it is **not** fair-share. **`WeightedShuffle`** (#70) picks a *source* by smooth weighted round-robin then takes its next item, so each source's `Weight` is its share of airtime — equal weights (the default) mean equal airtime regardless of library size, with small sources looping. Classic engine only; rejected at the write path for playlist/block items. See `decisions.md` 2026-07-17. | `PlaybackOrder`, `MultiCollectionItem.Weight`, `MultiCollectionSmartItem.Weight` | `WeightedShuffle` is offered as a Playback Order **only** on classic schedule items whose source is a MultiCollection (`web/src/schedules/itemRules.ts`, #404); the per-source weights themselves are edited at `/app/multi-collections` |
| **Watermark** | `ChannelWatermark` image overlay; attached at channel, schedule-item, block-item, deco, or playout-item level with position/size/opacity. | `ChannelWatermark`, `DecoWatermark`, `BlockItemWatermark`, `ProgramScheduleItemWatermark` | `/app/watermarks` |
| **Graphics element** | YAML-authored (`Text`/`Image`/`Subtitle`/`Motion`/`Script`) render-engine overlay, distinct from the image-only `ChannelWatermark` system. Attaches via 5 parallel join tables: `PlayoutItemGraphicsElement`, `ProgramScheduleItemGraphicsElement`, `BlockItemGraphicsElement`, `DecoGraphicsElement`, and (#74) **`ChannelGraphicsElement`** — a direct `Channel`-level attachment that did not exist before #74. `GraphicsElementSelector.SelectGraphicsElements` treats channel-level elements as the final fall-through **base layer**: they merge with `Merge`-mode deco elements and per-playout-item elements, but a deco in `Override`/`Disable` mode returns before that fall-through and so **suppresses** the channel overlay (and on a **filler** item, a deco whose graphics-elements section is not set to run during filler — `UseGraphicsElementsDuringFiller` false — clears it too, for `Merge` and `Override` alike); `HttpLiveStreamingDirect` always returns empty (ErsatzTV isn't transcoding, so nothing can be burned in). A built-in seeded text element, `on-now-next.yml` (`GraphicsElementDefaults.OnNowNextFileName`), is written once (`GraphicsElementSeeder.SeedOnNowNext`, guarded by the `graphics.on_now_next_seeded` ConfigElement marker, adopt-not-clobber like the #67 watermark seed) and identified to API clients via a server-derived `GraphicsElementResponseModel.builtIn` flag (path-name comparison, not name matching). Edited per-channel at Channel editor → Branding → "Show On Now / Next overlay". See `decisions.md``graphics.channel-level-attachment` (#74). | `GraphicsElement`, `ChannelGraphicsElement` | `/app/edit-channel/{id}` (Branding tab); YAML files under `GraphicsElementsTextTemplatesFolder` etc. are not directly SPA-edited |
| **Graphics element** | YAML-authored (`Text`/`Image`/`Subtitle`/`Motion`/`Script`) render-engine overlay, distinct from the image-only `ChannelWatermark` system. Attaches via 5 parallel join tables: `PlayoutItemGraphicsElement`, `ProgramScheduleItemGraphicsElement`, `BlockItemGraphicsElement`, `DecoGraphicsElement`, and (#74) **`ChannelGraphicsElement`** — a direct `Channel`-level attachment that did not exist before #74. `GraphicsElementSelector.SelectGraphicsElements` treats channel-level elements as the final fall-through **base layer**: they merge with `Merge`-mode deco elements and per-playout-item elements, but a deco in `Override`/`Disable` mode returns before that fall-through and so **suppresses** the channel overlay (and on a **filler** item, a deco whose graphics-elements section is not set to run during filler — `UseGraphicsElementsDuringFiller` false — clears it too, for `Merge` and `Override` alike); `HttpLiveStreamingDirect` always returns empty (ErsatzTV isn't transcoding, so nothing can be burned in). A built-in seeded text element, `on-now-next.yml` (`GraphicsElementDefaults.OnNowNextFileName`), is written once (`GraphicsElementSeeder.SeedOnNowNext`, guarded by the `graphics.on_now_next_seeded` ConfigElement marker, adopt-not-clobber like the #67 watermark seed) and identified to API clients via a server-derived `GraphicsElementResponseModel.builtIn` flag (`GraphicsElementDefaults.IsOnNowNext` — ordinal full-seeded-path equality plus `Kind == Text`, not name matching and not a bare filename, #568). Edited per-channel at Channel editor → Branding → "Show On Now / Next overlay". See `decisions.md``graphics.channel-level-attachment` (#74). | `GraphicsElement`, `ChannelGraphicsElement` | `/app/edit-channel/{id}` (Branding tab); YAML files under `GraphicsElementsTextTemplatesFolder` etc. are not directly SPA-edited |
| **Collection** | Manual list of media items (`CollectionItem`). | `Collection` | `/app/collections` |
| **SmartCollection** | Saved search — a `Query` string, no static item list. | `SmartCollection` | `/app/collections` |
| **MultiCollection** | Combines multiple `Collection`s and/or `SmartCollection`s (with grouping via `MultiCollectionItem`/`MultiCollectionSmartItem`). Both join entities carry a per-source `Weight` (default 1) used by `PlaybackOrder.WeightedShuffle` (#70) and ignored by every other order — the two are mirrors, so a change to one belongs on the other. The editor exposes a per-source weight input (1..1000, mirroring the API validator) with a computed % share and a "Reset to fair share" action; it round-trips `weight` from the GET because the PUT replaces the item list (#404). | `MultiCollection` | `/app/multi-collections` (#151, weight UI #404) |
+72 -7
View File
@@ -121,8 +121,48 @@ never overwrites an operator's file. Two rules govern it after that:
- Updating the shipped default → `graphics.seeded-template-upgrade-by-fingerprint`.
- It is attached to channels by default → `graphics.on-now-next-on-by-default`.
Identity is the **filename** (`GraphicsElementDefaults.OnNowNextFileName`), never the editable `Name`;
`GraphicsElementResponseModel.BuiltIn` is derived from it server-side so the SPA never name-matches.
Identity is the **full seeded path plus `Kind == Text`**, never the editable `Name` and never the
bare filename (`OnNowNextFileName`) alone — a filename-only, folder-agnostic comparison let a user
element named exactly `on-now-next.yml` in a different template folder also report `builtIn:true`
(#568). Both halves live in one predicate, `GraphicsElementDefaults.IsOnNowNext(path, kind)`, and
both `GraphicsElementResponseModel.BuiltIn` and `GraphicsElementSeeder.GetBuiltInElementId` resolve
through it whole, so the SPA never name-matches and the two sites cannot answer differently about
the same row. `Kind` was a `Where` clause on the seeder's lookup alone until #568, and that split is
exactly what let the API report an Image row at the seeded path as `builtIn:true` while the lookup
refused to treat it as the built-in element.
`Kind` is load-bearing rather than decorative, which is why it is inside the identity rather than
a filter any one caller may add: the seeder's own "does this row exist yet?" test —
`EnsureBuiltInElementRow`, which decides whether to create the row at startup — calls
`GetBuiltInElementId`, so a wrong-kind row at the seeded path answering yes would suppress the Text
row every consumer resolves. Asking that question a second way, as its own SQL `Path == target`
comparison, could answer differently in two ways, each leaving the built-in element undiscoverable
for the life of the install (#568): the collation one below, and `Kind`, which the SQL check ignored
(rows 18 and 34). Creating the row stays idempotent because the path it writes is the path the
lookup matches — held by `Repeated_Seeding_Does_Not_Accumulate_Element_Rows` (row 21), which reddens
if the two drift.
Each half of the identity rejects a row on its own, so a row that is BOTH of another kind AND
outside the seeded folder is still refused when either half is dropped — the survivor rejects it. A
test asserting such a row is ignored therefore cannot appear in row 18's red set; it is a test that
cannot fail, and the combination is deliberately not shipped. Row 10 drops the identity call
entirely, which is a different mutation from dropping either half.
The same arithmetic constrains how each negative case is SEEDED, at both discriminator sites: a
row varies exactly one half of the identity and holds the other at the seeded value. The
outside-folder rows carry `Kind == Text`, the wrong-kind rows sit at the seeded path. A negative row
that varies both is refused by the surviving half of the *pre-#568* predicate too — filename plus
`Kind == Text` — so a composite revert to it would pass every discriminator test at that site while
restoring the folder-agnostic bug the issue reports (#568).
`IsOnNowNext` is **ordinal**, and every caller applies it **in memory** rather than in a `Where`
clause. That is not incidental: `GraphicsElement.Path` takes no explicit collation (`TvContext`
pins one only on the listed name/title columns), so a SQL `Path ==` comparison is case-sensitive
under SQLite and normally case-INsensitive under MySQL. Evaluating one discriminator site in SQL and
another in memory is what would let them disagree, on MySQL only. The SQLite test suite cannot
distinguish the two — 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; what the case-variant tests in
the table below pin is the ordinal rule itself, against a loosening to `OrdinalIgnoreCase`.
## Tests
@@ -146,8 +186,10 @@ Three traps this table is built to avoid:
- **Measure against the whole project, never a per-fixture filter.** A filtered run structurally
cannot observe a red in another fixture, so it under-reports the failure set while looking precise.
Thirteen rows below redden more than one test, and two of them span two fixture classes — those two
are exactly what a per-fixture filter cannot see.
Twenty-four rows below redden more than one test, and five of them (13, 18, 22, 33, 41) span two
fixture classes — those five are exactly what a per-fixture filter cannot see. Both figures are
counted from the table itself, so a row added without recounting them makes this bullet quietly
false — recount both whenever a row is added or its red set changes.
- **A mutation that fails to COMPILE is not a result.** Warnings-as-error turn the obvious mutation
shapes into build failures — `CS0162` for `if (true) { return; }`, `CS1717` for self-assignment,
Sonar `S3981` for a constant-folded condition — and a build failure emits no test summary at all,
@@ -182,6 +224,14 @@ a test that cannot fail:
Rows are written per clause, not per block: a row naming a whole `if` or a whole style-merge block
hides which individual fields inside it are actually reachable from a test.
A row number is an identity, not a position: prose elsewhere cites rows by number (the built-in
element section above cites rows 18 and 21), so a new row takes the next unused number and the rest
are never renumbered. A clause that loses its red keeps its number vacant rather than freeing it for
some other clause — reusing it would silently re-point every existing citation. Reinstating the SAME
clause under its OWN number is the sole exception, because that is the same identity and not a new
one: row 18 is the `Kind == Text` filter, and stays that clause whether or not it currently has a
red.
| # | Clause mutated | Reddens (measured) |
|---|---|---|
| 1 | DrawBackgroundBox returns immediately | `An_Oversized_Border_Is_Clamped_And_Does_Not_Flood_The_Element`<br>`Background_Color_Fills_The_Box`<br>`Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range`<br>`Background_Opacity_Percent_Scales_The_Alpha`<br>`Background_Padding_Actually_Insets_The_Text`<br>`Border_Color_Draws_A_Border_Distinct_From_The_Fill`<br>`Border_Color_Without_An_Explicit_Width_Draws_A_Hairline`<br>`Corner_Radius_Rounds_The_Corner_Away` |
@@ -193,7 +243,7 @@ hides which individual fields inside it are actually reachable from a test.
| 7 | line-ending normalisation dropped from Normalize | `Upgrades_An_Untouched_Previous_Default_With_Windows_Line_Endings` |
| 8 | HLS-Direct exclusion removed from the backfill | `Skips_Hls_Direct_Channels_Where_The_Overlay_Cannot_Render` |
| 9 | already-attached filter removed | `Does_Not_Duplicate_An_Existing_Attachment`<br>`While_Armed_A_Restored_Element_Is_Attached_To_Every_Eligible_Channel` |
| 10 | filename check removed from the built-in lookup | `Ignores_A_Non_Built_In_Element_With_A_Different_Filename` |
| 10 | the whole `IsOnNowNext(c.Path, c.Kind)` filter removed from the built-in lookup (#568) — both halves of the identity at once, so every candidate row matches | `A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row`<br>`Ignores_A_Case_Variant_Of_The_Seeded_Path`<br>`Ignores_A_Non_Built_In_Element_With_A_Different_Filename`<br>`Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder` |
| 11 | graphics.on_now_next_default_attached guard never fires | `Does_Not_Re_Attach_After_An_Operator_Clears_It` |
| 12 | create-time `ChannelGraphicsDefaults.Attach` call removed from `CreateChannelHandler` | `Attaches_The_Built_In_Element_To_A_New_Channel` |
| 13 | HLS-Direct skip removed from the create path | `Leaves_An_Hls_Direct_Channel_Alone_Because_Nothing_Can_Render_There`<br>`Should_Not_Attach_The_Overlay_To_An_Hls_Direct_Channel` |
@@ -201,11 +251,11 @@ hides which individual fields inside it are actually reachable from a test.
| 15 | FitTextBlock drops HaloColor again | `The_Scale_Path_Preserves_Halo_Blur`<br>`The_Scale_Path_Preserves_The_Halo` |
| 16 | unrounded inset subtracted from the budget | `Fractional_Padding_Still_Respects_Width_Percent` |
| 17 | inset clamp removed | `An_Oversized_Padding_Is_Clamped_To_The_Largest_That_Fits` |
| 18 | Kind == Text filter dropped from the lookup | `Ignores_A_Same_Named_Element_Of_A_Different_Kind` |
| 18 | `kind == GraphicsElementKind.Text` conjunct dropped from `GraphicsElementDefaults.IsOnNowNext` (#568) — one row for both discriminator sites since they share the predicate; the lookup is what `EnsureBuiltInElementRow` asks, so a wrong-kind row at the seeded path would answer for the Text row that never then gets created, and the API would report it as `builtIn:true` | `A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row`<br>`GetAllGraphicsElementsForApi_Should_Not_Mark_A_Wrong_Kind_Row_At_The_Seeded_Path_As_BuiltIn` |
| 19 | Sanitize passes values through unchecked | `Non_Finite_And_Absurd_Box_Values_Do_Not_Corrupt_The_Geometry` |
| 20 | the BOX itself is no longer clamped (only insetPixels) | `An_Oversized_Border_Is_Clamped_And_Does_Not_Flood_The_Element` |
| 21 | duplicate guard removed from EnsureBuiltInElementRow | `Repeated_Seeding_Does_Not_Accumulate_Element_Rows` |
| 22 | EnsureBuiltInElementRow removed from the ALREADY-SEEDED branch | `An_Already_Seeded_Install_Missing_Its_Element_Row_Gets_One`<br>`Repeated_Seeding_Does_Not_Accumulate_Element_Rows` |
| 22 | EnsureBuiltInElementRow removed from the ALREADY-SEEDED branch | `A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row`<br>`An_Already_Seeded_Install_Missing_Its_Element_Row_Gets_One`<br>`Repeated_Seeding_Does_Not_Accumulate_Element_Rows` |
| 23 | non-atomic in-place write restored | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact`<br>`A_Failed_Write_Leaves_The_Original_Template_Intact` |
| 24 | marker written even with nothing resolved | `Stays_Armed_When_There_Is_No_Built_In_Element_To_Attach`<br>`While_Armed_A_Restored_Element_Is_Attached_To_Every_Eligible_Channel` |
| 25 | the atomic replace becomes a non-atomic copy | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact` |
@@ -216,3 +266,18 @@ hides which individual fields inside it are actually reachable from a test.
| 30 | per-style halo_blur override dropped from the style merge | `A_Non_Base_Style_Uses_Its_Own_Halo_Blur` |
| 31 | the 0-100 opacity clamp is removed | `Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range` |
| 32 | the lineup create path stops attaching the built-in element | `Should_Attach_The_Built_In_On_Now_Next_Element` |
| 33 | `IsOnNowNext` loosened from `Ordinal` to `OrdinalIgnoreCase` (#568) — one row for both discriminator sites, since they share the predicate | `GetAllGraphicsElementsForApi_Should_Not_Mark_A_Case_Variant_Of_The_Seeded_Path_As_BuiltIn`<br>`Ignores_A_Case_Variant_Of_The_Seeded_Path` |
| 34 | `EnsureBuiltInElementRow`'s existence check re-derived as a SQL `AnyAsync(e => e.Path == target)` instead of asking `GetBuiltInElementId` (#568) | `A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row` |
| 35 | `GraphicsElementIdsMustExist` removed from `UpdateChannelHandler.Validate` (#568) — the unknown-id 422 the issue asks for, and with it the count cap and the diagnostic cap that validator carries, and the lost-race recheck, which re-runs `Validate` whole | `Should_Cap_The_Ids_Echoed_Back_In_The_Unknown_Id_422`<br>`Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count`<br>`Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_Unknown_GraphicsElementId_With_422_Not_500`<br>`Should_Reject_When_One_Of_Several_GraphicsElementIds_Is_Unknown`<br>`Should_Translate_An_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 36 | `GraphicsElementIdsMustExist` removed from `UpdateDecoHandler.Validate` (#568) — and so from the lost-race recheck too, which re-runs `Validate` whole | `Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Unknown_GraphicsElementId_With_A_Validation_Error_Not_A_Throw`<br>`Should_Translate_A_Deco_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 37 | `WatermarkIdsMustExist` removed from `UpdateDecoHandler.Validate` (#568) — and so from the lost-race recheck too | `Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds`<br>`Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Unknown_WatermarkId_With_A_Validation_Error_Not_A_Throw`<br>`Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 38 | `idsAreConsumed: ConsumesGraphicsElementIds(request)` replaced by `idsAreConsumed: true` at `UpdateDecoHandler.GraphicsElementIdsMustExist`'s call site (#568) — a validator may only reject ids the apply path would consume | `Should_Ignore_An_Unknown_GraphicsElementId_When_The_Mode_Does_Not_Consume_It` |
| 39 | the same replacement at `UpdateDecoHandler.WatermarkIdsMustExist`'s call site (#568) | `Should_Ignore_An_Unknown_WatermarkId_When_The_Mode_Does_Not_Consume_It` |
| 40 | `GetAllGraphicsElementsForApiHandler`'s `BuiltIn` reverted from `GraphicsElementDefaults.IsOnNowNext(e.Path, e.Kind)` to `Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName` (#568) — the API-side call, the folder-agnostic and kind-agnostic shape the issue reports | `GetAllGraphicsElementsForApi_Should_Not_Mark_A_Wrong_Kind_Row_At_The_Seeded_Path_As_BuiltIn`<br>`GetAllGraphicsElementsForApi_Should_Not_Mark_Same_Filename_Outside_Seeded_Folder_As_BuiltIn` |
| 41 | the raw-count cap removed from `Validators.IdsMustExist` (#568) — one clause, three call sites, so its red set is what shows the cap is shared rather than restated | `Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count`<br>`Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds`<br>`Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them` |
| 42 | `Validators.DescribeIds` stops truncating and joins every missing id (#568) | `Should_Cap_The_Ids_Echoed_Back_In_The_Unknown_Id_422` |
| 43 | the `DbUpdateException` catch disarmed in `UpdateChannelHandler.ApplyUpdateRequestTranslatingLostRace` (#568) — retyped to an exception the save cannot raise, so the FK failure propagates as it did before the fix | `Should_Translate_A_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422`<br>`Should_Translate_An_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 44 | the same catch disarmed in `UpdateDecoHandler.ApplyUpdateRequestTranslatingLostRace` (#568) | `Should_Translate_A_Deco_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422`<br>`Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 45 | the lost-race recheck in `UpdateChannelHandler.ApplyUpdateRequestTranslatingLostRace` narrowed from `Validate` back to `GraphicsElementIdsMustExist` alone (#568) — the recovery enumerating its own fields instead of re-asking the validator set, which omits every other FK the same save writes | `Should_Translate_A_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 46 | the same narrowing in `UpdateDecoHandler.ApplyUpdateRequestTranslatingLostRace` (#568) | `Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 47 | the `idsAreConsumed` gate hoisted ABOVE the raw-count cap in `Validators.IdsMustExist` (#568) — the shape the deco mode gate had while it short-circuited the whole call, which took the cap with it | `Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them` |
+107 -34
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -364,6 +364,9 @@ HARD CONSTRAINTS:
task-specific delta. → `docs.convention-docs-session-start`
- Run `scripts/select-queue.sh` for queue selection; trust its deps/tiering/ordering and resolve only its
`CLAIM?`/`UMBRELLA?` flags. → `startup.parallel-orientation`
- A session that runs several issues at once is an orchestrated session: `docs/handoffs/orchestration.md`
owns its mechanics and `orchestrator-prompt.md` starts it; every slot is still bound by this file.
`process.orchestrated-session`
- Claim with `in-progress` before working — but **check for an existing claim first** (open PRs
referencing the issue, remote branches naming it, comments predating the label, a fresh
`git fetch`), because a label prevents duplicate *pickup*, not duplicate *work*: #649 was
+148
View File
@@ -0,0 +1,148 @@
# Orchestrated sessions
How one session closes several issues at once: an orchestrator that never implements, subagents
that each own one issue in one worktree, and the repo's merge gate between every branch and `main`.
This page owns the mechanics (`process.orchestrated-session`). `orchestrator-prompt.md` is the
standing prompt that starts such a session; `chicorytv-issue-queue.md` still owns queue selection,
claiming and the HARD CONSTRAINTS, and nothing here relaxes them — an orchestrated session is
several single-issue sessions run by one referee, and each of them is bound by the whole kickoff.
## Roles
| Role | Model, effort | Does |
|---|---|---|
| Orchestrator | Fable or Opus, high | Reads verdicts and evidence, ticks the `## Done-when` boxes, posts the H10 verdict, merges through the consent hook, clears labels, removes worktrees, watches for collisions and hangs. Never picks, claims, codes or pushes. |
| Picker + 2 refuters | sonnet, medium | Apply `scripts/select-queue.sh` and the kickoff's claim and bundle rules to live Gitea state; return up to `count` mutually non-colliding picks. `.claude/workflows/ersatztv-pick-next.js`, args `{taken, closed, notes, count}`. |
| Recon (large issues) | Opus, high | Reads the docs the task-signal map names, finds the handlers, call sites, tests and guards, returns a plan. |
| Implementer | Opus high; sonnet medium for `size: small` | Claims, builds, rebases onto `origin/main`, runs the local gate, sits inside the review loop, supplies the evidence per Done-when box. |
| Reviewers | correctness: Opus high, in its own worktree; conformance: sonnet high; cross-family: Codex via `codex exec` for the rubric's risk classes, with a cold Opus review-only fallback when Codex cannot run | Run the gate themselves; `blocking` / `should-fix` / `nit`. |
| Fixer | same tier as the implementer | Answers findings; two rounds, then the orchestrator decides. |
| Finisher | sonnet, medium | The single push, the PR, the closing record. Ticks nothing. |
**Sizing is a rule** (`process.per-agent-model-routing`): every launch names its model and effort.
sonnet at medium for picking, refuting, small well-specified fixes and mechanical finishing; sonnet at
high for conformance review; Opus at high for recon, implementation, fixing and correctness review;
Fable for orchestrating and for the frontier escalations the kickoff lists, never for implementing.
Cross-family review is mandatory for locks/concurrency, auth/security, API write-path handlers, DB
migrations and diffs over ~150 changed C# lines (`process.independent-review-rubric`); the picker's
`risk` field decides. When Codex cannot run, the workflow substitutes a cold same-family review-only
agent and the PR body states the substitution and its reason, as that record requires.
`.claude/workflows/ersatztv-issue-build.js` runs claim → recon → implement → gate → review → fix →
land for one issue or bundle (args: `issues, slug, title, size, risk, area, needs_e2e, port,
body_summary, done_condition, files_likely, avoid, trailer`; `issues`, `trailer` and `port` are
required and the script refuses without them). `ersatztv-resume-branch.js` finishes or fixes a
paused branch from a JSON brief. Both are passed to the Workflow tool inline; the tool persists the
script and returns a path for later runs.
## Isolation
- One worktree per issue: `~/orca/workspaces/ersatztv/wt-<n>`, branch `<n>-<slug>`, cut from a
freshly fetched `origin/main` (`process.shared-tree-readonly`), by absolute path. Never under
`/tmp` — macOS purges it mid-session — and never the shared checkout.
- The commit guard (`pretooluse-worktree-guard.sh`) reads a `.claude-worktree-owner` marker that
`posttooluse-worktree-marker.sh` writes on `git worktree add`. Measured 2026-09-04: a workflow
agent's `git worktree add` writes no marker (project hooks do not run for a workflow's agents),
and a `~`-prefixed path from the session writes none either, so orchestrated worktrees carry no
marker and the guard fails open inside them — acceptable, because they all belong to one session.
A sibling *session's* worktree keeps its marker and its denial
(`process.foreign-worktree-plumbing-merge`), so a denial inside an orchestrated worktree means the
worktree is foreign: the scripts stop and report it, and never overwrite the marker.
- `web/node_modules` is per worktree: clone it from the shared checkout with `cp -Rc` when the
lockfiles match, otherwise `npm ci`. The shared copy is kept current by
`scripts/refresh-shared-checkout.sh` at session end.
- **One .NET-building slot at a time on this host; docs and Python slots may run beside it.** A
slot's correctness reviewer builds .NET in its own worktree, and on a rubric-class change whose
Codex runner cannot run the fallback is a second such reviewer, so a review round can be two
build-and-test pipelines on top of the implementer's lingering MSBuild node servers. Measured
2026-09-05 on the 16 GB Mac: three slots in review reached load 82 and swap exhaustion; one slot's
round alone took swap from 6.8 GB to 10.8 GB in three minutes with the two reviewers concurrent.
The scripts therefore start the fallback reviewer only after the lenses return (the Codex runner
builds nothing and runs beside them). The cap stays at one even so: the implementer's own build,
one reviewer pipeline and the MSBuild node servers that linger after each build already fill the
budget beside the host's other sessions; re-measure before raising it.
`process.build-concurrency-limits` is the standing rule (under 20% free RAM launch nothing that
builds, under 10% pause); the Agent hook's RAM gate does not see a workflow's agents, so the
orchestrator holds the count itself.
- **Live-E2E runs per worktree on the slot's own port.** `scripts/e2e-local.sh` refuses concurrent
runs *within one repo root* because each run re-copies that root's `wwwroot`; across worktrees
there is nothing shared but the ports, so every slot gets a distinct `port` argument, one run at a
time inside the slot, and the launcher's pre-flight refuses a busy port and names the holder. A
busy port is reported, never taken over; kill only the PID the launcher printed to you
(`testing.e2e-cleanup-scope-by-pid`).
- Reviewers create scratch only under `/private/tmp`, delete nothing outside it, and never build a
path with `..` segments. A reviewer that must build gets `isolation: worktree` and fetches the
unpushed branch into it (`git fetch <worktree> <branch> && git checkout --detach FETCH_HEAD`); it
never builds in the slot's worktree.
## Landing a branch
The order is the kickoff's (`process.pr-routine-sequence`, `process.local-gate-before-push`), with
the review loop *inside* the worktree, before the single push:
1. **Implementer**: rebase onto a fresh `origin/main`, then the local gate — `dotnet build` and
`dotnet test` on the touched test projects (the full solution for anything under
`ErsatzTV.Core`), `npm run check:api && npm run lint && npm run typecheck && npm run build &&
npm test` under `web/` for SPA changes, `PYTHONPATH=. python3 -m pytest scripts/tests -q` plus
`ruff` for anything under `scripts/`, `.claude/`, `.husky/` or `.gitea/`, the BOM check for
touched `.cs` (`process.bom-format-detection-recipe`), and live-E2E on the slot's port for a
write path or UI change (`testing.live-e2e-prepush-timing`).
2. **Reviewers** read the worktree diff (`git diff origin/main...HEAD`) and run the gate in their
own worktrees. A round carrying any `blocking` or `should-fix` finding goes back to the fixer
regardless of the lens's own verdict word (a `should-fix` is a real defect by definition); nits
alone end the loop. A round in which every lens failed is an error, never a clean round. After
two fix rounds an open finding stops the workflow before the push and the orchestrator decides.
The same filter applies to the post-rebase round on the pushed head. In a resume, the existing
PR's stale head and body are the expected state until the finisher pushes, never a finding. The
loop ends on a clean round, never on "round one's findings are fixed".
3. **Finisher**: fetch; if `origin/main` moved, rebase, re-run the gate, and compare the patch-id
before and after — a changed patch (a conflict resolved, an artifact regenerated) sends the
branch through one more review round before the verdict, because the reviewed tree must be the
pushed tree. Then **one push**, `git push -u origin <branch>`; open the PR with `fixes #N` for
every issue in the bundle, the review history the workflow recorded (one line per round, quoted
verbatim — the finisher never describes a fix it did not see), the cross-family review status,
and the session trailers; arm the CI
monitor on the head sha; post the `## Closing record` on each issue with the evidence for every
`## Done-when` box, ticking none.
4. **Orchestrator**: read the review evidence, not the summaries. Send the PR back for anything
that lets a route or test pass having done nothing. Tick each box whose evidence holds, then
re-read the head sha immediately before posting `scripts/post-review-verdict.sh <pr> MERGEABLE
<note>` naming the reviewers, the rounds and the workflow's returned `cross_family` status. Read
that field, not only `error`: a cross-family failure in the post-rebase round leaves the branch
pushed and the run successful, and only `cross_family` says the substitute also failed; a
rubric-class PR in that state gets no verdict until a cross-family or substitute review of the
pushed head has run. Tick the review box. Wait for CI — a `cancelled` job
reads as `failure` at the combined status endpoint, so resolve it via the run's jobs
(`ci.cancelled-is-not-a-verdict`).
5. **Merge through the Gitea merge tool with the full head sha**; the consent hook derives consent
from the ticked boxes, the sha-bound verdict and green CI and auto-grants
(`release.merge-consent-autogrant`). A merge that `asks` is a state the orchestrator could not
derive, so derive it — never answer the prompt from memory. Then `DELETE /issues/{n}/labels/100`
on each issue, `git worktree remove`, refill the slot.
A rebase changes the head sha and voids the verdict; a fix after the push is a new commit, never an
amend (`process.pr-routine-sequence`). A branch that conflicts after its push goes back to the fixer
for a rebase pushed with `--force-with-lease` — the one sanctioned rewrite, scoped in
`process.orchestrated-session` — another gate run and a fresh review of the rebased head. Push a
branch behind `origin/main` and the pre-push hook refuses it (`release.format-as-you-touch-rebase`).
## Resuming a paused branch
A branch with a PR whose verdict is stale (head moved), whose CI is red, or that Gitea reports
unmergeable is resumed by `ersatztv-resume-branch.js` with a brief holding the done condition, the
last findings and the orchestrator's context. Its fixer rebases first, re-runs the gate, sits in the
same review loop, and the finisher pushes with `--force-with-lease`; the orchestrator posts a fresh
verdict for the new head. A resumed session finishes every open branch before it picks anything new.
## Hangs and incidents
- A `dotnet test` or E2E run with no output for ten minutes is a hang, not a slow test. Kill it by
the PID you started, gently; re-run once before diagnosing.
- Under an API incident (`https://status.claude.com/api/v2/status.json`), mechanical finishers and
probe-driven reviews run on whichever model is healthy; large implementation waits. Back off
before retrying.
- A workflow that returns an empty or unexpected result is read from its `journal.jsonl` before it
is re-run; a resumed run replays the unchanged prefix from cache.
- Before any stop: the H12 audit (`process.issue-qualification-audit`),
`scripts/refresh-shared-checkout.sh` (`session.shared-checkout-refresh`), and a handoff memory
naming every open branch, its worktree, its PR and its next step.
+16
View File
@@ -0,0 +1,16 @@
# Orchestrator prompt (paste unchanged into a fresh Claude Code session)
/goal @docs/handoffs/chicorytv-issue-queue.md You are the orchestrator and referee, not an implementer: read the kickoff and docs/handoffs/orchestration.md, then run the queue in parallel through subagents and workflows until you are told to stop or the eligible queue is empty, and run /done at the end.
Way of working:
- Size every subagent to its task; this is a rule, not a preference, because value per token is what the session is judged on. Picking and refuting: sonnet, medium. Recon on a large issue, implementation, fixing and correctness review: Opus at high (xhigh for a lock, threading or migration fix). Fable is for orchestrating and for the frontier escalations the kickoff lists, never for implementing. Small, well-specified fixes and mechanical finishing (rebase, push, PR body, label clearing): sonnet, medium. Conformance review: sonnet, high. Cross-family review through `codex exec` for the rubric's risk classes (locks, auth, API write paths, migrations, more than ~150 changed C# lines); when Codex cannot run, the workflow substitutes a cold same-family review-only agent and states the substitution in the PR body and its return, and your verdict note repeats it — a substitution is never silent. State the model and effort in every launch and revisit them when an incident degrades a model.
- You never pick, claim, code or push yourself. A picker (sonnet, medium; `.claude/workflows/ersatztv-pick-next.js` with the taken list as args) runs `scripts/select-queue.sh`, applies the kickoff's claim and bundle rules to live Gitea state, and two refuters try to overturn it; you accept, or take a refuter's better pick when its rule is right. Then one issue-build workflow per pick, from `.claude/workflows/ersatztv-issue-build.js`, passing the session's commit trailer as `trailer`.
- Before picking anything, finish what is already open: every open non-Renovate PR and every `in-progress` issue with a branch is a paused branch, resumed through `.claude/workflows/ersatztv-resume-branch.js` from a JSON brief.
- Keep at most one .NET-building issue in flight (a docs or Python issue may run beside it), each in its own worktree under `~/orca/workspaces/ersatztv/wt-<n>` on branch `<n>-<slug>`, cut from a fresh `origin/main` by absolute path, never under `/tmp`, and each with its own E2E port passed as `port`. Refill a slot as soon as one merges. Pass every running issue's files as the avoid list so two agents never edit one file; a pick that needs a file another slot holds waits for that slot.
- Each implementer claims its own issue after the four-way check (open PRs, remote branches, comments predating the label, a fresh fetch), with the `in-progress` label and a claiming comment; rebases onto a fresh `origin/main`, runs the local gate and sits inside the two-lens review loop BEFORE its single push; its finisher opens a PR whose body says `fixes #N` for every issue in the bundle and states the cross-family review status, and posts the `## Closing record` on each issue with the evidence per `## Done-when` box, ticking nothing — a box ticked by the agent it certifies is self-consent.
- You referee: read the verdicts and the evidence, send a PR back for anything that lets a route or test pass having done nothing, tick each Done-when box whose evidence holds, re-read the head sha, then post the verdict with `scripts/post-review-verdict.sh <pr> MERGEABLE <note>` and tick the review box. Merge only through the Gitea merge tool with the full head sha, so the consent hook derives consent from the ticked boxes, the sha-bound verdict and green CI. A rebase voids the verdict: the finisher rebases once before the push and reports whether the patch changed; a changed patch gets one more review round before you post. After the merge clear the `in-progress` label and remove the worktree.
- One .NET slot means two build pipelines in its review round (the correctness lens and, on a rubric change whose Codex runner cannot run, the fallback reviewer, one after the other); `process.build-concurrency-limits` holds — under 20% free RAM launch nothing that builds, under 10% pause — so read `memory_pressure -Q` and swap before every launch; live-E2E runs one at a time per worktree on the slot's own port, and a busy port is reported, never taken over. Kill only PIDs you started, gently. A `dotnet test` or E2E run silent for ten minutes is a hang.
- Reviewers never delete anything outside a scratch directory they created under `/private/tmp` and never build a path with `..` segments; the auto-mode classifier is the last line, not the first.
- Under an API incident (watch `https://status.claude.com/api/v2/status.json`), move mechanical finishers and probe-driven reviews to whichever model is healthy and park large implementation until it clears; back off before retrying.
- Report to the user only what changes what they would do next: merged and closed issues, a send-back and why, a hang, an incident. Before any stop: the H12 audit (`process.issue-qualification-audit`), `scripts/refresh-shared-checkout.sh`, and a handoff memory naming every open branch, its worktree, its PR and its next step, so a resumed session finishes those before picking anything new.
+1 -1
View File
@@ -133,7 +133,7 @@ classifications differ; otherwise the strictest applies and the Note names the e
| `scripts/ci-peak-anon.sh` | `N/A` | Reads no live remote state — samples the runner's local cgroup `memory.stat`/`memory.peak`. |
| `scripts/ci-prove-ban-detects.sh` | `N/A` | Reads no live remote state — mutates a local workflow copy and runs pytest against the local checkout. |
| `scripts/ci-step-ran.sh` | `N/A` | Reads no live remote state — reads runner-supplied env vars and local marker files it wrote itself. |
| `scripts/ci-toolchain-image-resolves.sh` — registry manifest read for the pinned toolchain tag | `UNSAFE-KNOWN` | Reads a MUTABLE identifier (a registry tag) with nothing re-checking it before the `container:` jobs pull, so a tag deleted between the preflight and the pull is reported as present. Graded `UNSAFE-KNOWN` rather than `N/A` deliberately: nothing proceeds on the strength of the read — it can only turn its own job red, which is not nothing (the merge-consent hook denies on the COMBINED status, ersatztv#598) but is not authorization either — while a stale PASS is read by a human as "the image is fine", which is an assertion about remote state this file exists to grade. The residual is bounded by what it degrades to: a stale pass leaves exactly the pre-#772 behaviour (five jobs failing at pull), never anything that proceeds on the strength of the read. The opposite error is closed by the EXIT CODE rather than by wording: an unusable credential, an unverifiable answer (after retries) and an HTTP 200 whose body is not a manifest all FAIL the job. THE TRAP, since warning on those and exiting 0 is the natural way to write this check: a missing `curl`, a moved registry or a DNS change all land there, and a green-with-a-warning job is indistinguishable from a healthy pin forever after — "the check could not run" presenting as "the pin is fine", which is precisely what this row would then be asserting falsely. |
| `scripts/ci-toolchain-image-resolves.sh` — registry manifest read for the pinned toolchain tag | `UNSAFE-KNOWN` | Reads a MUTABLE identifier (a registry tag) with nothing re-checking it before the `container:` jobs pull, so a tag deleted between the preflight and the pull is reported as present. Graded `UNSAFE-KNOWN` rather than `N/A` deliberately: nothing proceeds on the strength of the read — it can only turn its own job red, which is not nothing (the merge-consent hook denies on the COMBINED status, ersatztv#598) but is not authorization either — while a stale PASS is read by a human as "the image is fine", which is an assertion about remote state this file exists to grade. The residual is bounded by what it degrades to: a stale pass leaves exactly the pre-#772 behaviour (five jobs failing at pull), never anything that proceeds on the strength of the read. The opposite error is closed by the EXIT CODE rather than by wording: an unverifiable answer (after retries), an HTTP 200 whose body is not a manifest, and all three shapes of refused anonymous read — a `401`/`403` surviving a bearer the run actually obtained, a `401` whose token leg produced no bearer, and a first-read `403` that never entered the token leg at all — every one of them FAILS the job. The script names no credential (ersatztv#885, `ci.pr-route-carries-no-stored-credential`): it runs on the `pull_request` route, where the YAML is head-supplied, so it reads this public package through the registry's anonymous Bearer-token flow instead. That changes nothing about the grade — the tag it reads is mutable either way. THE TRAP, since warning on those and exiting 0 is the natural way to write this check: a missing `curl`, a moved registry or a DNS change all land there, and a green-with-a-warning job is indistinguishable from a healthy pin forever after — "the check could not run" presenting as "the pin is fine", which is precisely what this row would then be asserting falsely. |
| `scripts/set-provider.sh` | `N/A` | Reads no live remote state — sets local `dotnet user-secrets` values. |
| `scripts/__init__.py` | `N/A` | Empty package marker — executes nothing. |
| `scripts/scripted-schedules/entrypoint.py``ScriptedScheduleApi.get_context(build_id)`, then `define_content` / `reset_playout` / `build_playout` against the same live server | `UNSAFE-KNOWN` | A genuine read-then-act over live ErsatzTV state. The context is fetched, handed to user-supplied script functions that mutate the playout, and re-fetched after a reset with nothing pinning either read — a concurrent build or edit between them is invisible. Accepted because it runs inside a single scripted-schedule build the server itself serialises per playout, and because the API exposes no version or ETag on the context to compare against; the honest bound is that the blast radius is one playout's content, reversible by rebuilding. |
+95
View File
@@ -404,6 +404,66 @@ double-invoke for the re-arm) **and** the *integration* (mock the hook module an
component actually read `current` — and saw `false` — when the late response landed). Verify each by
removing the mechanism and confirming the test fails.
## 3c. A write failure must outlive the dismissible surface that started it (#830)
`Dialog`, `SlideOver` and `ConfirmDialog` are all dismissible **mid-request** through three paths
that never consult a busy flag: Escape and a backdrop/scrim click (both via `useOverlayBehavior`),
and the header close button. Disabling the footer Cancel button — which nearly every dialog here
does — looks like it closes that hole and does not.
So an error rendered from the dismissible component's own state has nowhere to go once the user
dismisses it. The request still runs, still fails, and the screen reloads unchanged: the user reads
that as success. Guarding the `setError` with an is-mounted check (§3b) is necessary but **not
sufficient** — it makes the drop deliberate rather than accidental, which is still a drop.
**Use `useDismissSafeError` (`web/src/hooks.ts`).** It renders the message inline while the surface
is mounted — the better surface, since it keeps the user's context — and hands it to a
caller-supplied `onFailed` once the surface is gone:
```tsx
const { inlineError, reportFailure, setInlineError } = useDismissSafeError(onFailed);
// ...
} catch (err) {
reportFailure(messageFromXError(err, 'Unable to …'));
}
```
The surviving surface belongs to the **parent**, so `onFailed` is a prop contract, not a rendering
decision. `CollectionsScreen` reports into its screen-level `role="alert"` banner — the only wired
site today. The other candidate surface is the `notice` + `Toast` pair that `MediaBrowseScreen` and
`SearchScreen` already use for success. There is no global toast host in this SPA; do not add one
for a single screen.
The two are **not equivalent**, so pick deliberately if you wire the second: `Toast` is
`role="status"` (polite, announced less assertively than `role="alert"`) and each screen has a
single `notice` slot, so a later success Toast would overwrite a pending failure one. Nothing
diverts to those screens today — they receive no reporting callback.
**Both halves of the outcome must reach the surviving caller**, and they are not the same kind of
callback. Report the OUTCOME (`onAdded` / `onDone` / `onFailed`) unguarded — gating a success
callback on an is-mounted check makes a completed write silent, exactly like the failure case. But
gate the DISMISS request (`onClose`): it means "close me", and after dismissal "me" is whatever the
user opened next. The four `media/addTo/` dialogs currently gate BOTH (measured on
`AddToCollectionDialog`: `onAdded` called 0 times after dismissal) and are tracked in #877
separating the two there needs the parent screens to stop nulling the dialog themselves, so it is a
design change rather than a one-line fix. When you add a success callback to a dialog, add its
failure counterpart, and keep the close decision distinct from the report.
Make the reporting prop **required** where the host screen has a surface for it —
`AddItemsDialog.onAddFailed` is, so the failure cannot be dropped by forgetting to wire it. Make it
optional only when some host genuinely has nowhere to report (the `media/addTo/` layer's eventual
one will be, because `MediaDetailScreen` wires no outcome callbacks at all); there, an omitted
`onFailed` drops the failure exactly as today — a known gap, not coverage (#877). Rationale and the
full call-site sweep: `docs/decisions/records/spa/dismissible-write-failure-reporting.md`.
**Pin the arm the CALL SITE reaches, not only the hook's.** A unit test of `useDismissSafeError`
proves the inline branch exists; it cannot see a call site that reports through `onFailed` directly
and so pushes the message onto the parent banner while the surface is still up — behind a
`createPortal` panel with `aria-modal="true"`, i.e. covered for sighted users and hidden from AT.
Add an integration assertion for each arm: fail the write with the surface still open and assert the
message is inside `[role="dialog"]`, and fail it again across a dismissal and assert the message is
on the screen and not in a dialog.
## 4. API client modules
One file per domain in `web/src/api/`, e.g. `logs.ts`, `blocks.ts`, `playouts.ts`. Pattern (see
@@ -583,6 +643,34 @@ safe) and mirrors `onError`'s requirements exactly: pass a **stable** `onPlaying
since it too sits in the attach effect's dependency array and an unstable identity would restart the
stream every render.
**Autoplay (#554).** `HlsPlayer` takes an optional `muted` prop, **defaulting to `false`**. Muted
media is the one thing browsers autoplay without user activation, so passing it makes the
`video.play()` kick above succeed even when `MANIFEST_PARSED` arrives past the browser's transient
user-activation window (~5s in Chrome) — the failure mode a slow-starting channel (unbounded manifest
`maxTimeToFirstByteMs`, see above) hits, leaving a player sitting at "starting" over a black frame
with no indication the operator just needs to press play. It is a **per-consumer** choice rather than
a player-wide default because muting costs audio-by-default: the channel-preview panel
(`ChannelPreviewPanel.tsx`) opts in — it answers "does this channel work right now", and starting
beats being audible — while the playback-troubleshooting screen
(`PlaybackTroubleshootingScreen.tsx`) must stay unmuted, since verifying the audio side of an FFmpeg
profile is part of its job. `controls` is always on either way, so a muted player can be unmuted by
hand.
For the residual case where autoplay is rejected anyway (a stricter browser policy or an extension),
`HlsPlayer` also takes an optional `onAutoplayBlocked()`. It fires **only** on an autoplay-policy
rejection — a `DOMException` named `NotAllowedError`; a `play()` promise rejected under any other
name (notably `AbortError`, which is what a pending `play()` interrupted by a teardown produces, and
a consumer's own Retry does exactly that) is not reported, or the caller would put a cause in front
of the operator that did not happen. Like `onError`/`onPlaying` it sits in the attach effect's
dependency array, so pass a `useCallback`; it is purely additive and omitting it is safe. The
channel-preview panel wires it to a "press play" hint whose visibility is the render guard
`state === 'starting'` **alone**, so a second clear in `onPlaying` would be a guard no test could
distinguish. That holds because the panel's paths back to `starting` either clear the flag themselves
(Retry, a channel change) or cannot be reached while it is set — the forced-preview opt-in re-enters
`starting` without clearing, but its button renders only while the panel has not started, and the
only thing that un-starts it is the channel reset that clears the flag. The panel never claims the
fault fixed itself; the hint mirrors an honest, still-not-playing state.
**Resolving a `/iptv/*` src under JWT auth (#552).** Before feeding an `/iptv/*` manifest URL to
`HlsPlayer`, pass it through `withIptvToken(url)` (`web/src/media/iptvToken.ts`): under a JWT-enabled
deployment it appends the short-lived `?access_token=` the `/iptv/*` scheme requires (the `ctv-session`
@@ -603,6 +691,13 @@ page's action row) and the `AddToCollectionDialog` / `AddToPlaylistDialog` / `Ad
on grid screens is an explicit "Select" toggle (see `docs/decisions.md` 2026-07-10 for the rationale
and the accepted deviations from Blazor).
Each dialog in this layer is unmounted the moment it is dismissed, so an outcome the screen does not
receive is an outcome the user never sees. As of 2026-08-29 the layer has **no failure channel at
all** — `AddToMenu` exposes `onDone` and no counterpart — and its dialogs gate the success callback
on their own unmount guard, so a write that settles after dismissal reports nothing in either
direction. Adding one is #877; see §3c for the mechanism and for why the close decision has to stay
separate from the report.
## 5d. Client-local preferences: `localStorage`, namespaced `ctv-*` keys
Per-browser UI preferences (theme, an auth token, a screen's remembered page size) live in
+81 -9
View File
@@ -20,7 +20,7 @@ before adding tests, not just `docs/contributing.md` §8 (which now just points
| `ErsatzTV.Scanner.Tests` | Library scanning: scan handlers, folder scanners, NFO readers | Handler tests substitute the folder scanners + `ILibraryRepository` and assert the resulting repository writes (e.g. `ScanLocalLibraryHandlerTests` pins which `LastScan` levels a scan records — ersatztv#264). Fakes/`Testably` back the file-system-facing scanners. ~1,485 tests (approximate on purpose — an exact count goes stale on every PR that adds one). Additionally contains `Core/FFmpeg/TranscodingTests``[Explicit]` + `[Combinatorial]`, so it never runs in CI or a plain `dotnet test` (it needs real ffmpeg/hardware) and contributes 0 to that count; run it by name when touching the transcoding pipeline. |
| `ErsatzTV.Architecture.Tests` | Layering rules via NetArchTest.eNhancedEdition | Core↛Infra/App/EF; FFmpeg↛all; App↛concrete providers. 5 tests. See `docs/contributing.md` §1. |
| `ErsatzTV.FFmpeg.Tests` | FFmpeg command construction | Build a pipeline, assert the exact rendered arg string (`PipelineBuilderBaseTests.cs`). |
| `web/` (vitest) | React SPA unit tests | Run alongside typecheck + build (see below). Collects `src/**`, `web/scripts/**` *and* `web/vite-plugins/**`, but deliberately **excludes** `web/e2e/**` (the Playwright specs — vitest's default `**/*.spec.*` glob would otherwise run them under jsdom). Two files have git prerequisites since ersatztv#819. `web/src/api/pageSizeCallSites.guard.test.ts` needs a git **checkout** AND, through it, the **binary** — it derives its file population from `git ls-files` via `web/vite-plugins/trackedSourceFiles.ts` rather than a directory walk, and refuses rather than falling back. `web/vite-plugins/trackedSourceFiles.realgit.test.ts` needs the **binary** but no checkout: it builds its own temp repository to prove that derivation by executing it. So it is not checkout-versus-binary supplying a `.git` alone would not let either run. Every other file runs fine with neither. `docker/Dockerfile`'s web-build stage runs the suite with exactly those two `--exclude`d, because its context carries no `.git` and `node:22-bookworm-slim` ships no git and the two exclusions overlap rather than divide — both files need the binary. |
| `web/` (vitest) | React SPA unit tests | Run alongside typecheck + build (see below). Collects `src/**`, `web/scripts/**` *and* `web/vite-plugins/**`, but deliberately **excludes** `web/e2e/**` (the Playwright specs — vitest's default `**/*.spec.*` glob would otherwise run them under jsdom). Some files have git prerequisites since ersatztv#819, and the set is not fixed — ersatztv#883 added one. `web/src/api/pageSizeCallSites.guard.test.ts` and `web/src/api/completeAnnotations.guard.test.ts` need a git **checkout** AND, through it, the **binary**: they derive their file population from `git ls-files` via `web/vite-plugins/trackedSourceFiles.ts` rather than a directory walk, and refuse rather than falling back. `web/vite-plugins/trackedSourceFiles.realgit.test.ts` needs the **binary** but no checkout it builds its own temp repository to prove that derivation by executing it. So it is not checkout-versus-binary: supplying a `.git` alone would not let any of them run. **The suite therefore runs only where git is present, and `docker/Dockerfile` is not such a place** — its web-build stage builds the SPA and does not test it (ersatztv#887). Enumerating the git-dependent files as Docker `--exclude`s was tried and REVERSED: that list is a population nothing derives, it went stale the first time a guard was added, and the resulting red is unreachable on a PR — `Build & push image (amd64)` is `if: github.event_name != 'pull_request'` — so it landed on `main` and on the release tag instead. The image is gated on `docker-build.yml`'s `test` job running the whole suite on a real checkout, held by `scripts/tests/test_image_build_delegates_the_spa_suite.py`. |
| `web/e2e/` (Playwright) | UI-interactive E2E flows against a **live** instance | Not a unit suite and **not** part of `npm test` — needs a running server, so it runs via `scripts/e2e-ui.sh` (boots its own fresh instance) and in CI as a step of the `functional-e2e` job. Headless Chromium, `serial`, `retries: 0`. Scope rule: assert only what the curl harness structurally cannot. See `docs/e2e-local.md` → "UI-E2E harness". |
## Golden-file nets
@@ -35,14 +35,10 @@ Three golden-file suites guard the highest-value, most-subtle output:
produces over a pinned build window. Covers the **Classic** (`PlaybackOrder.Chronological`),
**Block**, and **Sequential (YAML)** builders. The **Scripted** *end-to-end pipeline* is excluded from this
net — `ScriptedPlayoutBuilder` runs a user-authored external process that drives the engine over HTTP, which
the in-memory harness can't pin; that integration harness is tracked in ersatztv#563. The scheduling
*behavior* those scripts drive, though, lives in the in-process `SchedulingEngine` (the HTTP controller is a
1:1 pass-through) and IS directly testable — `SchedulingEngineTests` news it up with substitutes, and
`ContentEnumeratorBuilderTests` (ersatztv#395) is the direct regression net over the enumerator-construction
helper the Scripted and Sequential/YAML engines now share (decision:
`testing.scripted-playout-golden-deferred`). The **Sequential** case
(`Sequential_yaml`, ersatztv#381)
builds from a committed YAML fixture (`Goldens/Fixtures/sequential-schedule.yml`) instead of a
the in-memory harness can't pin. Scripted is instead covered in-process at two levels, described under
"Scripted playout coverage" below (decision: `testing.scripted-engine-in-process-net`, ersatztv#563).
The **Sequential** case (`Sequential_yaml`, ersatztv#381) builds from a committed YAML fixture
(`Goldens/Fixtures/sequential-schedule.yml`) instead of a
`ProgramSchedule`; it is TZ-independent (the `count`/`all`/`duration` handlers do UTC-only arithmetic —
it passes, not skips, under a non-UTC `TZ`) so needs no `Assume` guard. A third case,
`Classic_clock_padded` (ersatztv#77), locks clock-boundary padding: a `FillerMode.Pad` +
@@ -67,6 +63,71 @@ diff during normal test runs means the code changed the output — regenerating
away hides the change instead of surfacing it. Only a human who has confirmed the change is
intentional should regenerate.
## Scripted playout coverage
Scripted playout is characterized in-process at two levels, and the transport is deliberately not covered
at all (ersatztv#563, decision `testing.scripted-engine-in-process-net`).
| Where | Covers |
| --- | --- |
| `ErsatzTV.Core.Tests/Scheduling/Engine/SchedulingEngineTests.cs` | The engine build API a script drives: `AddCollection`, `AddCount`, `AddAll`, `AddDuration` (stop-before-end and trim), `PadUntilExact`, EPG guide-group locking, per-item `PlayoutHistory`, the `IsDone` no-progress halt and its reset, and the `GetAnchor`/`RestoreOrReset` round-trip a Continue build resumes from. Substituted repositories, no database. |
| `ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests.cs` | A committed script fixture (`Controllers/Fixtures/scripted-build.json`), bound with the production body-binder configuration (`ApiJsonSettings`) and replayed through the real `ScriptedScheduleController` + `ScriptedPlayoutBuilderService.MockSession` + `SchedulingEngine`, with a pinned 13-item snapshot in the same line format the playout goldens use (both trimming actions target an instant *between* two content boundaries, so each one's `trim` argument reaches the engine's trim branch rather than sitting inert), plus the four adapter mappings: 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 engine's no-progress `InvalidOperationException` translated to a 400. |
| `ErsatzTV.Core.Tests/Scheduling/ContentEnumeratorBuilderTests.cs` | The enumerator-construction helper the Scripted and Sequential/YAML engines share (ersatztv#395). |
| `ErsatzTV.Tests/Serialization/ApiJsonSettingsTests.cs` | What the replay's standalone binder does and does not share with the one MVC runs, so the paragraph below stays a measurement rather than a claim. |
The expected snapshot is a string constant in the test rather than a fourth golden file: the golden
harness lives in `ErsatzTV.Core.Tests`, which cannot reference a controller, and duplicating it would
create a second action-to-engine mapping — the thing the single-mapping design exists to avoid.
**Not covered, and not scheduled to be** (measured 2026-09-05): the `Cli.Wrap` launch of the user's own
program — exit code, the `PlayoutScriptedScheduleTimeoutSeconds` timeout, stdout capture; Kestrel and the
`Startup` middleware, including the host/`Settings.UiPort` check that 404s a foreign request;
`ApiAuthorizationFilter`, which fail-closes every mutating verb for an endpoint without
`[SkipApiAuthorization]`; and MVC model binding as a *wrapper* — the input formatter, model validation
(a non-nullable reference type picks up an implicit required check there) and the `[ApiController]`
automatic 400 either produces before the action runs, since the replay hands each action an
already-bound object. Hosting the real `Startup` would drag in the whole DI graph, a
hand-rolled minimal host would test a transport the product does not have, and the script is
user-authored by definition — so any committed script is a stand-in either way. The decision record
names the concrete blind spot this leaves, and it is tracked as ersatztv#913.
The *serializer* inside that binding wrapper is covered rather than scoped out. The replay deserializes
fixture bodies through `ErsatzTV.Serialization.ApiJsonSettings`, the same function `Startup` hands to
`AddNewtonsoftJson`, and two tests witness that choice through the replay's own bind helper. The
fixture's own bodies parse the same way under every plausible replacement, so each test carries a body
built to separate one of them:
| Replacement the test separates the production binder from | The body that separates them |
| --- | --- |
| `System.Text.Json` with web defaults | A body omitting the C# `required` member `collection`. Newtonsoft has no notion of `required` and deserializes it to a default; System.Text.Json rejects the body outright. `Production_Body_Binder_Ignores_Required_Members` asserts both halves. |
| a bare `new JsonSerializerSettings()` — still Newtonsoft, but without the production configuration | An explicit `"order": null`. `NullValueHandling.Ignore` keeps `ContentCollection.Order` at its declared `"shuffle"`; Newtonsoft's own `Include` default writes the null through, and `AddCollection`'s `Enum.TryParse` then returns a 400. `Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null` asserts the production side end to end. |
Both are statements about the serializer and stop there — what MVC validation does with such a body
belongs to the wrapper named above. What each test does to a suite when the binder is actually swapped
was measured while building it and is recorded in ersatztv#563, not here: under
`testing.mutation-claims-are-executed` such a sentence is a `CLAIMS` entry in
`scripts/tests/mutation_manifest.py` that executes every run or it is not written, and that harness runs
pytest, so an NUnit proof cannot be declared in it.
What is shared with production is the *configuration*, not the settings **object**. MVC applies it to
settings it has already configured; `ApiJsonSettings.Create()`, which every test outside the pipeline
uses, applies it to a bare one. Measured 2026-09-05, the standalone object therefore keeps Newtonsoft's
`MaxDepth` of 64 instead of MVC's stricter 32 and lacks MVC's `ProblemDetailsConverter` and
`ValidationProblemDetailsConverter`; `MissingMemberHandling`, `TypeNameHandling` and `DateParseHandling`
match. Neither gap can reach a scripted request body — the DTOs nest two levels and are never a
`ProblemDetails` — which is what makes `Create()` usable in a test at all, and no test may generalize
from it to "production" beyond that. `ErsatzTV.Tests/Serialization/ApiJsonSettingsTests.cs` pins the
whole delta in both directions, so it fails rather than rots if either object moves.
`ApiJsonSettings` exists so that binder is *defined* once; it is not a drift detector, and the difference
is worth stating because it bounds what the suite can promise. Nothing here observes
`Startup.ConfigureServices`, and a byte-equal hand-copy of `Apply` is behaviourally indistinguishable
from calling it — so the extraction removes the duplicate rather than detecting drift in one. A mirror
that has *lost* something is what the suites separate: the table above on the read path, and
`OpenApiSerializerContractTests` on the write path, whose four cases assert the camelCase keys
`CustomContractResolver` produces and nothing else in the configuration supplies. Drift confined to
`ReferenceLoopHandling` or the `StringEnumConverter` is separated by neither.
## Timezone independence
The suite is timezone-independent (ersatztv#24). When constructing test `PlayoutItem`s, always
@@ -74,6 +135,17 @@ set a real `Start` (e.g. `startState.CurrentTime.UtcDateTime`) — never rely on
`DateTime.MinValue`, which underflows `DateTimeOffset.MinValue` once a non-UTC local offset is
applied (`StartOffset` calls `ToLocalTime()`). CI runs in UTC; local runs may not.
**Which `SchedulingEngine` calls are safe to put in a fixture** (audited 2026-09-05). `AddCount`,
`AddAll`, `AddDuration` and `PadUntilExact` preserve the instant — items are always written as
`_state.CurrentTime.UtcDateTime`, and `PadUntilExact`'s `ToLocalTime()` changes the offset the state
carries, not the moment. `WaitUntil(TimeOnly)` and `PadUntil(string)` are **not** safe: both read the
LOCAL day and time-of-day off `CurrentTime` and rebuild a target from them. `PadToNext` is safe only
until something localizes `CurrentTime` — it reads `.Year`/`.Month`/`.Day`/`.Hour`/`.Minute` in whatever
offset that value happens to carry, so a fixture that calls it after `PadUntilExact`, `WaitUntilExact` or
a Continue anchor becomes TZ-sensitive by ordering rather than by call. The scripted fixtures therefore
use Chronological order and only the instant-preserving instructions; they pass, rather than skip, under
`TZ=UTC`, `America/New_York`, `Australia/Lord_Howe` and `Asia/Kathmandu`.
## Running tests
Full .NET gate:
+62 -14
View File
@@ -10,8 +10,8 @@ narrative detector is a string predicate over prose, and `docs/defect-shapes-773
withdrawn `test_review_verdict_vocabulary_parity.py` six review rounds, then deleted is the
empirical case). Do not convert this into a gate; the decision record says no in as many words.
WHY PYTHON AND NOT SHELL. The first implementation hand-parsed `git diff -U0` output in bash by
matching line prefixes, and cold review demonstrated four separate defects in that one parser: the
WHY PYTHON AND NOT SHELL. Hand-parsing `git diff -U0` output in bash by matching line prefixes
produced four separate defects in that one parser: the
`\\ No newline at end of file` marker was counted as content, an added line whose own text began
`++ ` was eaten by the `+++ ` header arm, `core.quotePath` hid non-ASCII paths, and `read` dropped a
final unterminated line. Those are four instances of one mistake deciding what a diff line IS from
@@ -54,7 +54,25 @@ except Exception: # noqa: S110 — a stdout that cannot be reconfigured is not
# the 35 have more than one commit, so revisions do happen — which is precisely the case where the
# rule's remedy (move it to the commit message) is still available. The exemption costs the only
# reach the detector has and buys only quiet in a sweep a person runs deliberately.
EXEMPT_PREFIXES = ("docs/decisions/",)
#
# `scripts/tests/fixtures/` is exempt for the same reason one level down (#876): it holds COPIES of
# decision records and other test DATA, which carry whatever phrasing the test under them needs and
# are not an artifact anyone edits for a reader. Exempting it is also what keeps the depth
# measurement in the record true — its record copies sit at four to six slashes.
EXEMPT_PREFIXES = ("docs/decisions/", "scripts/tests/fixtures/")
# The PROCESS corpus (#876): hooks, workflows, scripts and their tests, scanned regardless of
# extension because the artifacts there are shell, YAML, Python and jq, not Markdown. It is in the
# population on the #812 argument run forward — `--diff` sees only ADDED lines, and this is where
# narrative is being ADDED: 287 of the 453 sites the #876 sweep found outside the docs corpus were
# under 30 days old (measured 2026-09-03 at `fb5592971`), against a `docs/superpowers/**` that had
# not moved since 2026-07-23. `web/` and the C# projects are left out on the same measurement: 3 of
# the 74 PATTERNS-matching sites lived there, across roughly 4,600 tracked files.
PROCESS_PREFIXES = (".claude/", ".gitea/", ".husky/", "scripts/")
# Exempt by NAME, not by prefix: this file and its test carry the phrasings as pattern and fixture,
# so they would be permanent hits — the false positive that makes an advisory check stop being read.
EXEMPT_FILES = ("scripts/check-doc-narrative.py", "scripts/tests/test_check_doc_narrative.py")
# Session-narrative phrasings. Deliberately narrow: each is first person or names a revision of THIS
# artifact. Broad words that also appear in legitimate dated history ("previously", "was wrong") are
@@ -84,14 +102,19 @@ WARNING = (
def is_scanned_path(path: str) -> bool:
"""The population: `docs/**/*.md` minus `docs/decisions/**`, plus root-level `*.md`.
"""The population: `docs/**/*.md` minus `docs/decisions/**`, plus root-level `*.md`, plus every
tracked file under `PROCESS_PREFIXES` regardless of extension, minus `EXEMPT_FILES`.
Stated positively and in one place so the record's `mechanics:` can quote it exactly. Skills,
`web/`, and other nested markdown outside `docs/` are deliberately NOT in scope.
Stated positively and in one place so the record's `mechanics:` can quote it exactly. `web/`,
C# source, and nested markdown outside `docs/` and the process prefixes are deliberately NOT in
scope. This is a PATH predicate; `run_all` additionally restricts the population to tracked
regular files, because a symlink's content is its target, not the artifact.
"""
if not path.endswith(".md"):
if path in EXEMPT_FILES or any(path.startswith(p) for p in EXEMPT_PREFIXES):
return False
if any(path.startswith(p) for p in EXEMPT_PREFIXES):
if any(path.startswith(p) for p in PROCESS_PREFIXES):
return True
if not path.endswith(".md"):
return False
return path.startswith("docs/") or "/" not in path
@@ -163,10 +186,19 @@ def added_lines(diff: str):
p = raw[4:]
# `/dev/null` on the new side means the file was DELETED. Stated honestly: this arm
# is DEFENSIVE, not load-bearing — a deletion contributes no `+` lines, so nothing is
# yielded for it either way, and removing this arm reddens no test. It is kept because
# `path` should never name a file the added lines do not belong to. A
# yielded for it either way. Measured rather than asserted, and re-measured on every
# run: `test_check_doc_narrative.py` stays green with this arm removed, a declared
# claim in `scripts/tests/mutation_manifest.py` (ersatztv#881). The named file is the
# scope that is actually executed — a claim over every test in the repository would
# be wider than anything re-taking it. The arm is kept because `path` should never
# name a file the added lines do not belong to. A
# `--diff-filter=d` on the git call was removed rather than kept beside it: a second
# mechanism nobody can witness failing is how a duplicate guard hides its twin.
# Git terminates the `+++` filename with a TAB when the path contains a space, and a
# path carrying that tab matches nothing in `is_scanned_path` — the file is dropped
# SILENTLY, the same scanned-0 channel the config pins above close. Witnessed by a
# positive control with a space in the name.
p = p.split("\t", 1)[0]
path = None if p == "/dev/null" else (p[2:] if p.startswith("b/") else p)
elif raw.startswith("+"):
lineno += 1
@@ -212,14 +244,30 @@ def run_diff(base: str, out: list[str]) -> int:
def run_all(out: list[str]) -> int:
# Population from `git ls-files`, never a filesystem walk — an untracked scratch file is not
# part of the corpus (#778).
rc, listing = git("ls-files", "-z", "--", "*.md")
# part of the corpus (#778). The STAGE listing, because the population is tracked REGULAR files
# (mode 100644/100755): a tracked symlink (120000) is a path the predicate admits whose worktree
# content is its TARGET — a directory (`.claude/skills/jellyfin`, a cross-repo skill link) or a
# file that may sit outside the population — and a gitlink (160000) is a submodule pointer with
# no content of its own. `--diff` carries no such filter: a gitlink's diff content is a sha, and
# a symlink's is its target PATH, scanned like any other added line — a phrasing inside a path
# name would draw an advisory warning on the link, which is harmless. The stage listing emits
# one row PER STAGE for an unmerged path, so modes are gathered per path first: a path is opened
# once, and only when EVERY stage is a regular blob — in an add/add conflict between a symlink
# and a regular file, the worktree holds one of them and a per-row test would let the regular
# row authorise opening the other.
rc, listing = git("ls-files", "-s", "-z")
if rc != 0:
print("doc-narrative: could not list tracked files — SCANNED NOTHING.")
return -1
modes: dict[str, set[str]] = {}
for entry in listing.split("\0"):
if not entry:
continue
meta, _, path = entry.partition("\t")
modes.setdefault(path, set()).add(meta.split(" ", 1)[0])
scanned = 0
for path in listing.split("\0"):
if not path or not is_scanned_path(path):
for path, path_modes in modes.items():
if not all(m.startswith("100") for m in path_modes) or not is_scanned_path(path):
continue
try:
with open(path, encoding="utf-8", errors="replace") as fh:
+2 -2
View File
@@ -119,8 +119,8 @@ case $(printf '%s' "$verdict" | jq -r '.verdict // ""' 2>/dev/null || true) in
# NOT given separate arms — because this script's contract publishes exactly five class words and
# `unreadable` already means precisely "no comparison was made", which is true of both.
#
# A separate `unreadable)` arm printing the same word was written first and then deleted: cold
# review measured it as a no-op (removing it left the suite green), and the merge hook's own
# A separate `unreadable)` arm printing the same word is a measured no-op (removing it
# left the suite green), and the merge hook's own
# comment condemns exactly that — an arm no observation can distinguish is a comment with syntax.
# The hook can afford two arms because it has two distinct REASON STRINGS to hand an operator;
# here there is one output word, so a second arm would be decoration.
+3 -3
View File
@@ -127,8 +127,8 @@ comments=$(cat)
# "convention not adopted" and downgrade a hard block into an ask.
#
# Each body is emitted as a JSON STRING on its own line (newlines escaped by JSON), so comment
# boundaries are carried out-of-band. An earlier version joined bodies with a literal sentinel line;
# a comment containing that sentinel could forge a boundary, reset fence state mid-body, and expose a
# boundaries are carried out-of-band. Joining bodies with a literal sentinel line instead would let
# a comment containing that sentinel forge a boundary, reset fence state mid-body, and expose a
# verdict that was still inside an unclosed fence. In-band delimiters are forgeable by whoever writes
# the data — and here that is anyone who can comment on the PR.
# The shape is asserted IN jq so a payload that isn't an array of comment objects with STRING bodies
@@ -189,7 +189,7 @@ verdicts=$(printf '%s\n' "$encoded" | while IFS= read -r encoded_body; do
# A shorter or different marker while a fence is open is content, so it neither closes nor prints.
outside=$(printf '%s\n' "$body" | awk '
{
# Raw HTML blocks are the third code-block form (#629 round 5): <pre>, <code> and HTML
# Raw HTML blocks are the third code-block form (#629): <pre>, <code> and HTML
# comments all render their contents literally, so a verdict inside one is an example, not an
# approval. Tracked as a simple depth/marker count rather than parsed — the direction of error
# is to strip MORE, which can only ever withhold approval.
+14 -9
View File
@@ -63,16 +63,21 @@ if [ "$merge_tree" != "$pr_tree" ]; then
fi
# Merge tree matches the PR head tree exactly. Confirm that PR head was actually validated green
# before trusting it -- query the Gitea combined commit status API. Auth is required (private
# instance); a missing/failing/non-success response always falls through to skip=false.
if [ -z "${ETV_STATUS_AUTH:-}" ]; then
echo "ETV_STATUS_AUTH not set; cannot verify PR head status; running full validation (safe default)"
emit false
exit 0
fi
# before trusting it -- query the Gitea combined commit status API.
#
# THE READ IS ANONYMOUS (ersatztv#885, `ci.pr-route-carries-no-stored-credential`). The three jobs
# that call this run on the `pull_request` route as well as on the main push, and Gitea resolves a
# `pull_request` run from the PR HEAD -- so those jobs may hold no stored secret, and the
# `ETV_STATUS_AUTH` they used to carry is gone. `timothy/ersatztv` is public and answers this GET
# unauthenticated (measured 2026-09-04: HTTP 200 carrying the combined state). No credential
# override is kept in its place: the URL below names ONE instance, that instance is public, so an
# override would be a code path with no caller -- and an unusable one (":", the shape an absent
# secret interpolates to) would draw a 401 and turn a working read into a permanent skip=false.
#
# Every failure direction here is safe: a missing, failing or non-success response falls through to
# skip=false, which re-runs validation. Nothing about this step can cause a skip that was not earned.
status_url="http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv/commits/${pr_head}/status"
status_json="$(curl -sf -u "$ETV_STATUS_AUTH" "$status_url" || true)"
status_json="$(curl -sf "$status_url" || true)"
if [ -z "$status_json" ]; then
echo "status API request for PR head ${pr_head} failed; running full validation (safe default)"
emit false
+8 -8
View File
@@ -2,8 +2,8 @@
# Prove — at runtime, every run — that the delimiter ban actually DETECTS a delimiter (ersatztv#767).
#
# WHY THIS EXISTS AS A SHELL STEP RATHER THAN A TEST. The `scan` job's guarantee used to be a pytest
# that asserted the ban command works. Review then disarmed the whole gate with ONE new file at the
# repo root — a `pytest.ini` carrying `addopts = -k "not delimiter_banned"`, or a `conftest.py` with
# that asserted the ban command works. DEMONSTRATED: ONE new file at the repo root disarms the whole gate — a
# `pytest.ini` carrying `addopts = -k "not delimiter_banned"`, or a `conftest.py` with
# `pytest_collection_modifyitems` — which deselects the ban test AND the tests guarding it, leaving
# every job green while a delimiter sits in `build`'s `Smoke` body and the image publishes. That tier
# sits above every `env:` a test can reconstruct, and above the tests themselves: anything living
@@ -11,8 +11,8 @@
#
# IT RUNS IN THE REAL CHECKOUT, NOT A COPY, and that is the whole point. An isolated copy does not
# inherit the repo-root `pytest.ini`/`conftest.py`/`pyproject.toml` that a disarm would live in, so a
# copy-based proof reports the ban healthy while the job's actual invocation is deselected — measured,
# and it is why the first version of this script was wrong. Same cwd, same rootdir, same config and
# copy-based proof reports the ban healthy while the job's actual invocation is
# deselected — measured. Same cwd, same rootdir, same config and
# the same BAN INVOCATION as the run being vouched for — not the same command line, since the
# workflow step also runs the wiring tests and this runs only the ban file. Only the workflow file
# differs, and only for a moment.
@@ -34,8 +34,8 @@
# passing (the ban is not enforcing), pytest failing for a NON-test reason (collection error, nothing
# collected — cannot prove anything), and pytest failing on some OTHER test while the ban test itself
# was deselected. Only the ban test's `[build]` parametrisation failing counts as proof. Both weaker
# readings were live bugs in earlier drafts of this file: "non-zero means it noticed" reported OK on a
# collection error, and a later draft still fell through to OK when an unrelated test reddened.
# readings have been live bugs here: "non-zero means it noticed" reported OK on a
# collection error, and the other still fell through to OK when an unrelated test reddened.
#
# NOTE ON THE OPENER: this file must never contain the literal two-character expression opener, both
# because `scan`'s own run bodies are asserted delimiter-free and because a file containing it is the
@@ -81,8 +81,8 @@ detected=$?
set -e
# THREE OUTCOMES, EXHAUSTIVELY, and only the last one is a pass. "Non-zero means it noticed" is not
# good enough — that was this script's own first bug, and an earlier draft of this very block still
# fell through to OK when pytest exited 1 for a DIFFERENT test's failure while the ban test was
# good enough, and neither is "exit 1 means the ban test failed": both fall through to
# OK when pytest exits 1 for a DIFFERENT test's failure while the ban test was
# deselected. Requiring the exact expected failure is what makes this a proof rather than a liveness
# check. (`[build]` because the ban is parametrised per job, and `build` is the one whose dropped
# `Smoke` publishes an unsmoked image.)
+9 -9
View File
@@ -48,8 +48,8 @@
# `review-verdict.yml`, which runs WITHOUT a `container:`. `test` and `migrations` run INSIDE the CI
# toolchain image, so their `/tmp` is the job container's own and starts empty. That follows from
# `container:`, NOT from a measurement: the build-lane probe confirmed only that `RUNNER_TEMP` is
# `/tmp` here (the marker landed at `/tmp/etv-ci-steps-ran-test-1910-1`) — it says nothing about the
# directory being private or empty, and an earlier draft of this comment cited it as though it did.
# `/tmp` here (the marker landed at `/tmp/etv-ci-steps-ran-test-1910-1`) — it says nothing about
# the directory being private or empty.
# The fresh container is what actually rules out a stale marker here; the keying is defence in depth.
#
# It is kept because container-per-job is a property of how the lane is configured today, not a
@@ -74,8 +74,8 @@ EOF
exit 2
}
# NO SILENT FALLBACK FOR THE RUN IDENTITY — found by cold review. The first version defaulted to
# `nojob`/`norunid`/`1`, and those are REUSABLE: with `GITHUB_RUN_ID` unset, every run on the host
# NO SILENT FALLBACK FOR THE RUN IDENTITY. Defaulting to `nojob`/`norunid`/`1` is
# REUSABLE: with `GITHUB_RUN_ID` unset, every run on the host
# would share ONE marker file, so a leftover from any earlier run would satisfy the guard on a run
# whose step was dropped. A silent PASS — the exact failure the keying exists to remove, reintroduced
# by the code meant to implement it.
@@ -95,8 +95,8 @@ EOF
# then simply read off run 1916: `Marker identity: job=test run=1916 attempt=1 (from the runner)`
# and the same for `migrations`. Both required jobs, on the lane that matters.
#
# That measurement is what promoted it from warn-and-default to REQUIRED, which is why the residual
# this comment used to describe — a rerun inheriting attempt 1's markers — no longer exists FOR THE
# That measurement is what promoted it from warn-and-default to REQUIRED, which is why the rerun
# residual — a rerun inheriting attempt 1's markers — no longer exists FOR THE
# CONTAINER JOBS. If a future runner stops exporting any of the three, every job reddens with a
# message naming the variable; that is loud, instantly diagnosable, and the correct direction for a
# required check.
@@ -117,8 +117,8 @@ marker_path() {
if [ -n "$missing" ]; then
# NOTHING IS PRINTED TO STDOUT HERE, and that is load-bearing rather than style: this
# function's stdout IS its return value (it is always called inside `$( )`), so a notice
# printed here is captured INTO the path. An earlier revision did exactly that and both
# sub-commands then failed on a nonexistent directory. Caught by
# printed here is captured INTO the path. Printing one makes both
# sub-commands fail on a nonexistent directory. Pinned by
# test_a_degraded_run_IDENTITY_*, which is why that test asserts on the exit status and on
# the absence of any marker file rather than only on the message.
echo "::error::ci-step-ran.sh cannot identify this run —${missing} not set. The marker path would fall back to a name other runs also use, and a stale marker would make the dropped-step guard PASS on a run whose step never executed (ersatztv#756). Refusing rather than degrading to a reusable name." >&2
@@ -200,7 +200,7 @@ cmd_assert() {
echo "Skip gate fired (docs_only='${ETV_DOCS_ONLY:-}', already_validated='${ETV_REVALIDATE_SKIP:-}') — the gated steps were not expected to run."
fi
# RE-CHECKED AFTER GATING, not only on argv — found by cold review, which reproduced it:
# RE-CHECKED AFTER GATING, not only on argv — reproduced:
# `ETV_DOCS_ONLY=true … assert --always --gated foo` printed "All 0 expected step(s) executed"
# and exited 0. The argv check above cannot see that, because the set is emptied by the gate, not
# by the caller. Unreachable with today's argv (both jobs pass `--always detect revalidate`), but
+192 -42
View File
@@ -3,7 +3,8 @@
#
# WHY THIS EXISTS. `docker-build.yml` pins its five `container:` jobs to an immutable
# `ersatztv-ci:<sha>`. Between 2026-08-11 and 2026-08-13 that tag was deleted from the Gitea
# registry and every one of those jobs — including BOTH required contexts — died after 1-2s with
# registry and every one of those jobs — including BOTH required contexts — died at image pull,
# before running a step, with
#
# Error response from daemon: failed to resolve reference "…/ersatztv-ci:<the pinned sha>": not found
#
@@ -18,24 +19,42 @@
# failure into one actionable message, which is all a consumer of someone else's registry can do.
#
# WHY IT DOES NOT GATE THE CONTAINER JOBS with `needs:`. Serialising five jobs behind a checkout +
# one curl would tax every green run to speed up the rare red one, and the container jobs already
# fail fast (1-2s) when the pull fails. This runs in PARALLEL: the diagnosis is present the moment
# anyone looks, and the happy path pays nothing.
# one curl would tax every green run to speed up the rare red one, and 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.
# This runs in PARALLEL: the diagnosis is present the moment anyone looks, and the happy path pays
# nothing.
#
# UNKNOWN IS NOT A PASS, and this is where the first draft was wrong. It warned and exited 0 on
# every answer that was not 200 or 404, which makes "curl is missing from this runner", "the
# UNKNOWN IS NOT A PASS. Warning and exiting 0 on every answer that is not 200 or
# 404 makes "curl is missing from this runner", "the
# registry moved", and "DNS changed" all indistinguishable from a healthy pin — a job that is green
# forever having checked nothing, in a file whose header claims the opposite. Unknown answers are
# RETRIED (they are usually transient) and then FAIL. The message stays distinct from the deleted
# case: "could not verify" and "IS GONE" send an operator to different places.
#
# Env (all optional except the credential; the defaults are the live values):
# IT READS THE REGISTRY ANONYMOUSLY, AND THAT IS LOAD-BEARING RATHER THAN INCIDENTAL. This job runs
# on the `pull_request` route, where the workflow YAML is supplied by the PR head, so it may hold no
# stored secret (ersatztv#885, `ci.pr-route-carries-no-stored-credential`). A naive unauthenticated
# GET of a manifest is a 401 for every tag, present or deleted — which is why this used to demand a
# credential — but that 401 is a Bearer CHALLENGE, and this registry issues an anonymous pull token
# for a public package against it. Measured 2026-09-04: the token endpoint answers 200 with no
# credential, that token reads the pinned manifest and its config blob (200), and a tag that does
# not exist answers 404 rather than 401 — so the deleted-tag diagnosis this whole script exists for
# survives the change. What does NOT survive it is the `ersatztv-ci` PACKAGE ceasing to be
# anonymously pullable: the token leg then refuses, and this fails loudly with a message that names
# that cause rather than reporting could-not-tell. That is the package and not this repo — the
# package is linked to no repository (measured 2026-09-05: every version of it reports
# `"repository": null`), so `timothy/ersatztv` going private would not take the pull token away.
# What that WOULD take away is the unauthenticated commit-status GET in
# scripts/ci-detect-already-validated.sh, which is a different dependency with the opposite failure
# direction: it falls through to `skip=false` and stays green. Do not cite this script's loudness
# for that one.
#
# Env (all optional; the defaults are the live values):
# ETV_CI_REGISTRY registry host:port (default 192.168.1.95:3000)
# ETV_CI_IMAGE_REPO package path inside the registry (default timothy/ersatztv-ci)
# ETV_CI_WORKFLOW workflow file to read the pin from (default .gitea/workflows/docker-build.yml)
# ETV_CI_ATTEMPTS tries per pin before an unknown becomes a failure (default 3)
# ETV_CI_RETRY_SECONDS pause between those tries (default 5)
# ETV_REGISTRY_AUTH user:pass — REQUIRED; the registry rejects anonymous reads with 401
set -euo pipefail
registry="${ETV_CI_REGISTRY:-192.168.1.95:3000}"
@@ -52,60 +71,157 @@ fail() { printf '::error::ci-toolchain-image-resolves: %s\n' "$*" >&2; exit 1; }
pins=$(grep -oE 'ersatztv-ci:[0-9a-f]+' "$workflow" | cut -d: -f2 | sort -u || true)
[ -n "$pins" ] || fail "no ersatztv-ci pin found in $workflow — if the grep pattern stopped matching, fix it here and in pr-checks.yml::ci-image-pin together"
# No credentials is NOT a pass. An unauthenticated read of this registry is a 401 for every tag,
# present or deleted, so a run without them would report "cannot tell" for a live pin and for a
# deleted one alike — the shape where a guard reports green having checked nothing.
#
# The EMPTY-halves check is the one that matters in CI and is easy to miss: an absent secret does
# not arrive here as an unset variable. `ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ ... }}`
# interpolates a missing secret to the empty string, so the job passes the non-empty string ":".
# Testing only the unset case would leave the production shape uncovered.
auth="${ETV_REGISTRY_AUTH:-}"
[ -n "$auth" ] || fail "ETV_REGISTRY_AUTH (user:pass) is unset, so the registry cannot be queried — this check refuses to report a pass it did not establish"
case "$auth" in
*:*) ;;
*) fail "ETV_REGISTRY_AUTH must be user:pass, got a value with no ':' — the registry cannot be queried and this check refuses to report a pass it did not establish" ;;
esac
[ -n "${auth%%:*}" ] && [ -n "${auth#*:}" ] \
|| fail "ETV_REGISTRY_AUTH has an empty half (user or password) — this is what an ABSENT REGISTRY_USER/REGISTRY_PASSWORD secret interpolates to, not a credential. Fix the secrets rather than reading an unauthenticated 401 as could-not-tell."
accept='application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json'
attempts="${ETV_CI_ATTEMPTS:-3}"
retry_seconds="${ETV_CI_RETRY_SECONDS:-5}"
rc=0
# One GET, returning "<code> <is-a-manifest>". The body is fetched rather than a HEAD sent, because
# The anonymous pull token, acquired once and reused for every pin. Empty until the registry
# challenges. `token_leg_done` separates "not tried yet" from "tried", which is what lets the
# refusal arms below report what the run actually DID rather than what it might have done.
#
# `token_leg_retryable` separates the two ways the leg can come back empty, and they are not the
# same event. A token endpoint that ANSWERED and named no token — or a challenge naming no realm, or
# no challenge at all — has told us something, and asking it again cannot change the answer: that is
# the registry genuinely refusing anonymous reads, and it is asked once per RUN. A token endpoint
# that could not be REACHED, or that answered 5xx, told us nothing; it is the same transport blip the
# manifest read absorbs, so it is retried on the same `ETV_CI_ATTEMPTS` budget. Without that split
# the two legs of one read had opposite flake tolerances — measured 2026-09-05 on the predecessor of
# this commit with `ETV_CI_ATTEMPTS=3`, an unreachable token endpoint failed after 1 token call and
# 1 manifest call while a flaky manifest read got 3 retries — and this job's red denies a merge (the
# consent hook reads the COMBINED status, ersatztv#598), so a one-second token-endpoint outage
# blocked a merge until someone re-ran it.
token=""
token_leg_done=0
token_leg_retryable=0
token_leg_attempts=0
headers=$(mktemp)
trap 'rm -f "$headers"' EXIT
# One directive out of a `Www-Authenticate: Bearer realm="…",service="…"` challenge. The realm is
# read from the challenge rather than assumed, so a registry that moves its token endpoint is
# followed instead of guessed at; `service` is optional (this registry issues a token without it,
# measured 2026-09-04) and is passed through when the challenge names one.
#
# The directive NAME is matched in any case, through a character class generated from the key, and
# the header name likewise (below): RFC 7235 §2.1 makes auth-param names case-insensitive and RFC
# 9110 §5.1 makes field names case-insensitive, so a registry answering `WWW-Authenticate: Bearer
# Realm="…"` is spelling this legally. Matching one spelling would send that challenge down the
# "named no realm" arm and `fail` the job with the wrong diagnosis — loud, but pointing an operator
# at a token endpoint that is fine. The VALUE keeps its case: a realm URL is case-sensitive.
challenge_field() {
local key="$2" pattern="" index char
for ((index = 0; index < ${#key}; index++)); do
char=${key:index:1}
pattern="${pattern}[${char}$(printf '%s' "$char" | tr '[:lower:]' '[:upper:]')]"
done
printf '%s' "$1" | sed -n "s/.*[ ,]$pattern=\"\([^\"]*\)\".*/\1/p" | head -1
}
# The token leg. Returns non-zero on every shape that leaves us without a bearer — no challenge, no
# realm in it, a token endpoint that will not answer, or an answer carrying no token. Each of those
# is "could not establish anonymous access", which the caller turns into a REFUSAL rather than a
# could-not-tell: an empty token would otherwise fall through to a second 401 and read as an
# ordinary auth failure with no cause named. It also SORTS them, into `token_leg_retryable`: only
# the shapes where the endpoint said nothing at all are worth asking again.
acquire_token() {
local challenge realm service url resp body http
token_leg_done=1
token_leg_retryable=0
token_leg_attempts=$((token_leg_attempts + 1))
challenge=$(tr -d '\r' < "$headers" |
awk -F: 'tolower($1) == "www-authenticate" { sub(/^[^:]*:[[:space:]]*/, ""); print; exit }')
[ -n "$challenge" ] || return 1
realm=$(challenge_field "$challenge" realm)
[ -n "$realm" ] || return 1
service=$(challenge_field "$challenge" service)
url="$realm?scope=repository:$image_repo:pull"
# Spelled as a full `if` rather than `[ … ] && …`: as a bare statement the latter returns the
# test's exit status, which is 1 whenever `service` is absent — a legal challenge shape.
if [ -n "$service" ]; then url="$url&service=$service"; fi
# The status code is read for the same reason the manifest read reads it: it is the only thing
# that separates "this endpoint REFUSED" from "this endpoint was not there", and only the second
# is worth a second ask. A `curl` that fails outright (DNS, connect, reset) leaves no response at
# all and lands in the same class.
resp=$(curl -s -w '\n%{http_code}' "$url") || resp=""
if [ -z "$resp" ]; then
token_leg_retryable=1
return 1
fi
http=${resp##*$'\n'}
body=${resp%$'\n'*}
case "$http" in
000|5??) token_leg_retryable=1; return 1 ;;
esac
token=$(printf '%s' "$body" | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
[ -n "$token" ] || return 1
return 0
}
# One GET, recording the HTTP code and whether the body is a manifest. The body is fetched rather
# than a HEAD sent, because
# HTTP 200 alone does not mean "the manifest is there": a proxy, a captive login page or an error
# document all answer 200 with a body that is not a manifest, and a check that reads only the status
# line reports those as "resolves". A manifest always carries `schemaVersion`, so the body is matched
# for it — with a shell `case`, so nothing depends on jq being installed and no pipeline can invert
# the result on a large body.
probe() {
local url="$1" resp code body
# `-u` puts the credential in argv, visible to `ps` for the length of the call — and this job has
# no `container:`, so that is the shared host. Kept because it is the shape every other curl caller
# in scripts/ already uses (`ci-detect-already-validated.sh`, `pr-changed-files.sh`,
# `select-queue.sh`, `issue-qualification-audit.sh`): fixing one site would leave the class intact
# and the codebase inconsistent. The class is tracked in ersatztv#821.
resp=$(curl -s -w '\n%{http_code}' -u "$auth" -H "Accept: $accept" "$url") || resp=""
[ -n "$resp" ] || { printf '000 no\n'; return 0; }
#
# It ASSIGNS `code`/`is_manifest` rather than printing them, and so does `probe` — because the token
# and the "already tried the token leg" flag must survive from one pin to the next, and a
# `$(probe …)` command substitution runs in a SUBSHELL whose assignments are discarded. Reading the
# answer through a subshell would silently re-run the whole two-leg exchange for every pin.
request() {
local url="$1" resp body
if [ -n "$token" ]; then
resp=$(curl -s -w '\n%{http_code}' -D "$headers" -H "Authorization: Bearer $token" -H "Accept: $accept" "$url") || resp=""
else
resp=$(curl -s -w '\n%{http_code}' -D "$headers" -H "Accept: $accept" "$url") || resp=""
fi
if [ -z "$resp" ]; then
code="000"
is_manifest="no"
return 0
fi
code=${resp##*$'\n'}
body=${resp%$'\n'*}
case "$body" in
*'"schemaVersion"'*) printf '%s yes\n' "$code" ;;
*) printf '%s no\n' "$code" ;;
*'"schemaVersion"'*) is_manifest="yes" ;;
*) is_manifest="no" ;;
esac
}
# The two legs of an anonymous registry read: the plain GET, and — only if it is challenged and the
# leg is not already settled — the token exchange followed by ONE retry carrying the bearer. A 401
# that survives the retry is left as a 401 and refused by the caller; it is never rounded off to
# could-not-tell, because the cause (this package is no longer readable without a credential) sends
# an operator somewhere quite different from a flaky registry.
#
# "Settled" is `token_leg_done` AND NOT `token_leg_retryable`: a leg abandoned because its endpoint
# could not be reached settled nothing, so the next attempt asks again.
probe() {
request "$1"
if [ "$code" = "401" ] && { [ "$token_leg_done" -eq 0 ] || [ "$token_leg_retryable" -eq 1 ]; }; then
if acquire_token; then
request "$1"
fi
fi
}
code=""
is_manifest="no"
for pin in $pins; do
url="http://$registry/v2/$image_repo/manifests/$pin"
attempt=1
while : ; do
read -r code is_manifest <<EOF
$(probe "$url")
EOF
probe "$url"
case "$code" in
200|404|401|403) break ;;
200|404|403) break ;;
401)
# A 401 is an answer — unless the only reason it still stands is a token leg whose endpoint
# could not be reached, in which case it is an unknown wearing the first read's status code
# and belongs in the retry class below with every other unknown.
if [ "$token_leg_retryable" -eq 0 ]; then break; fi
;;
esac
# Only the unknown answers are retried: 200/404 are answers, and an auth failure will not cure
# itself. A transient registry is the common case for the rest, and absorbing it here is what
@@ -134,7 +250,41 @@ EOF
401|403)
# `fail` rather than `rc=1`: unlike a 404, this says nothing about the pin, and it will say
# the same thing about every remaining one. Abandoning the loop keeps the log to one cause.
fail "the registry rejected these credentials (HTTP $code) for $registry/$image_repo:$pin, so the pin could not be checked. Fix REGISTRY_USER/REGISTRY_PASSWORD rather than reading this as a pass."
#
# THREE ways to get here, and they send an operator to three different places, so they are
# worded apart — the same reason `404` and `could NOT VERIFY` are. Each message states only
# what actually ran, because a message naming a step that did not happen is evidence for a
# diagnosis nobody performed:
#
# * a refusal that survived a GOOD token is an answer about this PACKAGE, and since
# ersatztv#885 it is not a preflight-only problem: every `container:` job pulls the same
# image with no credential, so they fail at image pull too, including both required
# contexts;
# * a challenge that yielded no usable token is an infrastructure answer about the TOKEN
# ENDPOINT;
# * a FIRST-READ 403 never reached the token leg at all — `probe` enters it on a 401 only —
# so nothing was ever asked for. This is an answer about ACCESS to the registry. Its
# message says NO TOKEN WAS EVER REQUESTED rather than "the registry issued no challenge":
# a 403 MAY carry a `Www-Authenticate` and this script would still not follow it, so only
# the first is something the run observed. Note the boundary too: a 401 carrying no
# challenge DOES enter the token leg and abandon it, so it lands in the arm above, not
# this one. The branch order below is `token` first precisely so this case cannot borrow
# either of the other two mechanisms.
#
# A token leg that ANSWERED is not retried, deliberately: `token_leg_done` is set before the
# attempt and `token_leg_retryable` stays 0, so a registry genuinely refusing anonymous reads
# is asked once per RUN rather than once per pin or once per attempt. A token endpoint that
# could not be REACHED settled nothing and IS retried, on the same `ETV_CI_ATTEMPTS` budget the
# manifest read uses — the two legs of one read must not have opposite flake tolerances when a
# red here denies a merge. The message below reports how many token-leg attempts actually ran,
# so the two cases are distinguishable in the log rather than only in this comment.
if [ -n "$token" ]; then
fail "the registry refused an ANONYMOUS read (HTTP $code) of $registry/$image_repo:$pin even after a Bearer token was obtained, so the pin could not be checked. Every container: job pulls this image without a credential too, so they will fail at image pull. Check that the ersatztv-ci package is still PUBLIC (it is linked to no repository, so this is the package's own visibility, not the repo's) — do not read this as a pass."
fi
if [ "$token_leg_done" -eq 1 ]; then
fail "could NOT OBTAIN an anonymous pull token for $registry/$image_repo:$pin after $token_leg_attempts token-leg attempt(s) — the registry answered HTTP $code and the token leg produced none: either there was no Www-Authenticate challenge, or it named no realm, or the token endpoint did not answer with a token. The pin was NOT checked. Look at the registry's token endpoint, not at the pin."
fi
fail "the registry refused an ANONYMOUS read (HTTP $code) of $registry/$image_repo:$pin and NO TOKEN WAS EVER REQUESTED: the token leg is entered on a 401 only, so this answer was never followed as a Bearer challenge and the pin could not be checked. An outright refusal is an answer about ACCESS to the registry, not about the pin. Every container: job pulls this image without a credential too, so they will fail at image pull. Check that the ersatztv-ci package is still PUBLIC (it is linked to no repository, so this is the package's own visibility, not the repo's), and that nothing (a proxy, an ACL) is answering for the registry — do not read this as a pass."
;;
*)
# NOT gone, and NOT a pass either. Deliberately worded apart from the 404 message: this sends
+4 -5
View File
@@ -58,7 +58,7 @@ RECORD_CEILING_DEFAULT = 60
# it is cutting into the bulk rather than marking a tail. See `ceiling_calibration` for why the fine
# percentile claim is reported instead of asserted.
#
# The floor is NOT "at least one record" — that was the first draft and it was nearly unfalsifiable:
# The floor is NOT "at least one record" — that is nearly unfalsifiable:
# measured on the live corpus it accepted every ceiling from 39 to 229, including the ceiling of 200
# this module's own docstring offered as the case it catches (one 230-line record keeps the count
# nonzero). A 2% floor rejects 200/229/230 and still leaves ~5x headroom below today's 9.8%.
@@ -441,10 +441,9 @@ def record_wing_files(records_dir: Path | None = None, archive_dir: Path | None
`RECORDS_DIR.rglob` so such a file IS a corpus source while being exempt from the guard.
So the exemption is by exact RELATIVE PATH, never by basename. The only entry is
`archive/README.md`, a hand-written directory README that really does exist an earlier
version of this function excluded any wing-root `README.md` "since no such file exists today",
which was simply false and would additionally have exempted a future `records/README.md`, i.e.
reintroduced the very hole one directory over.
`archive/README.md`, a hand-written directory README that really does exist excluding any
wing-root `README.md` "since no such file exists today" is simply false, and would additionally
exempt a future `records/README.md`, i.e. reintroduce the very hole one directory over.
"""
records_dir = dl.RECORDS_DIR if records_dir is None else records_dir
archive_dir = dl.ARCHIVE_DIR if archive_dir is None else archive_dir
+65 -20
View File
@@ -37,12 +37,14 @@
# etv_hook_fire_begin <hook-name> [label]
#
# SELF-LOCATED, and the `${CLAUDE_PROJECT_DIR:-...}` form this recipe used to show is BANNED —
# `process.hook-resolves-inputs-from-repo-root` (ersatztv#858). This line is SOURCED, so whatever it
# names runs as code inside the hook before the hook decides anything; a file of this name in an
# env-var-designated tree can print a decision and exit 0. Measured on the merge-consent hook: it
# granted the merge outright. The recipe is corrected here because the header is what a new hook
# copies, so leaving it would keep minting the defect after ersatztv#891 sweeps the existing hooks.
# Twelve hooks still carry the old form; that sweep is #891, not this line.
# `process.hook-resolves-inputs-from-repo-root` (ersatztv#858, #891). This line is SOURCED, so
# whatever it names runs as code inside the hook before the hook decides anything; a file of this
# name in an env-var-designated tree can print a decision and exit 0. Measured on the merge-consent
# hook: it granted the merge outright. The recipe matters because the header is what a new hook
# copies, so a wrong one here keeps minting the defect. #891 swept the rest: every tracked hook now
# carries this line byte-identically, and `test_hook_fire_log.py` — whose population comes from
# `git ls-files`, so a new hook is covered the moment it exists — fails any that does not. The read
# side below self-locates for the same reason.
#
# USAGE (read side):
# scripts/hook-fire-log.sh report # this session (or every session, see below)
@@ -90,8 +92,8 @@ etv_hook_fire_scrub() {
# A STRICTER scrub for the one value that becomes a PATH. The record scrub above deliberately keeps
# `/` and `.` — tool names like `mcp__gitea__x` and event paths read better with them — but those
# are exactly the two characters that turn a session id into `../../escaped`. Passing a value
# through a scrub is not the same as passing it through the RIGHT scrub, and the first version of
# this fix reused the record scrubber and left the traversal wide open while reading as fixed.
# through a scrub is not the same as passing it through the RIGHT scrub: reusing the record
# scrubber here leaves the traversal wide open while reading as fixed.
etv_hook_fire_scrub_component() {
printf '%s' "${1:-}" | tr -c 'A-Za-z0-9_-' '_' | cut -c1-120
}
@@ -166,8 +168,8 @@ etv_hook_fire_classify() {
fi
[ -z "$d" ] && d=$(printf '%s' "$out" | sed -n 's/.*"decision"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
# A NON-CANONICAL VALUE IS RECORDED AS SUCH, not normalised into a valid one. An earlier version
# lowercased whatever it found, so `"permissionDecision":"Deny"` was filed as a clean `deny` —
# A NON-CANONICAL VALUE IS RECORDED AS SUCH, not normalised into a valid one. Lowercasing
# whatever is found files `"permissionDecision":"Deny"` as a clean `deny` —
# manufacturing a decision the harness may never have honoured. The documented values are
# lowercase; anything else is a hook bug, and the log should say so rather than launder it.
case "$d" in
@@ -403,9 +405,8 @@ etv_hook_fire_flush() {
# rescue path delivered 52 bytes where the hook wrote 53, and every JSON parser downstream
# accepts the short form without complaint. Pinned by `test_stdout_is_replayed_BYTE_EXACT`.
#
# NO `2>/dev/null` ON THE `exec` — see the stdin comment. An earlier version of THIS line had
# it, eight lines below the comment forbidding it, which is why the rule is now restated at
# every `exec` rather than once.
# NO `2>/dev/null` ON THE `exec` — see the stdin comment. The rule is restated at every `exec`
# rather than once because it has been violated eight lines below a single statement of it.
if [ -r "${ETV_HOOK_FIRE_STDOUT_TMP:-}" ]; then
# Common path: stream the FILE straight through. A shell variable cannot hold a NUL byte, so
# replaying via `$(...)` silently drops them and warns on stderr; `cat` is byte-exact for any
@@ -452,9 +453,16 @@ etv_hook_fire_end() {
# Read side: the report
# ---------------------------------------------------------------------------------------------
# The hook population, DERIVED from the filesystem, never listed
# (`testing.guard-derives-population-from-source`). A hook added tomorrow appears in the report as
# The hook population, DERIVED rather than listed. A hook added tomorrow appears in the report as
# NEVER-FIRED the moment it exists, rather than being invisible because nobody updated an array.
#
# A FILESYSTEM WALK, and that is the carve-out rather than the rule.
# `testing.guard-derives-population-from-source` requires the GIT INDEX for a guard's file
# population and allows a walk only with its reason written down, so: this is the REPORT, not a
# gate — it describes what is on disk in the tree it was run from, and an untracked scratch hook
# that really does fire is a row a reader wants to see. The guard over these same hooks
# (`test_hook_fire_log.py`) uses `git ls-files`, and the two populations are deliberately not the
# same one.
etv_hook_fire_population() {
local root="${1:-}" f
[ -d "$root/.claude/hooks" ] || return 0
@@ -464,12 +472,41 @@ etv_hook_fire_population() {
done
}
# SELF-LOCATED, for the same reason the write side is (ersatztv#891,
# `process.hook-resolves-inputs-from-repo-root`). This used to prefer `$CLAUDE_PROJECT_DIR` when it
# named a tree with a `.claude/hooks`, which read as harmless because the report decides nothing.
# It is not: the report's two halves are the POPULATION (from this root) and the LOG (from
# `$ETV_HOOK_FIRE_LOG_DIR`). Resolving them from two different roots is how a report earns a
# NEVER-FIRED row for a hook that fired, or omits a hook that exists — a confident wrong answer
# about another checkout, in a table whose entire purpose is to replace inference with measurement.
# The env var bought nothing the fallback did not: self-location is what already makes
# `scripts/hook-fire-log.sh report` work from any directory.
#
# A WRONG ROOT CANNOT BECOME A PLAUSIBLE REPORT, and that is enforced below rather than hoped for:
# the root must own this file. Without that, self-location alone still resolves somewhere, and a
# root that happens to hold a `.claude/hooks` would be reported on confidently. (`$PATH` invocation
# is NOT such a case, measured 2026-08-30: bash sets `${BASH_SOURCE[0]}` to the resolved path, so
# `PATH=…/scripts hook-fire-log.sh report` self-locates correctly.)
etv_hook_fire_repo_root() {
if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "$CLAUDE_PROJECT_DIR/.claude/hooks" ]; then
printf '%s' "$CLAUDE_PROJECT_DIR"
return 0
fi
( cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." >/dev/null 2>&1 && pwd )
local self root
self="${BASH_SOURCE[0]}"
root="$( cd -- "$(dirname -- "$self")/.." >/dev/null 2>&1 && pwd )" || return 0
# AND THE ROOT MUST OWN THIS FILE. Self-location alone answers "two directories up from wherever
# I am", which is this repo only when this file sits in it. A copy or symlink whose grandparent
# happens to hold a `.claude/hooks` otherwise roots there and prints a confident table about
# another tree's hooks. The shape is ordinary — `$HOME` commonly holds a `.claude/hooks` (it does
# on this machine), so anything dropped in a `$HOME/<dir>/` roots at `$HOME` — and it is the
# SILENT direction, since the wrong answer is a plausible one. Requiring the root's own sink to BE
# this file makes that case return nothing, which `etv_hook_fire_report` refuses with exit 2.
# `-ef` AND NOT `-e`, because identity is strictly stronger than presence. A mere `-e` already
# rejects the common shape above — `$HOME` holds no `scripts/hook-fire-log.sh`, so presence fails
# and the report refuses. What only `-ef` rejects is the narrower shape where the wrong root DOES
# hold a sink of that name: `-e` accepts it and prints a confident table about that other tree.
# Proven by `test_the_report_REFUSES_a_root_that_does_not_OWN_this_sink`. Of its three arms only
# `other-sink` distinguishes the two operators; the copy and symlink arms are the shapes that
# actually occur, and a weakened `-e` still passes them — so they cannot be the proof.
[ "$root/scripts/hook-fire-log.sh" -ef "$self" ] || return 0
printf '%s' "$root"
}
etv_hook_fire_report() {
@@ -502,6 +539,14 @@ etv_hook_fire_report() {
# ANTI-VACUITY. A report over an empty population would print a clean table of nothing and read
# as "all hooks accounted for" — the exact failure this file exists to end.
#
# THE TWO REASONS ARE REPORTED SEPARATELY. An empty `$root` means the ownership check above
# declined to name a checkout, not that a checkout was searched and found bare; saying "no hooks
# under /.claude/hooks" for that case is a refusal stating a finding it never made.
if [ -z "$root" ]; then
printf 'hook-fire-log: could not establish which checkout this sink belongs to (%s is not the `scripts/hook-fire-log.sh` of the tree two levels above it) — refusing to report rather than describing some other tree.\n' "${BASH_SOURCE[0]}" >&2
return 2
fi
local pop
pop=$(etv_hook_fire_population "$root")
if [ -z "$pop" ]; then
+5 -5
View File
@@ -109,18 +109,18 @@ fi
# warning line or a date prefix as the version — `2026.07.26 jq-1.6` parsed as 2026.07, which sails
# over the floor. Anchoring keeps every legitimate form (`jq-1.6`, `jq version 1.6`, `jq-1.7.1`,
# `jq-1.6-dirty`, `jq-1.6 (Debian 1.6-2.1)`) and rejects the rest, which then fails closed below.
# FIRST LINE ONLY, and bounded everywhere. Both bounds are load-bearing; this is the third round on
# this one predicate and each previous version failed for a variant of the same reason.
# FIRST LINE ONLY, and bounded everywhere. Both bounds are load-bearing; this is the third narrowing of
# this one predicate and each earlier narrowing failed for a variant of the same reason.
#
# * First line only. `[[:space:]]` matches NEWLINES, so an "anchored" pattern still scanned the
# whole output: `jq\n2.34: cannot load` matched `jq`, crossed the newline as separator, and
# parsed 2.34 — fail-open, the round-2 bug narrowed but not closed. `[[:blank:]]` (space/tab
# parsed 2.34 — the fail-open narrowed but not closed. `[[:blank:]]` (space/tab
# only) plus a first-line slice confines the match to the line that can actually carry a version.
# * Bounded digit runs. This is the round-1 mechanism resurrected. The regex guaranteed the
# * Bounded digit runs. This is the original fail-open mechanism resurrected. The regex guaranteed the
# operands were digits but not that they fit in `test`'s integer range, so a 23-digit major made
# `[ "$major" -lt "$min_major" ]` error with "integer expression expected" — and `set -e` exempts
# a failing command in an `if` condition, so the conditional read false and THE FLOOR WAS NEVER
# ASSERTED, exit 0. Exactly what the empty-string case did in round 1. `{1,9}` keeps every
# ASSERTED, exit 0. Exactly what the empty-string case did before the bound. `{1,9}` keeps every
# operand inside a 32-bit integer, so the comparison can no longer error.
# * Bounded separator runs, so the pattern cannot be walked across arbitrary filler.
first=${raw%%$'\n'*}
+7 -7
View File
@@ -75,15 +75,15 @@
# order is right — precedent for a style is not evidence the behaviour is correct, and this chain
# was wrong about everything else on this line. If a server ever sends two different non-empty
# names, decide then, with the payload in hand.
# THREE outcomes per field, not two, and collapsing the last two is a regression cold review
# caught in the first draft of this fix. A field either supplies a NAME, or SKIPs (it is simply
# THREE outcomes per field, not two, and collapsing the last two is a REGRESSION.
# A field either supplies a NAME, or SKIPs (it is simply
# not carrying one: absent, null, or the empty string Gitea sends for a glob rule), or POISONs
# (it is PRESENT holding a type a name cannot have).
#
# The first draft fell through to `rule_name` on anything that was not a usable string, which
# made a malformed field indistinguishable from an unsupplied one. Measured: with `branch_name`
# Falling through to `rule_name` on anything that was not a usable string
# makes a malformed field indistinguishable from an unsupplied one. Measured: with `branch_name`
# a number, boolean or array beside `rule_name:"main"`, the predecessor THREW and the gate asked,
# while that draft answered `exact` and AUTO-GRANTED — and with `rule_name:"develop"` it answered
# while that fallback answered `exact` and AUTO-GRANTED — and with `rule_name:"develop"` it answered
# `none`, denying on "the full rule list was read and none matches" about a field it never read.
# Both directions of the very defect this file is fixing, reintroduced one shape over.
#
@@ -94,8 +94,8 @@
elif $v == null then {skip: true}
else {poison: true} end;
# POISON IS CHECKED ON BOTH FIELDS BEFORE EITHER NAME IS TAKEN, so the invariant above holds
# field-agnostically. The first draft short-circuited on `branch_name` and only consulted
# `rule_name` as a fallback, which made the rule order-dependent: `{"branch_name":42,
# field-agnostically. Short-circuiting on `branch_name` and only consulting
# `rule_name` as a fallback makes the rule order-dependent: `{"branch_name":42,
# "rule_name":"main"}` poisoned, while `{"branch_name":"main","rule_name":42}` answered `exact`
# off the good field and never looked at the malformed one. Gitea 1.27.1 does not send that
# second shape — a plain rule sets both fields equal, a glob rule sets `branch_name` to `""` —
+2 -2
View File
@@ -98,8 +98,8 @@ unset _etv_h10_root
# Reset to 0 HERE, at load time, so that "derived successfully" is a DATA dependency the query
# function requires rather than a control-flow step a caller is trusted to have taken.
#
# ATTRIBUTED CAREFULLY, because the obvious second reason is not this line's. An earlier version
# claimed the reset defeats an inherited `ETV_H10_REVIEWERS_OK=1` from the environment, and cited a
# ATTRIBUTED CAREFULLY, because the obvious second reason is not this line's. That reason is that
# the reset defeats an inherited `ETV_H10_REVIEWERS_OK=1` from the environment, backed by a
# measurement showing a hostile environment does not get a verdict past the writer. The measurement
# is real; the conclusion does not follow. Removing this reset produces a byte-identical refusal,
# because `etv_h10_reviewers_load` runs on that path and overwrites both variables anyway. What the

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