Commit Graph
580 Commits
Author SHA1 Message Date
timothyandClaude Opus 5 5648f8e92e docs(616): scope the pageSize cap per endpoint — it is not one number
Self-review of the previous commit caught an overclaim I introduced. I wrote the
paging docs as if the pageSize cap were uniformly 100. It is not:

  most reads (collections, library browse, logs, playouts)  Math.Clamp(.., 1, 100)
  GetAutoTuneChannelMembers                                 pageSize <= 0 ? 100 : Min(.., 200)
  GET /api/v1/search/all-items                              default 500, cap 1000

That made a concrete example in docs/mcp.md simply false. It claimed
`pageSize=500&pageNum=1` returns items 101-200 "not 501-1000" — but
ersatztv_search_all_items also takes Page(), and 500 is UNDER its cap, so there
page 1 really is items 501-1000. A caller following that example on the one tool
most likely to be paged hard would have mis-derived its offsets, which is the
same class of silent-short-set error this issue is about.

The invariant that actually holds everywhere is the derivation, not any single
cap: the offset comes from the EFFECTIVE (bounded) page size, never the
requested one. Reworded to say that, in docs/mcp.md, the api.paging-zero-based
record (rule + mechanics + body), and the ToolCatalog Page() comment. Catalog
regenerated. The record's mechanics line no longer claims every controller uses
Math.Clamp — ChannelController does not.

No behaviour change; MCP suite still 59/59.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:24:40 +02:00
timothyandClaude Opus 5 8d35a2792f fix(616): document paging as 0-based, expose channelId on playout detail
#616 filed three MCP/API paging traps. Two were real; one was not, and one was
already half-fixed on main. Verified each against the code before changing it.

REAL — pageNum documented as 1-based. `ToolCatalog.Page()` described pageNum as
"1-based page number" while every paged controller defaults it to 0, floors it
with `Math.Max(0, pageNum)`, and skips `PageNum * PageSize`. A caller that
trusted the description started at page 1 and silently lost the first page: no
error, just a short set that reads as data loss rather than an off-by-one (it
cost #487 a verification pass). Fixed in the description rather than by making
the MCP layer 1-based: /api/v1 is additive-only post-freeze, 0-based is
load-bearing in a dozen controllers and the SPA, and a 1-based wrapper over a
0-based API would make the same parameter name mean two different things on two
surfaces a reader reads together.

NOT REAL — "pageSize caps the page but the offset honors the requested value".
Not reproducible on any endpoint. Every controller clamps before passing, every
handler skips by the clamped size, and GetCollectionItemsHandler re-clamps
defensively. The reported observation (pageSize=500&pageNum=2 on a 204-item
collection returning 4 items) is exactly correct 0-based behaviour at the
clamped width of 100 — page 2 is items 201-204. The issue's own trap-1 table
states this. Pinned by test rather than "fixed".

ALREADY FIXED — playout LIST rows gained channelId in #297 (2026-07-22), three
days before #616 was filed; the report was measured against prod, which runs an
older :prod image. The DETAIL response (PlayoutResponseModel) genuinely still
lacked it, so channelId is added there (additive) and the reset_channel_playout
argument now names the trap: the id spaces overlap numerically, so passing a
playout id silently resets a different channel and returns a plausible 202.

Tests, both mutation-verified (each fails when its fix is reverted):
- ToolCatalogTests pins "0-based" on EVERY paged tool's pageNum description,
  with a non-empty guard so it can't pass vacuously over an empty tool set.
- GetCollectionItemsHandlerTests pins 0-based page boundaries and proves the
  offset derives from the clamped pageSize (page 1 at pageSize=500 returns
  items 101-150; the mutation that honors 500 returns an empty page).

Docs: new decision record api.paging-zero-based (catalog regenerated), the
api-conventions paging bullet, and a Paging section in docs/mcp.md. OpenAPI
v1.json + web/src/api/generated/v1.d.ts regenerated for the added field.

fixes #616

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:16:00 +02:00
timothy 960145348b ci(491): split the MySql dedupe fixture out of CI, tracked by #627
PR Gates / Docs update reminder (pull_request) Successful in 18s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 21s
PR Gates / decisions lifecycle (pull_request) Successful in 36s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m30s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m34s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The gate went red three times in CI with three distinct root causes (stale
pooled session after a drop, lost isolation from a shared database name,
connect-before-create). An intermittently-red gate is worse than none: it
trains everyone to re-run instead of read, which is how the two collation
defects escaped in the first place. The production fix is reviewed and green,
so it should not stay blocked behind test-harness reliability.

The fixture is kept and stays opt-in via ETV_TEST_MYSQL_CONNECTION (visible
skip without it); only the CI wiring is removed, with a note where it belongs.
Decision record corrected — it described a CI step that no longer exists.

[decisions-edit]
2026-07-25 21:43:17 +02:00
timothy 8e83183b8b docs(491): migrate the decision record to the per-file corpus format
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 18s
PR Gates / Docs update reminder (pull_request) Successful in 21s
PR Gates / decisions lifecycle (pull_request) Successful in 36s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m30s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 22m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
#610 split docs/decisions.md into one YAML-frontmatter file per record while
this branch was open, so the inline record could not be merged -- it had to be
converted. Same content and metadata, now at
docs/decisions/records/scan/libraryfolder-unique-identity.md with an index
entry and a regenerated catalog.
2026-07-25 21:16:51 +02:00
timothy fba5233caf feat(610): split the decision corpus into one YAML-frontmatter file per record
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and
docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key,
so one-active-record-per-key becomes a filesystem property rather than a
validator check, and supersession becomes a `git mv`.

WHY: the monolith was a concurrency problem before an aesthetic one. A
3,900-line append target made parallel sessions collide -- PR #605 and PR #614
both hit append-vs-append conflicts during routine rebases, and hand-resolving
those inside the corpus is exactly the operation the rationale-rewrite guard
exists to police.

HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness
does not rest on reading it. The parser was taught BOTH formats first, so the
body-diff guard parses the old form at the merge-base and the new form at head --
the migration validates itself, no bypass. The proof is a field-level equivalence
harness: 168 records before and after, zero lost, zero gained, zero field
mismatches, zero rationale bodies differing. Reviewers should scrutinise the
harness; it is the actual evidence.

What measuring caught that reading would not have:

- ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each
  topic file's preamble, mostly the only copy. Source files are kept and
  stripped, never deleted. They also cannot be filed per-area: topic files hold
  several areas and 4 of 23 areas span several files.
- Archive discovery was a non-recursive glob; after the split it found ZERO
  archived records, surfacing as four bogus "supersedes points to unknown key"
  errors rather than an obvious failure.
- ~32 live docs point into the corpus BY DATE, which the split dangles. Each
  stripped file now ends with a generated "Records formerly in this file" index,
  which also rescues the identical breadcrumbs in old issue comments.
- decisions.md's "In this file:" list was 97 same-file anchor bullets that the
  split makes WRONG, not merely stale. Dropped; the generated index replaces
  them with links that resolve.

The equivalence harness now runs against a checked-in FIXTURE, not the live
corpus. The earlier version migrated the real tree, which made it a one-shot:
the moment the migration landed there was nothing left to move and the tests
failed for reasons unrelated to the code. A fixture keeps them testing the
SCRIPT rather than the repo's current state.

Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain
two directories for one concept. Renaming a key is not a move -- it changes
identity, breaks the equivalence proof, and invalidates MemPalace's per-key
drawers. Taxonomy normalisation is separate work.

refs #610
2026-07-25 19:45:09 +02:00
timothyandClaude Opus 5 f49556b6ef docs(609): qualify the two ci-cd.md mentions as non-merge
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m55s
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
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 15m16s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m30s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Last review Low, marked safe-to-defer but it is two words in a file this PR already
edits. Both prose mentions of the marker now say "non-merge commit", matching the
decision Rule, the catalog, the module comment and the function docstring.

Docs-only.

fixes #609

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 18:40:55 +02:00
timothyandClaude Opus 5 f00dfde0c5 docs(609): correct the --no-merges rationale — merges are discouraged, not blocked
Review Low x2, both correct and both the stale-comment class:

The claim that prepush-rebase-check.sh forbids merging main into a PR branch is
false. That hook refuses a branch that is BEHIND origin/main; a merge makes
origin/main an ancestor, so the push is allowed. Merging main in is discouraged by
convention only. So --no-merges does cost a real false negative: an author who marks
ONLY a conflict-resolving merge commit gets a legitimate rewrite rejected. Keeping
--no-merges and stating the trade explicitly -- that failure is loud and costs one
extra commit, whereas honoring forge-composed merge bodies disables the guard
silently, which is #609 itself.

The module comment also still claimed a quoted example cannot arm the guard, which
contradicts the residual the decision record now states accurately. Aligned both, and
narrowed the record's Rule line from "some commit" to "some NON-MERGE commit" so the
stated contract matches the implementation.

No logic change -- comments, docstring, record prose and regenerated catalog only.

fixes #609

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 18:36:07 +02:00
timothyandClaude Opus 5 cc9481f541 fix(609): review fixes — exclude merge commits, unfold folded values, correct the fail-posture doc
Cross-family review (High + Medium + Low), all three reproduced before fixing:

High -- on a pull_request event actions/checkout lands on a synthetic merge commit whose
body the forge composes from the PR description, so a description ending in an example
marker armed a guard no author armed. Excluded merge commits from the range; merging main
into a PR branch is separately forbidden, so no author-written commit is skipped.

Medium -- a folded value (`no` + continuation ` yes`) was split into independent lines and
the continuation armed on its own, inverting the value the author wrote. Read with `unfold`
so the value is judged whole.

Low -- the module docstring promised fail-open while marker resolution deliberately fails
closed. The posture is right; the docstring was wrong. Documented as the one exception.

Both new negative controls verified red against the unfixed matcher. Decision record
amended to state the residual honestly rather than overclaim: a quoted example that is the
FINAL paragraph of an ordinary commit is a trailer by git's own grammar and does arm. What
the change buys is that discussing the marker can no longer disable anything.

fixes #609

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 18:29:46 +02:00
timothyandClaude Opus 5 c597c49f02 fix(609): arm the decisions body-diff with a git trailer, not a bare substring
The rationale-edit exemption was a substring test over the whole commit range, so any
message containing the literal marker armed it -- including prose explaining why no
marker was needed, which is how it fired live in PR #605: a green --base/--head run
that was vacuous on the body-diff dimension, in the one PR that hand-resolved a merge
conflict inside the corpus the guard exists to police.

Now read as an affirmative `Decisions-Edit:` git trailer. Git parses trailers only in
the final paragraph, so a quoted example message cannot arm it -- which matters because
this commit and its decision record both quote one. A non-affirmative value (`no`) does
not arm it either; the retired substring arms nothing and gets a ::warning:: nudge.

Tests: negative controls (prose mention, quoted mid-body trailer, `no` value, retired
substring) plus positive controls (trailer, uppercase, alongside Co-Authored-By). All
four negative controls verified red against the old matcher.

fixes #609

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 18:21:12 +02:00
timothy b5b6e7f636 test(496,484): record projection failures during enumeration, not eagerly [decisions-edit]
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 10s
PR Gates / decisions lifecycle (pull_request) Successful in 15s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m21s
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 8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m21s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m44s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Re-review Low. The substitute incremented the failure counter inside .Returns(...), i.e. when
the enumerable was handed out, while the real paginator records from ProjectToMusicVideo's catch
DURING enumeration. A refactor that snapshotted Count before the enumeration completed would
then break production while both replacement tests kept passing — exactly the regression the
guard exists to prevent.

Moves the recording into an async iterator, and corrects the decision record to describe #484's
removed music-video test accurately and name its two replacements.
2026-07-25 17:36:34 +02:00
timothy 4bdad4bf52 fix(496,484): thread #484's projection-failure guard through the new music-video scanner [decisions-edit]
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 24s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 19s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m33s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Rebasing onto #612 exposed a silent gap rather than a conflict. #612 added a
`projectionFailureCount` parameter to MediaServerReconciliationGuard.ShouldFlagMissing that
refuses the sweep when the enumeration reported swallowed projection exceptions — but the
parameter is OPTIONAL with a default of 0, so this scanner compiled unchanged while opting
out of the protection entirely. ProjectToMusicVideo has exactly the swallowing catch #484
exists to defend against, so the music-video sweep would have been the only one unguarded.

- MediaServerMusicVideoLibraryScanner creates one MediaServerProjectionFailureCounter per
  enumeration (never a field on the singleton api client, so concurrent scans of different
  libraries can't leak failures into each other's sweep decision), passes it to
  GetMusicVideoLibraryItems, and feeds its Count to ShouldFlagMissing.
- The single guard call also gates the #496 legacy path diff, which is more exposed: a
  legacy row has no etag to fall back on.
- ProjectToMusicVideo now returns MediaServerProjectionResult<JellyfinMusicVideo>, combining
  #612's Skipped/Failed distinction with #496's identity type.
- main's own #484 music-video test targeted the pre-#496 hard-delete scanner (FindMusicVideoPaths
  /DeleteByPath) and no longer applies; it is replaced by two tests in the new architecture
  covering the identity sweep and the legacy sweep. Both proven non-vacuous — removing
  projectionFailures.Count from the guard call fails exactly those two.

4,277 tests green; format/BOM clean; decisions validator OK.
2026-07-25 17:27:50 +02:00
timothy 147166b053 fix(496): harden the legacy path sweep against MySQL collation (re-review Low)
Re-review returned MERGEABLE with one Low, MySQL-collation-dependent edge in
FlagFileNotFoundByPaths: the C#-side Except diff is ordinal, but `MF.Path IN @LocalPaths`
runs under MySQL's case-insensitive default collation, so a still-reported identified row
differing only in case from an absent legacy row could be matched and flagged missing.

Match on the indexed PathHash instead of collated Path text, and re-state the
NOT EXISTS (JellyfinMusicVideo) guard so the identity pass and the legacy pass are disjoint
by construction rather than by the caller's diff being correct. SQLite was unaffected.
2026-07-25 17:20:53 +02:00
timothy 6bd1d954bd fix(496): review fixes — thread the replaced local path, scope identity per library, sweep legacy rows [decisions-edit]
Independent cold review (Codex) returned BLOCKED. Findings 1, 3 and 4 are fixed here;
each has a regression test proven non-vacuous by a negative control.

1. Blocker — the replaced local path was discarded. The scanner computed localPath but
   GetOrAdd only received `incoming`, so the repository re-derived the path from the
   UNREPLACED projection. On any install with path replacements, adoption hashed the
   server-side path, missed the existing row, ALSO slipped past MediaFileAlreadyExists
   (which hashes that same wrong string) and inserted a duplicate row under a server path,
   leaving the original collection-linked row identity-less forever. The test harness hid
   this because its path-replacement stub was an identity function.
   → GetOrAdd now takes localPath explicitly and never reads the projection's path;
     BuildPathReplacement takes a real mapping and the new test genuinely replaces.

3. Medium — GetByItemId matched on ItemId alone, so two media sources presenting the same
   item id (cloned Jellyfin DB) resolved to each other's row, letting one library repoint
   another's. → filtered by LibraryPath.LibraryId.

4. Medium — a row predating the identity that the server had ALREADY stopped reporting was
   never adopted (adoption only runs for an incoming item) and carried no identity, so the
   itemId diff could not see it either: it sat Normal and schedulable forever, strictly
   worse than the hard delete it replaced. → GetExistingLegacyMusicVideoPaths +
   FlagFileNotFoundByPaths reconcile legacy rows by local path, and they are counted into
   the #477 empty-fetch guard (on the first scan after this ships they ARE the whole
   library, so a guard counting only identity rows would sweep all of them on a transient
   empty fetch).

Finding 2 (the issue's Done-when #2) is a scope question, not a defect, and is unchanged:
one file path is still one MediaItem row globally, so this lands music videos at parity
with movies rather than eliminating shared-row trashing. Recorded honestly in the decision
record; raised for an explicit call before the issue is closed.

fixes #496
2026-07-25 17:20:53 +02:00
timothy 64decd492e fix(496): give music videos a per-library server identity; itemId diff + soft trash
Music videos carried no server identity, so JellyfinMusicVideoLibraryScanner had to
reconcile by a (LibraryPathId, path) diff and HARD-delete the remainder. A file served
by two libraries with overlapping local paths is a single row owned by whichever library
scanned it first, so that owner's sweep destroyed a row another library still served —
taking collection membership and playout references with it, irreversibly.

This is #494's deferred "option 2":

- New JellyfinMusicVideo : MusicVideo (ItemId/Etag), mirroring JellyfinMovie — TPT table,
  varchar(36), ItemId index. Dual-provider migration Add_JellyfinMusicVideo.
- New IMediaServerMusicVideoRepository + JellyfinMusicVideoRepository: itemId-keyed
  existing-set/lookup and Flag{Normal,Unavailable,FileNotFound} seams, all scoped per
  library via LibraryPath.LibraryId.
- New MediaServerMusicVideoLibraryScanner base; JellyfinMusicVideoLibraryScanner folds
  onto it and keeps the #177/#488/#497/#500 metadata-reconcile logic verbatim.
- The sweep now soft-trashes (FileNotFound) instead of deleting, so removal is reversible
  and EmptyTrash-governed. DeleteEmptyArtists consequently no longer fires from a sweep.
- Pre-identity rows are ADOPTED in place: the identity row is inserted against the same
  MediaItem id, scoped to the scanned library's own LibraryPath, so collection membership
  survives and a local/second-library row is never hijacked.
- AddMusicVideo normalizes Path/PathHash to the path-REPLACED local path; the projection
  fills them from the server-reported path, which would break every later PathHash lookup.

Docs: scan.musicvideo-reconciliation relocated to docs/decisions/archive/scan.md as
superseded; new active record scan.musicvideo-server-identity.

fixes #496
2026-07-25 17:20:53 +02:00
timothy 6f4497e1ce fix(484): guard the nested TV season and episode sweeps against projection failures
Review finding 1 (blocking). ScanSeasons' FlagFileNotFoundSeasons and ScanEpisodes'
FlagFileNotFoundEpisodes had no guard at all — neither #477's nor #484's — so ProjectToSeason /
ProjectToEpisode returning Failed() was computed and discarded.

#477 scoped those out because "the blast radius is one show's seasons / one season's episodes",
which holds for a per-parent EMPTY fetch but not for a projection failure: that is systematic by
construction. One bad code path fires on every parent, so every season enumerates zero episodes,
existing.Except([]) is the whole episode library, and EmptyTrashHandler deletes it permanently.

Threads the counter into GetSeasonLibraryItems / GetEpisodeLibraryItems(WithoutPeople) for
Jellyfin and Emby using the same optional-trailing-param shape, and guards both sweeps with
MediaServerReconciliationGuard.ShouldFlagMissingDescendants — the same class and the same private
failure predicate as ShouldFlagMissing, deliberately WITHOUT #477's empty-fetch branch so
per-parent empty behaviour (and #476's cascade, which depends on it) is unchanged.

Also from the review:
- finding 3: tests now pin the same-instance JOIN at every level (movie, show, season, episode,
  music video) by driving the real ScanLibrary entry point and recording the failure from inside
  the enumeration, so a refactor handing the api client a fresh counter goes red.
- finding 4: the missing-library Failed() branch is documented as defensive and unreachable.
- finding 2: the mass-Skip residual (Emby's response-shape-dependent MediaSources guard, Plex's
  pre-projection filter) is stated as a known limitation in the decision record.
- finding 5: the log-contract change (only the #484 message when both refusals apply) is noted.

fixes #484
2026-07-25 16:36:35 +02:00
timothy e3645a2840 feat(484): suppress the media-server sweep on projection failures; reject the ratio threshold
Extends MediaServerReconciliationGuard (#477) with a second deterministic refusal: when the
enumeration that produced the incoming set silently dropped items whose projection THREW, the
file-not-found sweep is refused. A dropped item the server did return is indistinguishable
from a deletion at the reconcile step, so a projection regression could otherwise mass-flag a
healthy library FileNotFound (which EmptyTrash then deletes permanently).

Deliberate guard-clause skips (STRM files, virtual items, unsupported types) are explicitly NOT
failures and never suppress a sweep — counting them would permanently disable reconciliation for
any library holding a single STRM file.

The ratio / missing-fraction threshold is REJECTED, not deferred: it is a two-sided heuristic
with no tunable default and no telemetry, and the failure it approximates is exactly observable
via the projection-failure count (a genuine bulk deletion produces zero failures).

Seam is deliberately narrow — the private ProjectTo* contract inside each api client changed from
Option<T> to MediaServerProjectionResult<T> (projected/skipped/failed), the paged helper counts
IsFailure in one place, and the scanner reads it through an optional trailing
MediaServerProjectionFailureCounter on only the five library-level methods that feed a sweep.
The counter is per-enumeration state created by the scanner, never a field on an api client.

fixes #484
2026-07-25 16:36:35 +02:00
timothy f04412a95b docs(603): correct the record's own no-backfill claim, which the backfill commit falsifies
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m40s
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 6s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m26s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Caught in re-review. The record shipped in 0ad02db6 said 'no backfill was done
here'; 8e1e15e4 then backfilled three ci.* records on the same branch. Corrected
now while the record is still NEW at HEAD, so the body-diff guard does not apply
and no edit token is needed -- after merge this would be surviving rationale
prose and the same fix would cost one.

Deliberately avoiding the literal bracketed token string here: decisions_validate
matches it as a bare substring over the whole commit range, so a message merely
DESCRIBING the mechanism arms it and suppresses the rationale-rewrite guard for
the entire PR. Found exactly that way on the first draft of this commit.
2026-07-25 15:30:43 +02:00
timothy 8e1e15e4d7 docs(603): backfill stale-after + Sources onto the three ci.* outside-world records
The mechanism shipped unused. First adopters are the records that encode measured
host behavior, which is what drifts silently:

- ci.runner-placement    2027-01-15 — bumblebee sizing (25 GiB/12 cores) + container
  caps. Premise has already partly moved: the media transcoders left for jazz on
  2026-07-20 and the runners were retuned since.
- ci.infra-shaped-red-under-load  2027-02-15 — a triage heuristic calibrated against
  load 243 on a host that has since been retuned.
- ci.peak-anon-measurement        2027-03-15 — weakest of the three (mostly our own
  script) but it does assert that memory.peak is cache-inflated, a cgroup fact.

Dates staggered so they don't all come due in the same week. Each Sources line cites
the incident evidence already named in the record's own prose.
2026-07-25 15:22:50 +02:00
timothy 0ad02db651 feat(603): adopt OKF's optional stale-after and Sources decision-record metadata
Evaluated the Open Knowledge Format (GoogleCloudPlatform/knowledge-catalog okf
v0.2, scaccogatto/okf-skills) as a replacement for our decision-record system and
rejected it: its conformance rules are deliberately permissive exactly where ours
are strict (broken links, unknown types and missing fields must all be tolerated;
`deprecated` points at no successor), and its stable identity is the file path,
which the breadcrumb rule tells agents not to trust.

Adopted two of its optional families instead, additively:

- `stale-after: YYYY-MM-DD` on the metadata line — marks a record asserting an
  outside-world fact as due for re-confirmation. Absolute date, no TTL.
- `**Sources:**` in the metadata block — the evidence a record rests on, as
  distinct from `Signals:` (recall keywords).

Neither is required; absence is never an error. A malformed `stale-after` is
blocking (it would silently never fire), but a past-due record is only a
non-blocking `::notice::` — going stale is the passage of time, not a defect in
whatever commit is under test. The catalog's new "Review due" section renders the
date only and never a clock-derived verdict, so it cannot drift `--check` red on a
calendar boundary with no commit touching the corpus.

No backfill: no existing record adopts either field here.

fixes #603
2026-07-25 15:22:50 +02:00
timothy abeef63f05 Merge pull request 'feat(445,533): headless Playwright UI-E2E flows + fix e2e-local readiness probe' (#591) from feat/445-533-ui-e2e into main
Build CI Toolchain Image / Build & push CI image (push) Successful in 27s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 18m39s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 23m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 24m56s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 21m43s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
2026-07-25 12:52:32 +00:00
timothy e87285c33d Merge pull request 'fix(460): write null LastScan on disable-sync instead of the MinValue sentinel' (#601) from fix/460-lastscan-null into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m1s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 16m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 18m54s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 6m0s
2026-07-25 12:11:34 +00:00
timothy 5dd0b147e8 docs(445): record that rebasing invalidates the CI toolchain image pin
Hit this for real on this branch. `ci-image-pin` went red after a rebase that
was otherwise clean, and the failure is confusing on three counts:

  - the pin must equal the short sha of the commit that touched docker/ci, and a
    rebase REWRITES that sha (e9fd26f6 -> 9130274c here);
  - the pin still resolves to a real commit and the tagged image still exists in
    the registry, so nothing looks broken;
  - the force-push does NOT republish: ci-image.yml filters on
    `paths: docker/ci/**`, and a rebase that leaves the Dockerfile's content
    unchanged produces no diff for that path.

And it cannot be fixed by re-dispatching ci-image.yml, because that tags
`git rev-parse --short HEAD` — the branch HEAD, not the commit that touched
docker/ci. The two coincide only when the docker/ci commit IS HEAD, which is why
the original two-step worked and the post-rebase state does not.

Recorded in docs/ci-cd.md with the recovery, plus the cheaper lesson: land a
toolchain-image change on its OWN branch first, so the consuming branch never
carries the docker/ci commit through a rebase.

This commit is also the recovery itself — it touches docker/ci, so it becomes the
commit ci-image.yml tags, restoring the pin dance.

Refs #445
2026-07-25 14:04:52 +02:00
timothy dbe72dbf03 docs(445): refresh web test count after rebase (983 -> 995)
main added 12 web tests while this branch was in review, so the count I wrote
into docs/testing.md went stale during the rebase. Verified by running the suite
against the rebased tree: 995 tests / 105 files.
2026-07-25 14:04:52 +02:00
timothy 71706f3849 fix(445): silent-pass on spec failure + prove server ownership via pidfile [decisions-edit]
Second review round. Codex returned BLOCKED @ e50a2624 with three High findings;
all three were real and all three are fixed. One of them was severe and was
introduced by my OWN previous "fix" commit.

## HIGH 1 — a FAILING spec run exited 0, silently passing CI

`if ! npx playwright test; then status=$?; ... exit "$status"; fi`

Under `!` negation bash sets `$?` to the LOGICAL NEGATION of the command's
status, so inside the failure branch `$?` reads 0 — the script exited 0 on a
failing run. Verified: `if ! (exit 42); then echo $?; fi` prints 0.

A UI-E2E harness that reports success when its specs fail is worse than no
harness. My five green local runs could never have caught this: the bug lives
only on the failure path. Introduced by the log-tail improvement in e50a2624.

Fixed with `set +e` / read `$?` / `set -e`, then exit that status explicitly.
Verified: a deliberately-failing run (`--grep ZZZ_NOPE`) now exits 1.

## HIGH 2 + 3 — the pre-PID fallback needed lsof and only GUESSED ownership

The mid-boot fallback reaped "whatever LISTENS on $PORT", which was wrong twice:
  - it needed `lsof`, which is ABSENT from the CI toolchain image (verified
    directly in the published image) — so it silently no-opped precisely where
    it was needed;
  - it INFERRED ownership from the earlier pre-flight rather than proving it, so
    a process that grabbed the port after the pre-flight — or a real instance on
    a shared host — could be killed. Reaping someone else's server is worse than
    the leak it was meant to fix.

Replaced with an opt-in `ETV_PIDFILE`: e2e-local.sh writes the PID the instant
it forks, BEFORE its readiness wait, which is exactly the window a mid-boot
signal lands in. A pidfile we asked for PROVES ownership, needs no external
tool, and works in CI. The port-based kill is gone; the lsof pre-flight remains
as a friendly local check only.

Verified: the pidfile is populated while still mid-boot (readiness not yet
reached), names the real `dotnet ErsatzTV` process (not a subshell — which also
re-confirms the `exec` fix), and killing that PID alone frees the port.

Also dropped the `seq` dependency inside the trap (shell arithmetic instead),
addressing the other reviewer's busybox concern.

## Docs-reviewer finding — my stated reasoning was wrong

I justified amending `testing.e2e-local-fresh-config-dir` rather than superseding
it partly on "renaming the heading trips CI". That's a true statement that does
NOT bear on the choice: a supersession relocates to `archive/` with the heading
INTACT (verified: archive/api.md keeps the #72 heading verbatim). Corrected to
the actual reasons — the Rule never reversed, and the key is cited from
docs/handoffs/chicorytv-issue-queue.md plus two docs/superpowers/ files, which a
supersession would aim at an archived, stale-labelled record.

## Gotcha found by accident, now documented

A flawed test of mine booted two e2e-local.sh instances concurrently and the
first mysteriously failed to become ready. Cause: every run `rm -rf`s and
re-copies the SAME build-output wwwroot, so a second run yanks the static files
out from under a still-starting first instance. Documented in both the script
header and docs/e2e-local.md, because the symptom (readiness timeout, or /app
404ing) looks nothing like a shared-directory race. CI is unaffected — its curl
and UI-E2E steps are sequential.

## Budget: filed, not shaved

This PR pushes the active decisions corpus 7 lines past its 5600-line soft
budget (main was under). I trimmed my records repeatedly and each rewrite
recovered ~1 line, because the content is load-bearing; continuing would have
meant deleting useful rationale from a new convention record to hit an arbitrary
cap. The validator's own remedy is "schedule a consolidation", so that is filed
as #595 rather than paid for by starving the record. Non-blocking warning.

Also filed #594 for the pre-existing `ci-image-pin` any-hex-length weakness.

## Verification
- failing run exits 1 (was 0); passing run still 3/3 green
- pidfile written mid-boot, names the real dotnet proc, reap frees the port
- SIGTERM mid-run: exit 143, no orphan listener or process
- curl harness unaffected: 45/45 PASS; ETV_PIDFILE unset => unchanged behaviour
- decisions validator OK; zero orphaned processes after the full gate

Refs #445 #533 #594 #595
2026-07-25 14:04:52 +02:00
timothy 0f1951340e fix(445,533): harden e2e-ui lifecycle + retire stale #533 decision record [decisions-edit]
Addresses the adversarial review round. Codex returned BLOCKED on the harness
lifecycle contract; the second (cold) reviewer independently flagged the same
trap-ordering defect, which is what made it credible.

**Trap installed AFTER boot -> signal mid-boot orphans the server.** The window
between `e2e-local.sh` returning and `trap ... EXIT INT TERM` had no handler, so
a Ctrl-C/TERM there (or the PID-parse bail-out) left dotnet holding the port —
violating the script's own "always kills the server" contract. The trap is now
installed BEFORE boot. When the PID is not yet known, cleanup falls back to
reaping whatever LISTENS on our port; attribution is sound because the
pre-flight proved that port free moments earlier.

**Signals were not re-raised.** A TERM landing beside a passing Playwright run
could exit 0, reporting success for a cancelled run. INT/TERM now clean up and
re-raise, so the wrapper dies BY the signal.

Verified, not assumed: SIGTERM mid-run -> wrapper exits 143 (128+15) and leaves
zero listeners and zero stray ErsatzTV processes.

`e2e-local.sh` backgrounded a MULTI-command subshell, so `$!` is the subshell —
not dotnet — wherever bash does not collapse it. Measured both ways:

  bash 3.2.57 (stock macOS /bin/bash), 2-command subshell : $! = SUBSHELL
  bash 5.3.15 (homebrew)                                   : $! = leaf
  with `exec` (both versions)                              : $! = leaf

Consequence on stock-macOS bash: every PID-based kill/liveness check targeted
the wrong process, the escalation silently no-opped, and the server leaked. Fixed
at the source with `exec`, which benefits all consumers (the CI step's trap and
the e2e-functional.sh pairing), not just e2e-ui.sh.

My first attempt to test this was WRONG and would have cleared the finding: a
single-command subshell is collapsed on both versions. Only the 2-command form
reproduces it.

`testing.e2e-local-fresh-config-dir` was still `status: active` asserting the bug
'wait for either line' option today", "widening the probe ... is tracked as #533".
`READY_LINE` existed ONLY in that record; nowhere in code.

Amended rather than superseded: the operative rule (use a fresh config dir) is
unchanged and still correct — only its RATIONALE moved from "the probe hangs" to
"state bleed". Heading deliberately left alone: the validator matches records by
`## HEADING`, so renaming fails CI as an "unlogged removal"; an explicit note now
tells the reader the heading is historical.

- Boot-failure diagnostics were lost: `OUT="$(...)"` aborts under `set -e` before
  the print. Now `if ! OUT=$(...)` so the output is shown.
- Playwright failures surfaced no server-side evidence (nothing uploads the
  traces in web/e2e/.output). The server log tail is now printed on failure.
- `lsof -ti :PORT` also matched outbound/TIME_WAIT sockets -> false positives.
  Now `-sTCP:LISTEN`. Documented honestly that the CI image ships no lsof, so
  the pre-flight is a local-developer guard only.
- Predictable `/tmp/etv-pw-probe.$$` -> `mktemp` (symlink-redirect on a shared host).
- Suggested escape-hatch port was 8411, which is scripts/security-scan.sh's
  default; 8409/8410 are prod/ersatztv-test on jazz. Now suggests 8419 and names
  the conflicts.
- boot-gate.spec.ts comment overclaimed: after logout the browser holds NO
  cookie, so that assertion cannot prove stamp rotation (the curl harness does,
  by replaying the same cookie). Comment corrected to what it actually proves.

Deliberately NOT changed: `ci-image-pin`'s regex accepts any hex length rather
than the exact 7 chars ci-image.yml publishes (pre-existing guard weakness, not
introduced here — filed as a follow-up rather than widened in this PR).

Also trimmed the two records I had bloated: the active decisions corpus went over
its 5600-line budget as a result of this PR (baseline on main was under), so the
overflow was mine to pay down, not to pass on.

- SIGTERM mid-run: exit 143, no orphan listener/process
- curl harness unaffected by the `exec` change: 45/45 PASS
- UI-E2E 2x clean; typecheck + lint clean; decisions validator OK, no size warning

Refs #445 #533
2026-07-25 14:04:52 +02:00
timothy f7fa57c816 docs(445): fix dangling decision key + clarify UI-E2E step reference [decisions-edit]
Two review findings from the cold docs/convention pass.

HIGH — the new `ci.ui-e2e-harness` record cited `` `ci.toolchain-image` `` as if
it were a resolvable decision key. No such key exists anywhere in the corpus
(verified: the only occurrence in all of docs/ was that citation itself). #390's
toolchain-image work was deliberately never migrated to a standalone record —
`ci.runner-placement`'s own Signals line says so.

This is exactly the breadcrumb hazard docs/README.md -> "Knowledge retrieval"
warns about: a future session (or a MemPalace lookup) resolving that key gets
nothing back, and cannot distinguish "retired" from "never existed". Replaced
with an explicit pointer to #390, `ci.runner-placement`, and docs/ci-cd.md, and
stated outright that #390 has no standalone record.

Swept the whole class rather than the one instance: every dotted key cited in
added lines across the diff now resolves (ci.functional-e2e-harness,
ci.runner-placement, ci.ui-e2e-harness).

NIT — docs/ci-cd.md said the UI-E2E step is "Step 4 of this same job", which
refers to the doc's own 4-item prose summary, not the YAML step list (where it
is the 11th `steps:` entry). Reworded to name the actual step so a reader
skimming the workflow isn't sent looking for a 4th YAML step.

Refs #445
2026-07-25 14:03:59 +02:00
timothy d8c0b3e752 feat(445,533): headless Playwright UI-E2E flows + fix e2e-local readiness probe [decisions-edit]
Adds the last deferred #299/#363 follow-up: the flows that CANNOT be expressed
as curl calls. Scope rule (the durable part) — assert only what the curl
harness structurally cannot reach:

  1. client-side form validation (the Setup confirm-password gate is pure React
     state and makes no request, so there is no HTTP contract to assert)
  2. AuthGate's RENDERED states (Setup vs Login vs app)
  3. the session cookie authenticating the SPA's OWN /api XHRs — curl proves the
     cookie works for curl, not that the app sends it
  4. sign-out through the UserMenu back to the login gate

New: web/e2e/boot-gate.spec.ts, web/playwright.config.ts, scripts/e2e-ui.sh
(owns the whole lifecycle: fresh config dir -> boot -> specs -> always kill).

Runs as a second step of the EXISTING advisory `functional-e2e` job rather than
a new job: the dominant cost there is `npm ci` + the Release build, both already
done, so this adds ~5s instead of duplicating a heavy job. It boots its own
fresh instance on port 8410 because the first spec asserts the one-shot Setup
gate that the curl step has already claimed on its config dir.

Determinism (the issue asked for it explicitly): `serial`, `workers: 1`,
`retries: 0` even in CI — a retry would let a flaky flow merge looking green.
Measured 5 consecutive clean runs, ~2s each.

Pins all five `container:` jobs to the toolchain image built by the preceding
commit, which bakes `chromium-headless-shell`.

Non-obvious coupling fixed: vitest's default include glob would have collected
web/e2e/*.spec.ts and run it under jsdom. Excluded `e2e/**` by spreading
`configDefaults.exclude` rather than narrowing `include` to `src/**`, because
web/scripts/ holds a real vitest test an src-only include would silently stop
running.

`RebuildSearchIndexHandler` logs one of two mutually-exclusive lines just before
`SystemStartup.SearchIndexIsReady()`:

  fresh config  -> "Done migrating search index in {Duration}"
  reused config -> "Search index is already version {Version}"

The probe watched only the first, so a reused dir waited out the full 120s
timeout and then killed a perfectly healthy server. Widened to a `grep -Eq`
alternation; the handler's if/else is exhaustive, so the pair covers every path
to readiness.

Verified with a negative control: on a reused dir the server is ready in 2s via
the "already version" line, and the OLD probe string is genuinely ABSENT from
that run's log — so the old code would have hung, i.e. the fix is load-bearing
rather than incidentally passing.

The "prefer a fresh config dir" guidance stays: that guards state bleed, which
is a separate concern from the probe hanging.

- `wait "$PID"` in the cleanup trap was a NO-OP: the server is a grandchild
  (launched in e2e-local.sh's subshell, which then exits), so `wait` fails
  instantly and was swallowed by `|| true` — cleanup did not actually ensure the
  port was released, exactly what its comment claimed. Replaced with a bounded
  `kill -0` poll, then SIGKILL.
- Added a port pre-flight check: previously an occupied port surfaced as a 120s
  readiness timeout that reads like a broken build. Now fails in 0s naming the
  PIDs, and warns against blanket-killing `dotnet ErsatzTV.dll` (that reaps
  other sessions' servers).

- UI-E2E: 5x clean (3 specs, ~2s); back-to-back runs pass with no manual cleanup
- curl harness unaffected by the boot-script change: 45/45 PASS
- web: 983 tests / 105 files green; typecheck + lint clean
- vitest collection verified: excludes web/e2e, still collects web/scripts
- Dockerfile sequence + browser launch validated verbatim in a container on the
  real amd64 base before committing; chromium launches as root with NO sandbox
  opt-out needed
- decisions validator green; catalog regenerated
- docs/decisions.md TOC repaired: it had drifted to 69 of 97 records and held a
  dangling anchor to the #72 record that #415 superseded into archive/.
  Regenerated with a generator validated against the 68 existing anchors (0
  mismatches) -> 97/97, no dangling, no duplicates.

Docs: docs/e2e-local.md (new "UI-E2E harness" section), docs/ci-cd.md (toolchain
image + UI-E2E step), docs/testing.md, docs/README.md, docs/decisions.md
(new `ci.ui-e2e-harness` record; `ci.functional-e2e-harness` amended — its Rule
said "curl-only", now accurate).

Refs #445 #533
2026-07-25 14:03:59 +02:00
timothy 67e53b1304 Merge pull request 'chore(586,594,485): PID-scoped E2E cleanup, ci-image-pin length guard, .gitignore core fix' (#598) from chore/586-594-485-hygiene into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m11s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-25 11:51:57 +00:00
timothy 0081bed4b1 docs(460): drop the "clean at rest" overstatement from the test header
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 25s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m51s
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 8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m10s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m38s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Follow-up to the cold review of 615e00a. The record was corrected to say only
that no NEW sentinel rows are created, but the test file's header still carried
the disclaimed "the data is clean at rest for every provider" claim — sitting on
the very test that supposedly proved it. It now states what the tests actually
pin, and names what they cannot speak to (rows written before this change, which
keep the sentinel and are covered only by the read coercion).

Also corrects docs/testing.md's ErsatzTV.Core.Tests count (543 -> ~650 measured;
the neighbouring Scanner.Tests cell is corrected in #602 instead, to keep the two
open PRs off the same line).
2026-07-25 13:46:41 +02:00
timothy 890745d1d4 fix(460): write null LastScan on disable-sync instead of the MinValue sentinel [decisions-edit]
MediaSourceRepository's Plex/Jellyfin/Emby remove-and-recreate (disable-sync)
flows stamped SystemTime.MinValueUtc into library.LastScan, so normal use kept
minting 0001-01-01 sentinel rows. #409 fixed the READ side (the API coerces the
sentinel to null and a migration cleaned the historical residue), so the wire
contract was already correct — this stops new sentinel rows being written.

Safe because every remaining LastScan reader either coalesces
(LastScan ?? SystemTime.MinValueUtc) for its own non-nullable scan-comparison
needs, or is the API read-boundary coercion itself — audited every reference
under ErsatzTV{,.Application,.Core,.Infrastructure,.Scanner,.Mcp} plus web/.
There is no Where/OrderBy/GroupBy on LastScan anywhere, so the SQL
null-ordering divergence between SQLite and MySQL has no surface here.
Scan-comparison behavior is unchanged.

Deliberately ships NO second cleanup migration: rows written between #409's
NullOutNeverScannedLastScan and this change keep the sentinel at rest, and the
permanent read coercion — not a migration — is what keeps the contract honest
for them (as it must be anyway for a restored or hand-edited DB).
LibraryPath.LastScan is likewise left untouched: the flows re-add Paths with
their original values, and its only readers are the local-library scan
handlers, so it has no API surface and no remote-scan effect.

Regression test per provider (MediaSourceRepositoryDisableSyncTests), proven
non-vacuous: restoring the MinValue writes fails all three.

[decisions-edit] — the media.lastscan-null-boundary record documented this
write as an ONGOING sentinel source and rested its "the coercion is permanent"
argument on it, so the rationale prose is corrected in the same PR per
docs.decision-lifecycle. The Rule line is unchanged, so the generated
decisions/README.md catalog is byte-identical.

MediaSourceRepository.cs also loses its UTF-8 BOM (fix-as-you-touch, #311).

fixes #460
2026-07-25 13:46:09 +02:00
timothy 2f2bcca681 fix(500): dedup incoming metadata collections so a duplicate name inserts once
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 9s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 19s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m24s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m27s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The remove-stale + add-new reconcile idiom materializes its add set with
.ToList() BEFORE the loop mutates the existing collection, so the add filter
(`incoming.All(x2 => x2.Name != x.Name)`) is evaluated against a snapshot. Two
identically-named incoming entries whose name is not yet on the existing item
therefore BOTH passed the filter and BOTH inserted — a duplicate row.

Deduplicate the incoming set on the same key the filter compares (Name; Guid
for Guids), in both copies of the idiom:

- PlexMovieLibraryScanner.UpdateMetadata (the original) — genres, studios,
  actors, directors, writers, guids, tags.
- JellyfinMusicVideoLibraryScanner.Reconcile{Genres,Tags,Studios,Artists}
  (added in #497, mirrors the Plex pattern verbatim).

Plex ACTORS are the exception and get an artwork-preferring dedup hoisted out
and shared with the remove filter, because that filter is keyed on
(Name, artwork-presence) — it is the mechanism that drops an artwork-less actor
so the add loop can re-add it WITH artwork. A bare DistinctBy(a => a.Name)
there keeps the FIRST duplicate, so Plex listing the artwork-less copy first
discarded the artwork; worse, the remove filter would still see the
artwork-less duplicate, making its upgrade clause false, so the stale row was
never removed and the artwork never arrived on ANY later scan either. Actor
also carries Role/Order, which first-wins would silently drop too. Caught by
the cold review of the first version of this commit.

For the Jellyfin scanner the dedup sits at the incoming-list declaration, which
also covers the remove filter — safe because all four of those filters only ask
"is this name present at all", an answer duplicates cannot change.

Tests: duplicate-collapse for both paths, the two Actors cases above, and a
POSITIVE CONTROL proving distinct entries are still all added and stale ones
still removed (without it, a mis-keyed dedup that collapsed genuinely different
entries would pass every other assertion). Each proven non-vacuous.
The Plex tests drive the protected UpdateMetadata through a minimal test-only
subclass, as MediaServerMovieLibraryScannerTests already does.

Low likelihood in practice (a media server emitting two identically-named
genres for one item is unusual); this is defensive, with no observed occurrence.

The same idiom is copied into 8 further scanners/repositories that this change
deliberately does not touch (the issue scoped it to two paths) — filed as #600
so the class of bug is tracked rather than silently left in the majority of its
instances. The dedup rule is recorded under scan.musicvideo-reconciliation.

fixes #500
2026-07-25 13:04:38 +02:00
timothyandClaude Opus 5 c8e79f49f4 chore(586,594,485): PID-scoped E2E cleanup, ci-image-pin length guard, .gitignore core fix
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 11s
PR Gates / decisions lifecycle (pull_request) Successful in 12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m55s
Three independent CI/repo-hygiene fixes swept together; disjoint file sets.

fixes #586 — E2E cleanup is scoped by PID, never a pattern-wide pkill
  - New decision record `testing.e2e-cleanup-scope-by-pid`.
  - docs/e2e-local.md states the constraint where a BRIEF-WRITER sees it (the
    #586 root cause was a delegation gap, not agent error).
  - scripts/e2e-local.sh: reviewed against e2e-ui.sh's trap lifecycle and
    deliberately does NOT adopt it — its contract is to hand a running instance
    back to its caller, so an EXIT trap would kill the server the instant the
    launcher returned (both callers use `OUT="$(e2e-local.sh ...)"`). Recorded.
  - Instead it gains what actually prevents the incident: an lsof pre-flight
    that NAMES a foreign listener's PID rather than letting Kestrel fail its
    bind and surface as "process N exited before becoming ready".
  - Pre-flight probes BOTH bound ports, and ETV_STREAMING_PORT now defaults to
    ETV_UI_PORT. Program.cs binds a second listener whose port defaults to 8409
    independently of ETV_UI_PORT, so `ETV_UI_PORT=8420` alone still bound 8409
    and died against a foreign holder — i.e. the documented escape hatch was a
    dead end that led straight back to the confusion behind the pattern kill.

fixes #594 — ci-image-pin accepts any hex length
  - Length is a separate invariant from correctness: the resolve/staleness
    checks compare resolved shas, so an 8-char pin of the right commit passes
    green while matching NO registry tag, and all five container: jobs then die
    at image-pull with `manifest unknown` (reads like a registry outage).
  - Guard fails at the gate and prints the exact tag to use. Verified against
    doctored pins: 7 green; 6/8/10 red.
  - Uses a literal 7 rather than a derived `--short=7`: in a full clone git may
    widen an ambiguous abbreviation, demanding a pin ci-image.yml can never
    publish. Escape hatch documented inline.
  - Also fixes a pre-existing misdiagnosis: zero pins reported "MORE THAN ONE".
  - docs/ci-cd.md documents the 7-char rule and `git rev-parse --short=7 HEAD`.

fixes #485 — .gitignore `core` silently ignored `*/Core/` files
  - A bare `core` matched any path component named `core`; case-insensitively
    on macOS that swallowed every `*/Core/` SOURCE dir, so new untracked files
    were dropped by `git add -A` while tracked ones stayed fine — a clean local
    build and a CI checkout that fails to compile.
  - Now `/core` + `/core.[0-9]*`, both anchored (an unanchored `core.[0-9]*`
    would re-introduce the same silent-exclusion class this fixes).
  - Verified by diffing the full ignored-file set before/after: identical, and
    the three real Core/ dirs are trackable without -f.

Docs updated in-PR: docs/e2e-local.md, docs/ci-cd.md, docs/decisions/
workflow-process.md (+ regenerated catalog), docs/handoffs/chicorytv-issue-queue.md.

Follow-ups filed: #596 (the same shared-host reap in the Playwright-MCP
recovery record) and the ci-image.yml `--short=7` publisher-side fix, which
cannot ride this PR — editing ci-image.yml re-points ci-image-pin's `expected`
at this commit and reds the gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:46:13 +02:00
timothy b7d9e0ecb9 Merge pull request 'docs(592): record that a skipped CI context is not red' (#593) from chore/skipped-not-red into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 26s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 9s
2026-07-25 10:07:05 +00:00
timothy bcff7c686a Merge pull request 'feat(440): per-source weight steppers + exclude/add-untagged in the Auto-Tune DetailPanel' (#589) from feat/440-autotune-weights into main
Build ErsatzTV Image / Build & test (.NET) (push) Has started running
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
2026-07-25 10:06:02 +00:00
timothy 2fee3f0b94 docs(592): record that a skipped CI context is not red [decisions-edit]
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 23s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 20s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Amends ci.monitor-armed-at-pr-open (prose + Signals only; heading and key
unchanged, no supersession) with the monitor-classification rules that were
missing.

Landing #436/#583 today, my CI monitor filtered per-context statuses on
`!= "success"` and announced "NOT all green" on two fully green PRs, because
`Build & push image (amd64)` reports `skipped`. Nothing was blocked — Gitea's
combined /status already treats skipped as non-blocking and reported
overall=success — but a false red costs a diagnosis cycle every time.

Two corrections recorded:
- The image job is skipped on EVERY PR (job-level `if: github.event_name !=
  'pull_request'`; images build only on push-to-main and tags), NOT because of
  the docs-only mechanism. Misattributing it to docs-only is a plausible-sounding
  wrong diagnosis, since docs-only gates STEPS precisely so required jobs still
  report success. decisions.md already stated the fact from the branch-protection
  angle; the monitor-authoring consequence was missing.
- skipped / failure / cancelled are three distinct meanings and must not be
  collapsed. Prefer gating on the combined `.state`.

The documented filter is verified in BOTH directions: silent on a green PR
carrying a skipped build, and still dirty on a genuinely pending run. My first
draft of it was itself broken — `select(.status != …)` after the pipeline had
renamed `.status` to `.st`, so it compared against null and reported a green PR
as nine failures. That failure is recorded in the note, per the "verify your
detector" rule.

fixes #592
2026-07-25 11:53:35 +02:00
timothy 8664220aa5 Merge pull request 'chore(583): make per-agent model routing a hard constraint + PreToolUse gate' (#585) from chore/agent-model-routing into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m2s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-25 09:47:43 +00:00
timothy 22e89fb9a4 docs(440): correct two misleading text defects found in review
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m14s
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
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m51s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m54s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Text-only follow-up; no behavior change (14/14 tests, lint clean, tsc clean).

- AutoTuneScreen.tsx: the addSource comment claimed picking a hit that is
  already a base member makes "the existing row read as customised". It does
  not — patchSource(id, {}) materializes a DEFAULT draft, sourceCustomized is
  false for it, and sourcesRequest omits it, so the pick is a payload no-op
  whose only visible effect is the query clearing. Comment now states that.
- spa-conventions.md §11: said WEIGHT_MIN/WEIGHT_MAX are "the same const pair
  the multi-collection editor uses". Same VALUES, separate screen-local consts
  — there is no shared module. The old wording invited a future reader to
  assume a shared seam that does not exist.

Both were nits in the independent review of d3c89d87 (verdict
MERGEABLE-WITH-NITS). Fixed rather than deferred because a comment that states
the opposite of the code, and a doc that implies a nonexistent shared const,
are exactly the kind of thing the next session reads and trusts.

Remaining review nits deferred to a follow-up issue: exporting compile.ts's
Lucene escaper instead of duplicating it, an exclude-all warning, and >50-source
axis handling.
2026-07-25 11:30:36 +02:00
timothy 7e5d20be98 fix(583): gate every model-less dispatch, not just implementer-looking ones
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m37s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 55s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m23s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m22s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Independent review of d221a065 found the prompt-text heuristic both over- and
under-fired. Confirmed repros: a read-only recon brief merely mentioning
"worktree" nagged, while "author the change and open a PR", "land this on the
branch" and "make the changes and commit them" all passed silently — the gate
missed exactly the case it existed to catch.

Root cause behind all three findings: the hook contradicted its own rule. The
HARD CONSTRAINT says "every dispatched agent"; the hook gated only dispatches
whose prose looked like an implementer. Prompt text is not a reliable signal for
authority.

Inverted: ask whenever `model` is absent, exempting only `fork` (the tool ignores
a fork's model override, so a prompt could not be acted on). This dissolves all
three findings rather than patching the regex — no phrasing dependence, no
false-positive concept, and no "cannot commit" claim to be wrong about. The
reviewer was right that Explore/Plan/claude-code-guide all carry Bash, so the old
exemption rationale was false.

The noise objection is answered by the escape hatch, not by scoping: naming a
tier costs one parameter and the hook never fires. Self-eliminating for anyone
following the rule.

Decision record updated to record the rejected narrow design and why.

Re-verified: 11 payloads incl. all three findings, the invariants (model named ->
never fires; fork exempt; non-Agent passes), and robustness (malformed JSON,
empty payload, missing tool_input, embedded backticks/quotes/newlines) — never
crashes, never emits malformed JSON. decisions-validate: OK.
2026-07-25 11:19:31 +02:00
timothy d3c89d87aa feat(440): per-source weight steppers + exclude/add-untagged in Auto-Tune DetailPanel
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m48s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Wires the Auto-Tune DetailPanel's Content-sources pane to #425's backend: each
member row gains a 1..1000 weight stepper and an include/exclude toggle, and a
library typeahead adds a source that isn't in the axis's base set. Edits
accumulate in the screen's per-channel draft (the existing §8/§11 guard covers
them) and are flushed as the create request's `sources` array.

- Only genuinely customised rows are sent, mirroring the server's own
  `customized` predicate — an all-default array is a backend no-op, so the field
  is omitted entirely and the channel keeps the cheaper fair-share shape.
- Weights are clamped to 1..1000 on blur and again at save, so an out-of-range
  value never reaches the server as a raw 400 (spa-conventions §4a).
- The add-untagged picker compiles typed text to `title:*…*` rather than
  forwarding raw Lucene: the index's default field does not match bare title
  words, so a raw forward would silently find nothing.
- Removes the read-only #425 hint.

Docs: spa-conventions.md §11 records the per-source correction-row convention.

fixes #440
2026-07-25 11:15:20 +02:00
timothy d221a06522 chore(583): make per-agent model routing a hard constraint + PreToolUse gate
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 14s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m42s
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
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m2s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The kickoff tells the orchestrator to route agents by capability, but that rule
lived only in a prose paragraph. On 2026-07-25 a session dispatched two
implementers (#436, #440) with `model` omitted, silently inheriting the
orchestrator tier — while following every bullet in HARD CONSTRAINTS in the same
session. The bulleted list is what functions as the checklist; prose above it
reads as background.

- HARD CONSTRAINTS: keyed routing bullet requiring the tier to be stated in the
  dispatch itself, so an invisible omission becomes visible output. Prose
  paragraph tightened to point at the key rather than restate the table (the
  kickoff is pasted into every session — duplication is a per-session tax, #542).
- .claude/hooks/pretooluse-agent-model.sh: PreToolUse on Agent, `ask` when a
  committing agent is dispatched with no explicit `model`. Narrow by design —
  passes through read-only/recon types, `fork` (model override ignored by the
  tool), and any dispatch already naming a tier, because a gate that fires on
  every fan-out trains one-shot dismissal. `ask` not `deny`: routing is a
  judgment call with no derivable right answer, unlike the H6/H10 merge gate.
- New decision record `process.per-agent-model-routing`; catalog regenerated.

Decision matrix verified against 9 payloads incl. a replay of the dispatch that
missed. decisions-validate: OK.

fixes #583
2026-07-25 11:02:42 +02:00
timothy 6cf99718ee feat(436): arbitrary-depth rule-builder group nesting
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 34s
PR Gates / Docs update reminder (pull_request) Successful in 38s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 42s
PR Gates / decisions lifecycle (pull_request) Successful in 1m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m3s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17m14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m32s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The rule builder's Group nesting was capped at one level (#176's Kodi
model). Generalize it to recursive nesting bounded by a single shared
constant, MAX_GROUP_DEPTH (types.ts, = 5, root group is depth 0):

- parse.ts: replace the allowNested boolean with a depth counter that
  recurses to the cap; deeper input stays out of subset (null -> raw-text
  fallback), so parse remains the exact inverse of compile. Sub-group
  detection now requires the leading '(' to be the one closed by the
  trailing ')' (quote/escape aware), so '(a)x(b)' can't be mistaken for
  one wrapped group.
- RuleBuilder.tsx: 'Add group' is offered while depth < MAX_GROUP_DEPTH
  instead of only at the root; nested group boxes get box-sizing:
  border-box so per-level padding can't overflow (no global reset).
- roundtrip.test.ts: the 500-tree generator nests to the cap and asserts
  the corpus actually reached it; explicit depth-3 cases added to
  compile/parse/validation tests and a depth-gate test to RuleBuilder.

compile.ts and validation.ts already recursed correctly and are unchanged.
No backend/OpenAPI change.

Docs: spa-conventions.md §12; decisions lifecycle — new active record
spa.rulebuilder-nesting, predecessor spa.smartcollection-rule-builder
relocated to docs/decisions/archive/spa.md as superseded.

fixes #436
2026-07-25 11:01:35 +02:00
timothy be1070de51 Merge pull request 'feat(437): inline RuleBuilder smart-query authoring in Channel Builder' (#579) from feat/437-rulebuilder-channelbuilder into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 15m56s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 16m3s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 20m47s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 11m29s
2026-07-23 21:17:41 +00:00
timothyandtimothy 65c0e09179 feat(415): per-channel fault detection — server-derived health object + Problems filter (#581)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 15m30s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 15m48s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m32s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Closes #415. Server-derived health object on the channel list + detail DTOs (built-timeline detection, kind-agnostic across all 5 PlayoutScheduleKind; assessable gate keyed to the owning channel's mode), single "Problems" SPA filter with per-fault badges. Supersedes #72's api.channel-health-signal decision.

Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-23 20:45:19 +00:00
timothyandClaude Opus 4.8 5fba6187ce feat(437): inline RuleBuilder smart-query authoring in Channel Builder
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m0s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m38s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m49s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adopt the reusable RuleBuilder (#176) for inline query authoring in the Channel
Builder (/app/new-channel). Extract CollectionsScreen's smart-collection dialog
into a shared, self-contained component (SmartCollectionDialog) and consume it in
both screens; the Channel Builder's Collections source gains a "New smart query"
action that persists the authored query as a real SmartCollection and adds it to
the lineup by smartCollectionId. Pure frontend — no REST/MCP surface change (the
MCP already exposes ersatztv_create_smart_collection).

The Auto-Tune half of #437 is a different primitive (group-by, not single-query
filtering) and a backend epic; it is designed separately in
docs/superpowers/specs/2026-07-23-auto-tune-arbitrary-field-design.md and filed as
its own issue rather than wired here.

Verification: web typecheck + lint clean; full vitest suite green (981, incl. a new
inline-smart-query test); cold-context review clean; live-E2E on a real instance
(query authored in the SPA persisted as SmartCollection "Action Picks" and added to
the lineup, 0 console errors).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:08:41 +02:00
timothyandClaude Opus 4.8 7e6861a928 fix(434): accurate endpoint description (DB-sourced) + tests for case-insensitivity, tag exclusion, distinct
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m24s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m43s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m38s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m50s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:55:32 +02:00
timothyandClaude Opus 4.8 9cb51f6a73 docs(434): [decisions-edit] update field-values decision + api-conventions for DB sourcing
Edits the api.search-field-values decision record's rationale prose in
place (same key, same date, not a reversal) to describe the DB-sourced
per-field distinct-values design and the narrowed allow-list, replacing
the superseded Lucene-term-dictionary description. Updates the endpoint's
api-conventions.md entry the same way. Regenerated docs/decisions/README.md
via build_decisions_catalog.py; decisions_validate.py passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:44:15 +02:00
timothyandClaude Opus 4.8 c2fb62dc88 docs(434,435,438): decisions records, spa-conventions §12, api-conventions
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:44:15 +02:00
timothyandClaude Opus 4.8 b4ae0bde18 feat(434): distinct-values search endpoint (text fields, Lucene term enumeration)
Adds GET /api/v1/search/fields/{name}/values?q=&limit= — the backend slice of the
visual rule builder's facet-value typeahead (#434). Enumerates distinct Lucene term
values for a text field via MultiFields.GetTerms + TermsEnum, filtered by a
case-insensitive prefix, limit clamped to [1,50]. 404s when the field is absent from
SearchFieldCatalog or is not type "text". ElasticSearchIndex (the optional external
backend) throws NotSupportedException for this method — its text fields are analyzed,
not keyword-mapped, so a terms aggregation isn't safe to guess at without verifying
against a live cluster.

Regenerated OpenAPI trio (v1.json, v1.d.ts, endpoint-index.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:43:27 +02:00
timothyandClaude Opus 4.8 5618ad12ea plan(434,435,438): RuleBuilder bundle implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:43:26 +02:00
timothyandClaude Opus 4.8 4e2183d9a3 design(434,435,438): RuleBuilder bundle spec — validation, relative-date operators, facet typeahead
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:43:26 +02:00