Adds the shared optimistic-concurrency contract so a stale second tab can no longer silently overwrite a fresher edit. PR1 lands the infra + the Block reference aggregate; PRs 2–4 fan the same recipe across the other 8 roots (design: #253#issuecomment-8472). Contract - `IVersionedAggregate` (`int Version`) on all 9 replace-all roots (ProgramSchedule, Block, Template, DecoTemplate, Playlist, Collection, Playout, MultiCollection, RerunCollection), EF-mapped `.IsConcurrencyToken()`; one dual-provider migration `AddAggregateVersions` (nullable:false, default 0). - Strong `ETag` of `Version` on the aggregate GET; `If-Match` on the PUT; mismatch → 412 (distinct from the §3a 409 build-lock guard). Successful PUT returns the new ETag. - `PreconditionFailedError : BaseError` → 412 in `ApiResults.ToErrorResult`; `ConcurrencyHeaders.ParseIfMatch/SetETag`; malformed If-Match → 400; `*`/absent = Phase-1 force-write. Block reference wiring - Handler: standalone `Either` via `CheckVersion` AFTER validation (never through `Apply`, which Join()-flattens the subtype to 422), unconditional `Version++`, `SaveChangesWithConcurrencyGuard` backstop (DbUpdateConcurrencyException → 412). - `BlockViewModel.Version` (header-only, not echoed in the body); controller sets the ETag on GET items and on the successful PUT. - SPA: `client.requestWithMeta` seam; `blocks.getBlockItemsWithMeta` + `replaceBlock` If-Match/ETag round-trip; `BlockEditor` holds the ETag, sends If-Match, and on 412 opens a blocking "changed elsewhere — reload" dialog. Tests - Handler contract tests: stale-If-Match → 412 (no mutation), matching/absent → success + bump, no-op save still bumps, and a two-context racing save → 412; proven non-vacuous (drop `.IsConcurrencyToken()` → the race test fails). - Controller tests: malformed If-Match → 400, If-Match threaded to the command, ETag on GET/PUT, 412 passthrough. SPA: requestWithMeta ETag, replaceBlock If-Match, 412 dialog. Docs: api-conventions §7a, spa-conventions §4a, domain-model glossary, decisions log. Refs #253 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
37 KiB
Decisions — append-only log
Purpose: why the codebase does what it does, so agents don't "fix" an established convention or relitigate a settled call. Append new entries at the bottom in date order; never edit or delete past entries except to fix a factual error. Update this doc in the same PR that changes any fact below (or that establishes a new convention worth recording).
2026-06 — REST API wraps existing MediatR handlers 1:1, no service layer
The REST API (#2, docs/rest-api.md) is thin controllers over the existing MediatR
Create/Update/Delete handlers — no new service/business-logic layer was introduced, since nearly
every handler already returns Either<BaseError, T>, which maps cleanly to HTTP status codes.
Latent handler bugs (missing existence checks, KeyNotFoundException risk, etc.) are fixed at
the handler, converting what would have 500'd into a proper 404/422 — not papered over in the
controller. Established across the #2a–#2e gap-issue PRs. Deep FK ids nested inside item-list
request bodies (e.g. a schedule item's CollectionId) are deliberately not existence-checked at
that depth, to avoid N+1 validation queries — precedent set by the schedules endpoints (#172); see
docs/api-conventions.md §3 for the up-to-date statement of this rule.
2026-06 — UI rebuild is a React SPA (ChicoryTV) on the REST API, not a Blazor reskin
#59 committed to a full SPA rebuild rather than reskinning Blazor Server pages. Blazor removal is
split into two phases under #91: (a) root-flip (SPA becomes /) + legacy-route redirects —
DONE, merged via PR #148 (ErsatzTV/LegacyUiRedirects.cs, feat/91-cutover → main). (b) full
Blazor removal — gated on every route having an SPA equivalent; tracked route-by-route in
docs/blazor-route-parity.md.
2026-07 — Response DTOs live in ErsatzTV.Core/Api, file-scoped #nullable enable
New REST response DTOs go in ErsatzTV.Core/Api/<Domain>/*ResponseModel.cs and mirror the shape of
the corresponding Application-layer ViewModel — controllers never expose VM types directly. Because
ErsatzTV.Core.csproj sets <Nullable>disable</Nullable> project-wide, any response-model file
with an optional member needs its own #nullable enable pragma at the top (most already have one).
ErsatzTV.Application has no nullable context at all — do not add ? annotations to types living
there; that's a Core/Api-layer-only convention. Full detail: docs/api-conventions.md §2.
2026-07 — PUT-replace list endpoints derive Index from array order; alternate-schedules last row = catch-all default
For "replace the whole list" endpoints (PUT over a collection — schedule items, template items,
etc.), the item's Index is derived from its position in the request array, not from a
client-supplied index/order field — established by ReplaceScheduleItemsRequest.ToCommand
(Items.Select((item, index) => item.ToReplaceCommand(index))). Separately, ProgramScheduleAlternate
and PlayoutTemplate rows (both IAlternateScheduleItem) are evaluated in Index order,
first-match-wins; the convention is to place the least-conditional (or unconditional) row last
so it acts as the catch-all default. Established by the alternate-schedules work (PR #179,
AlternateScheduleSelector.cs).
2026-07 — Templates editor in the SPA is a table, not Blazor's drag-calendar
The legacy Blazor TemplateEditor.razor used a drag-and-drop day-grid calendar UI. The SPA
equivalent (/app/templates/{id}, PR #173) renders the same day/block assignment as a table
instead. This is an accepted, deliberate parity deviation — don't "fix" it to match Blazor's
interaction model without discussing it first.
2026-07-07 — API artwork contract: rooted URLs produced server-side
API response DTOs return artwork as rooted, directly-usable URLs (/artwork/posters/...,
/artwork/thumbnails/..., /artwork/fanart/...), plus passthrough for absolute http(s):// URLs
and Jellyfin/Emby proxy variants. Established by PR #181
(ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs, private Artwork(...)
helper — comment: "Returns a rooted, directly-usable artwork URL for the SPA's <img src>... the
SPA [needs it pre-rooted]"), then generalized into the reusable ApiArtwork helper
(ErsatzTV.Core/Api/ApiArtwork.cs, PR #183). Root cause: the SPA has no <base href>, unlike
Blazor, so relative artwork paths that worked for Blazor pages 404 in the SPA. Do not reuse the
Application-layer Mappers used by Blazor (e.g. MediaCards/Television mappers) for new API
DTOs — those still return old Blazor-convention relative paths; map from the domain/VM directly and
root the path via ApiArtwork.
2026-07-07 — Decode-style endpoints take a row id and look up server-side
Endpoints that decode/expand opaque stored state accept a database row id and resolve server-side,
rather than accepting client-supplied serialized state to decode. Established by
GET /api/playouts/history/{id} (PlayoutController.GetHistoryDetails, PR #182) — the row's raw
JSON (Key/Details) is decoded server-side into PlayoutHistoryDetailsResponseModel, the client
never round-trips the raw payload itself.
2026-07-07 — Season/episode/music-video drill-in via parentId, not new child-listing endpoints
Rather than adding dedicated child-listing endpoints per media kind (e.g. "list episodes of a
season"), the library-browse endpoint takes an optional parentId query param and the SPA drills
in by re-querying with it. Established across PRs #181/#183 (library-picker season drill-in, then
media-detail's season/episode/artist/music-video browsing). Avoids a combinatorial explosion of
per-kind child endpoints.
2026-07-07 — Convention docs read at session start, updated in-PR
docs/api-conventions.md, docs/spa-conventions.md, docs/e2e-local.md,
docs/blazor-route-parity.md, docs/domain-model.md, docs/decisions.md, and docs/README.md
are the standing reference set every ChicoryTV session should read before starting work, and each
one carries an explicit "update this doc in the same PR" rule rather than deferring doc updates to
a follow-up. These docs replace per-session recon — an agent reads the index
(docs/README.md) and the relevant convention doc instead of re-deriving conventions from the code
each time it starts API/SPA/E2E/parity work. A testing map and a generated-endpoint index are
tracked as still-to-come under #185. Drafting this doc set also surfaced a drift in
ApiControllerSecurityTests.cs's hardcoded controller registry (several controllers under
ErsatzTV/Controllers/Api/ are missing from it — see docs/api-conventions.md §6) — tracked as a
follow-up under #184 rather than fixed inline, since it's a pre-existing gap, not something this
doc-drafting pass caused.
2026-07-09 — Playback-troubleshooting completion feedback: poll status, no push channel
The SPA playback-troubleshooting screen (PlaybackTroubleshootingScreen.tsx, #145) reports FFmpeg
completion by polling GET /api/troubleshoot/playback/status every ~2s while a session is
running (plus one poll on mount so a session started elsewhere still gates Play), rather than a
server push. The status endpoint returns { state, exitCode, speed, logs }; the screen captures the
running→completed/failed transition in local component state and surfaces a completion notice
(success on exit 0, warning otherwise) — the SPA equivalent of the Blazor page's MediatR
ICourier/ISnackbar PlaybackTroubleshootingCompletedNotification. Chosen over SignalR/SSE
because the SPA has no push channel and troubleshooting sessions are short and user-initiated, so
a lightweight poll (started on Play, stopped on settle/unmount) is simpler than standing up a new
real-time transport. Speed thresholds and the "(Speed: Nx)" badge colors are copied verbatim from the
Blazor GetSpeedClass (red <0.9, green >1.1, amber otherwise).
2026-07-09 — datetime-local instead of Chronic natural-language start parsing
The channel-mode "Date and Time" input in the SPA playback-troubleshooting screen uses a native
<input type="datetime-local">, a deliberate deviation from the Blazor page, which parsed a
free-text field with Chronic.Core.Parser (natural language like "yesterday at 8pm"). The SPA has no
Chronic dependency and a picker is unambiguous; the selected local datetime is sent to
playback.m3u8 as an ISO-8601 start param via new Date(value).toISOString(), which the
controller binds to DateTimeOffset? exactly as the Blazor round-trip ("o") format did.
2026-07-09 — SPA gates Download Media Sample while a session is active
Minor intentional deviation: the SPA playback-troubleshooting screen disables Download Media Sample (alongside Download Results) while a troubleshooting session is starting/running; Blazor only gated Download Results. Both downloads compete with the live transcode for I/O and the sample archiver reads the same media file, so gating both during a session is strictly safer and costs nothing (sessions are short).
2026-07-09 — OpenAPI spec mirrors the runtime Newtonsoft serializer (#198)
The generated OpenAPI document is made to follow the runtime JSON contract, not the reverse. Runtime
/api/* responses are serialized by Newtonsoft via CustomContractResolver/CustomNamingStrategy
(camelCase + a FFmpegProfileId→ffmpegProfileId special case + [JsonProperty] overrides such as
ChannelResponseModel.FFmpegProfile→ffmpegProfile), while Microsoft.AspNetCore.OpenApi generates the
spec from System.Text.Json metadata, whose camelCase drifted (fFmpegProfileId, fFmpegProfile). That
drift fed the SPA the wrong key. Rather than hand-patch the spec or change the wire format (breaking clients),
we added NewtonsoftSchemaNamingTransformer — an OpenAPI schema transformer registered on all three
documents that renames each schema property through the same Newtonsoft contract resolver the runtime uses,
so the spec matches the wire format by construction. A contract test
(OpenApiSerializerContractTests) serializes representative DTOs through the real runtime settings and pins
the spec property sets to them. Decision: the wire format is the source of truth; the spec follows it via the
real contract resolver. This also fixed a latent SPA bug (the channel-list "FFmpeg profile" column read
fFmpegProfile and always showed "Unassigned"). Issue #198.
2026-07-09 — YAML playout validator: paste-textarea instead of a server file path
The legacy Blazor YAML playout validator took a server-side file path (read directly off the
container's filesystem). The SPA's YamlValidatorScreen.tsx instead uses a paste <textarea> — a
deliberate deviation, not an oversight. The SPA runs entirely client-side against /api/* and has
no access to the server's filesystem, so a file-path field would either need a new
filesystem-browsing endpoint or silently fail; pasting the YAML directly is simpler and matches how
every other SPA editor already round-trips content through the API instead of the disk.
2026-07-09 — Channel numbers: prompt-driven sequential renumber instead of drag-to-reorder
The legacy Blazor channel list let you drag-and-drop rows to reorder channel numbers. The SPA
(App.tsx) instead offers a "Renumber" action that walks the list and asks for each channel's new
number via a sequence of native prompt() calls. Deliberate deviation: drag-to-reorder needs a
dedicated drag library and a bespoke reorder-persistence endpoint; a sequential prompt reuses the
existing per-channel update call and needs no new UI dependency. Revisit only if channel counts grow
large enough that prompt-per-channel becomes tedious.
2026-07-09 — "Table, not calendar" convention also covers the deco-templates editor
Extends the 2026-07 "Templates editor in the SPA is a table, not Blazor's drag-calendar" entry
above (not editing that entry — this generalizes it): the deco-templates editor
(DecoTemplatesScreen.tsx) follows the same convention, rendering its day/deco assignment as a
table rather than reproducing Blazor's drag-and-drop calendar grid. Same rationale, same
accepted-deviation status — don't "fix" either editor to match Blazor's interaction model without
discussing it first.
2026-07-09 — Trash "see all" is capped at 100 items per kind; true paging deferred
TrashScreen.tsx requests GET /api/search?query=state:FileNotFound&pageSize=100 — 100 is
SearchController's MaxPageSize, and the endpoint has no page-number parameter, so a client
can't page past the first 100 matches of a given media kind. The SPA shows the first 100 per kind
(movies, shows, episodes, etc. are separate groups, so in practice most trash lists fit well within
that per-kind cap); this mirrors the legacy Blazor Trash page, which had the same underlying search
behavior and cap. True paging is deferred until the search API grows a page param — not attempted
here, since it would mean adding a paging contract server-side, out of scope for this pass.
Superseded 2026-07-11 (#213): the cap is lifted via a per-kind "See all N …" button, without
adding any new API surface. GET /api/library/browse (LibraryBrowseController /
GetLibraryBrowseItems) already accepts mediaType + pageNum + pageSize and runs the same
underlying query as GetSearchResults (which itself fans out to GetLibraryBrowseItems per kind,
just always at pageNum=0) — so TrashScreen.tsx pages pageNum=1, 2, … through
/api/library/browse?query=state:FileNotFound&mediaType={kind}&pageSize=100 for a kind once the
user asks to see past the first 100, and appends the results client-side. The 100/kind first
page still comes from /api/search (unchanged, cheapest for the common case where a kind has
few matches); only kinds that exceed the cap ever issue the follow-up /api/library/browse calls.
2026-07-11 — Logs page-size is a client-local preference, not a server ConfigElement
The legacy Blazor Logs page persisted the user's chosen rows-per-page via
ConfigElementKey.LogsPageSize (SaveConfigElementByKey/GetConfigElementByKey), a
per-server-instance setting stored in the DB. LogsScreen.tsx instead persists it to
window.localStorage under ctv-logs-page-size (same wrapped-Storage pattern as
designSystem.ts's theme preference: try/catch getter, validated against the known option set,
falls back to a default) and restores it on mount. Deliberate deviation: this is a per-browser UI
preference, not server/business state — no other client should see or be affected by it, so there
is no reason to round-trip it through the API and grow a new /api/* surface (or reuse the
generic config-element endpoints) just to store a page-size number. Follows the existing SPA
localStorage convention (designSystem.ts theme, auth.ts token) rather than introducing a new
persistence mechanism.
2026-07-11 — Logs column sorting: allow-listed sortField/sortDirection on GET /api/logs
Parity for Logs.razor's MudTableSortLabel columns (Timestamp, Level — Message was never
sortable in Blazor either). LogsController.GetLogs adds sortField (timestamp | level,
default timestamp) and sortDirection (asc | desc, default desc) query params, normalized
server-side the same way pageNum/pageSize are clamped rather than rejected with a 422: an
unrecognized sortField silently falls back to timestamp, an unrecognized sortDirection falls
back to desc — the pre-existing default behavior (newest-first) is unreachable to break via a bad
query string. LogsScreen.tsx renders the two sortable headers as buttons with a chevron
indicating the active field/direction; clicking the active column toggles direction, clicking the
other column switches to it ascending.
2026-07-09 — Per-playout "Schedule reset" button dropped; Reset uses the server-default build mode
Blazor's playouts page had both a per-playout Reset and a separate Schedule Reset control
(setting the daily rebuild time). The SPA keeps Reset — POST /api/channels/{channelNumber}/playout/reset with no mode param, so the server picks the same
default Blazor used (Classic → Refresh, everything else → Reset) — but drops the dedicated
"Schedule reset" button: the daily rebuild time is already editable through the playout's
Edit-details flow, so a second entry point would duplicate an existing capability. Deliberate
deviation, not a lost capability. Issue #210.
2026-07-09 — Collection custom order: move up/down buttons, any-kind collections
Blazor reordered collection items with SortableJS drag-and-drop and only enabled custom ordering
for movies-only collections. The SPA (CollectionsScreen.tsx) uses per-row Move up / Move
down buttons in an explicit reorder mode instead of drag (no new drag dependency; the mode loads
ALL items first because PUT /api/collections/{id}/custom-order replaces the whole order from
array position — submitting a partial page would scramble the rest), and does not replicate
the movies-only gate: the API and the playback-side CustomOrderCollectionEnumerator sort by
CustomIndex regardless of item kind, so the SPA offers reorder for any manual collection with
custom order enabled. Issue #211.
2026-07-10 — Shared "Add to…" layer lives in web/src/media/addTo/; select-mode is an explicit toggle
The media mutation surface (#208/#209) is built on one reusable component group,
web/src/media/addTo/ (media-domain components, like MediaPosterCard — not generic
components/): AddToCollectionDialog (existing-collection Select + inline "(New collection)"
create, Blazor AddToCollectionDialog.razor parity), AddToPlaylistDialog (group → playlist
Selects, no inline create), AddToScheduleDialog (schedule Select; payload replicates Blazor's
AddProgramScheduleItem.ForMediaItem defaults — see addTo/scheduleItem.ts),
SaveAsSmartCollectionDialog, and AddToMenu (the drop-in popover for cards/detail pages via
MediaPosterCard's actions slot). New screens wanting add-to affordances use this layer —
don't build screen-local pickers. Two deliberate deviations from Blazor, applied consistently on
the search and browse screens: (1) multi-select is an explicit screen-level "Select" toggle
(off = cards open, on = cards select) rather than Blazor's always-on corner-select, because
MediaPosterCard's select handler takes over the card's single click gesture; (2) the
per-card menu offers collection/playlist for a single item of any kind, plus schedule only for
shows/seasons/artists — collection/playlist is a superset of Blazor's per-card collection-only
menu, while the schedule target is gated to exactly the kinds Blazor's
AddProgramScheduleItem.ForMediaItem call sites offer, because the server validator
(ProgramScheduleItemCommandBase.CollectionTypeMustBeValid) accepts only the
TelevisionShow/TelevisionSeason/Artist per-media-item CollectionTypes and 422s the rest.
"Add All" (query-wide) mirrors Blazor's two-step: materialize ids
via GET /api/search/all-items, then reuse the id-list add endpoints — no query-based add
command exists server-side. Issues #208/#209.
2026-07-10 — Schedule-item GET returns a flat, non-polymorphic DTO (ScheduleItemResponseModel)
GET/POST/PUT /api/schedules/{id}/items return ScheduleItemResponseModel /
ScheduleItemsResponseModel (ErsatzTV.Core/Api/Scheduling/), not the Application-layer
ProgramScheduleItemViewModel hierarchy (One/Flood/Multiple/Duration subtypes). The polymorphic VM
only described its base shape in OpenAPI, so the SPA couldn't see the subtype fields (issue #126).
The flat DTO promotes every subtype field to a nullable top-level member — multipleMode,
multipleCount (renamed from the VM's Count), playoutDuration, tailMode,
discardToFillAttempts — mapped by pattern-matching the concrete VM in
ScheduleItemResponseMapper (ErsatzTV.Application/ProgramSchedules/). Its mutation fields are
named 1:1 with ScheduleItemRequest so a GET maps losslessly back to a PUT/POST
(ScheduleItemResponseRoundTripTests is the release gate proving the fixed point). It also carries
picker-hydration fields the editor needs: collectionName/smartCollectionName/…/playlistName,
playlistGroupId (to preselect the playlist's group), per-filler names, watermarks /
graphicsElements as NamedIdResponseModel lists, the computed name, and durationEstimate.
GetProgramScheduleItemsHandler.EnforceProperties still rewrites StartType→Dynamic, Flood→One and
Playlist/Rerun→PlaybackOrder None when ShuffleScheduleItems is on — that lossy normalization is
deliberate and lives on the read side (documented + tested). New shared NamedIdResponseModel
(ErsatzTV.Core/Api/) is the generic {id, name} embed for API responses. Issues #126/#207/#212.
2026-07-10 — Playout API mutations return 409 while the build lock is held (#215)
Blazor disabled per-playout Reset/Erase/Delete/Edit while a BuildPlayout was in flight
(EntityLocker.IsPlayoutLocked, Playouts.razor + per-kind editors); the REST API had no
equivalent, so a client could race an in-flight build with a destructive ExecuteDelete and leave
a half-built playout. Adversarial-reviewer#18 promoted this to a #91-phase-(b) removal gate: after
Blazor is deleted the invariant would vanish entirely.
Decision: enforce the invariant server-side on the API rather than re-implementing a live push
channel. PlayoutController and ChannelController inject IEntityLocker; every id-keyed mutation
— PUT /api/playouts/{id}, .../deco, .../alternate-schedules, .../templates,
POST .../erase-items, .../erase-items-and-history, DELETE /api/playouts/{id}, and
POST /api/channels/{channelNumber}/playout/reset — checks IsPlayoutLocked(id) first and returns
409 Conflict (ApiResults.ConflictProblem, new shared helper mirroring NotFoundProblem) while
the build lock is held. The PUTs are gated too (not just the destructive ops): the target invariant
is "no mutation during a build", matching Blazor's edit-disable.
-
The guard is advisory check-then-act, not mutual exclusion — same posture as Blazor's disabled buttons. A
BuildPlayoutalready sitting in the worker queue can take the lock a few milliseconds after the check passes, so the original race is narrowed, not eliminated; consequences remain self-healing (the next rebuild corrects a half-mutated playout). True prevention — having each mutation acquire the playout lock for its duration — was deliberately not taken:LockPlayoutpublishesPlayoutUpdatedNotification(UI churn per mutation) and would make mutations block builds, a semantics change out of scope for restoring Blazor parity. -
reset-allis deliberately NOT gated — it stays 202.ResetAllPlayoutsHandleralready silently skips locked playouts, which matches Blazor and the handler semantics; a fire-and-forget bulk enqueue always accepts. -
SPA mirrors the lock via data, not a push channel —
PlayoutListItemResponseModelgains anIsLockedbool (set fromIsPlayoutLockedin the controller's list projection). The playouts screen disables Reset/Erase/Erase-and-history/Delete for a locked row and shows a "Building…" Badge; on a 409 from any mutation it surfaces the error and callsquery.refresh()so the row picks up the flag. No new polling was added (the existing 30s channel-state poll is unchanged).
Precedent for the 409 shape: TraktController (left as-is with its own private ConflictProblem()
to keep the diff small). Convention recorded in api-conventions.md §3a.
2026-07-11 — Schedules SPA editor: draft/explicit-Save over instant-persist; Copy includes multi/smart/rerun; shuffled-GET normalization preserved
The ChicoryTV schedules editor (web/src/screens/SchedulesScreen.tsx + web/src/schedules/,
issue #207) rebuilds the schedule-item lineup to full mutation parity with the legacy Blazor editor.
Three deliberate decisions:
(a) Draft model with one explicit Save, replacing instant-persist. All add/edit/copy/remove/
reorder mutate a local draft list only; a single Save issues one
PUT /api/schedules/{id}/items (the replace endpoint). This is intentional because that PUT is
destructive server-side — it deletes and recreates every item row (new ids) and triggers playout
rebuilds — so batching edits into one flush (vs. the old per-action POST/DELETE/PUT) minimizes churn
and gives the user a Discard/dirty affordance. On 422/network error the draft is kept and the error
surfaced; on success the draft is replaced with the server response. A Discard action and a dirty
guard (native confirm() on schedule-switch + in-app nav via navigationGuard.ts, plus a
beforeunload listener) protect the draft. See docs/spa-conventions.md §8. This retires the old
inline ScheduleScreen in App.tsx and its instant-persist add/delete/reorder tests.
(b) Copy item deep-copies ALL source references, including multi/smart/rerun collections. Blazor's
CopyItem omitted the multi-collection / smart-collection / rerun-collection references when
duplicating an item (copying only the plain collection/media-item/playlist refs) — a latent bug. The
SPA's copyDraftItem (web/src/schedules/itemRules.ts) copies every source field + display name, so
copying a MultiCollection/SmartCollection/Rerun item preserves its source. Deliberate deviation
fixing the Blazor omission.
(c) The shuffled-schedule GET normalization (EnforceProperties) is preserved lossiness, matching
Blazor. When a schedule has ShuffleScheduleItems, GET .../items still rewrites startType→Dynamic,
Flood→One, and Playlist/Rerun playbackOrder→None (and zeroes discardToFillAttempts for non-random
Duration items). The SPA does not fight this — it hides the Fixed start type and Flood playout
mode from the option lists (and disables the reorder arrows) for shuffled schedules, mirroring Blazor,
so a GET→edit→PUT round-trip stays consistent with the server's read-side normalization. Issue #207.
2026-07-11 — Channel editor: bare-create entry point + external-logo mutual exclusion (#212)
Two Blazor-parity decisions closing the channel-editor gaps (ChannelEditor.razor +
ChannelEditViewModel):
- Bare-channel create lives on the channels list, not a form-first route. Blazor's
/channels(noId) is a full add form; the SPA instead adds a "New blank channel" action next to "Add Channel" onChannelsScreen(web/src/App.tsx) that POSTsCreateChannelRequestwith Blazor's computed add-mode defaults ((max existing int-parsed channel number) + 1,name: "New Channel",group: "ErsatzTV",ffmpegProfileId=GET /api/settings/ffmpeg'sdefaultFFmpegProfileId,streamingMode: "TransportStreamHybrid",isEnabled/showInEpg: true, every other field at its C#default(T)— seeChannelEditor.razor'selsebranch for the source of truth) directly, then navigates to/app/edit-channel/{id}for the rest of the fields. This is a deliberate equivalent, not a parity gap: it reuses the existing full editor instead of duplicating its ~20 fields into a second form. "Add Channel" (/app/new-channel, the library-to-lineupChannelBuilderflow) is unrelated and untouched. - External logo URL wins over an uploaded logo, mirroring
ChannelEditViewModel.ToUpdate/ToCreate. The channel's logo isArtworkContentTypeModel { path, contentType, isExternalUrl }; on hydration,isExternalUrl: truepopulates a separate "External logo URL" field and the uploaded-logo draft state is treated as empty (EMPTY_LOGO = { path: '', contentType: '' }inChannelEditScreen.tsx) so the two never disagree. On submit, a non-blank URL always wins:logo: { path: url, contentType: '' }, exactly matchingExternalLogoUrl's precedence in the C# view model. Uploading a file clears the URL field (the last-set field wins, Blazor parity viaUploadLogo's_model.ExternalLogoUrl = null). The URL is validated as http(s) client-side before save is enabled (Blazor has no equivalent validation; added because the field is free text with no server-side format check surfaced to the SPA).
Also landed with #212: preferredAudioLanguageCode/preferredSubtitleLanguageCode,
musicVideoCreditsTemplate, and streamSelector moved from free-text Inputs to Selects fed by
GET /api/languages / /api/channels/music-video-credits-templates /
/api/channels/stream-selectors. Each keeps the channel's currently-stored value selectable even if
it's absent from the reference list (optionsKeepingCurrent in ChannelEditScreen.tsx) so loading
an existing channel never silently changes the value out from under an unmanaged language code or a
template/selector file removed from disk since save.## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file
With multiple sessions/agents working the repo in parallel, the old protocol — every session
wholesale-rewrites docs/handoffs/chicorytv-issue-queue.md on main (session state + queue +
next-session prompt) — became a last-writer-wins race. New protocol: volatile queue state
moved to Gitea, which is concurrency-safe by construction. Pinned tracker issue #237
holds the goal + ordered arc in its body (edited rarely, only on arc changes, re-read before
edit) and an append-only session-comment log (fixed template: Closed / Filed / Triage /
Arc change / Recommended next). Milestone Blazor removal (#91 phase b) + the review and
in-progress labels are the machine-queryable view. Sessions claim an issue before working
it (in-progress label + claim comment; the tiny read→claim race window is accepted, later
claimant backs off; stale claims — no commits/comments ~48h — may be taken over with a comment).
Every new issue gets an explicit end-of-session triage verdict — gate-blocker (milestone + arc
slot) or backlog (label only) — so review findings adjust the queue only through that step and
the arc doesn't drift. The handoff file keeps only the static kickoff prompt and the
append-only Lessons lore (per-session prompts are gone; task context lives in issue bodies).
2026-07-11 — EntityLocker: atomic flags + single-owner release discipline, no owner tokens (#231)
EntityLocker (process-wide singleton, ErsatzTV.Infrastructure/Locking/EntityLocker.cs) is the
advisory "operation on entity X is in progress" mutex layer. Adversarial review (#20/F5, →#231)
found three defects: six plain-bool flags with a non-atomic check-then-set (two threads could both
acquire and both return true), tokenless Unlock* letting any caller release another owner's lock
(e.g. BuildPlayoutHandler ignored LockPlayout's return then unconditionally unlocked in
finally), and one batch taking a single LockLibrary released after the first of two enqueued
scan units (so the second ran unlocked).
Model chosen: tokenless atomic flag + documented single-owner release discipline. The six bools
became ints guarded by Interlocked.CompareExchange (0/1), so Lock*'s bool return is now a
reliable "this call won the transition" signal and the change event fires exactly once per
transition. The contract (XML-doc'd on IEntityLocker): a true from Lock* confers ownership of
exactly one release — performed either in the acquiring scope (finally, gated on the captured
bool) or by the single designated releaser the acquirer hands off to (the consumer of the message
enqueued while holding the lock, with a compensating unlock if the enqueue throws — the established
TraktController.EnqueueWithTraktLock / scheduler unlock: last tail pattern). Callers must never
release a lock they did not acquire. Unlock* on an already-unlocked slot returns false, fires no
event, and logs a Warning — the loud tripwire for double-release bugs; it does not throw or
Debug.Assert, because an advisory flag must stay safe to release in finally paths. The three
ConcurrentDictionary-backed kinds (Library/Playout/RemoteMediaSource) were already atomic
(TryAdd/TryRemove) and kept their semantics (the redundant ContainsKey pre-checks were dropped
as tidy-up); the interface signature is unchanged across its ~40 call sites.
Rejected: owner tokens/leases — the acquirer and releaser for Library/Trakt/Plex/Collections
locks are different code correlated only by entity id across in-memory Channel<T> queues, so a
token would have to travel inside ~8 background-request message types for a defect that discipline
plus the now-trustworthy atomic return value already prevents. Counted/reentrant locks — wrong
semantics: these are exclusive in-progress flags; two holders is the failure mode, not a feature.
Call-site fixes this model prescribes land separately: #232 (scan lifecycle — enqueue only after a
successful lock, compensating unlock on enqueue failure, one release per acquisition in batches) and
#234 (BuildPlayout/subtitles gate their finally unlock on the captured acquire result).
2026-07-11 — Channels screen extraction (#244): single-file screen, no sibling helper dir (epic #243 phase 1)
First bounded extraction under the App.tsx modularization epic (#243): the Channels domain moved
verbatim out of web/src/App.tsx into web/src/screens/ChannelsScreen.tsx (zero-prop, self-sufficient,
mirroring the SchedulesScreen extraction), with its behavior tests moved to a colocated
ChannelsScreen.test.tsx that owns its own scoped fetch mock (spa-conventions §6). Pure structural
move: no API/route/CSS/visual change; App.tsx retains only the import + the <ChannelsScreen />
dispatch clause.
Decision — no web/src/channels/ sibling helper directory (unlike Schedules' web/src/schedules/).
Channels' pure logic (stateByChannelId, sortedChannels, groupedChannels, progressFromChannelState,
formatChannelNumber) totals ~30 lines with no independent business-rule layer comparable to Schedules'
itemRules.ts (~470 lines, separately unit-tested). Keeping it inside the single screen file matches the
Blocks/Decos/Templates precedent. Revisit only if a later #243 phase adds substantial pure Channels logic
worth isolating.
One cross-domain helper inlined, not shared: the Dashboard-owned progressFromNowPlaying (still in
App.tsx, used by OnAirCard) was structurally reused by the Channels progressFromChannelState. Rather
than export it from App or create a shared module, its ~5-line start/finish/now percentage math was inlined
into the moved progressFromChannelState (behavior-identical — ChannelState.nowPlaying carries the same
startUtc/finishUtc shape), so ChannelsScreen.tsx has no import back into App.tsx.
#238 (TopBar primaryAction dead button) left untouched — the channels route's inert ctv:primary-action
dispatch is #238's owned bug and out of scope for a behavior-preserving extraction; the shell/action redesign
is deferred to #247 (epic phase 4).
2026-07-11 — Optimistic-concurrency contract for replace-all PUTs (#253 PR1: infra + Block reference)
Replace-all aggregate PUTs had no optimistic concurrency — a stale second tab silently overwrote a
fresher edit (200, no signal) across ~10 aggregate surfaces. PR1 lands the shared contract on the Block
reference aggregate; PRs 2–4 fan it out. The full ratified design + independent-review hardening is
#253#issuecomment-8472;
the mechanics live in api-conventions.md §7a. Decisions frozen here:
- Token = uniform plain
int Versionon each root implementingIVersionedAggregate, EF-mapped.IsConcurrencyToken(), one dual-provider migration (AddAggregateVersions,defaultValue: 0). Not a reusedDateUpdated(tick-collision, SQLite TEXT precision, couples UI cosmetics to correctness) and not a MySQL-native rowversion (portability over provider-native). - 412 Precondition Failed, not 409 — 409 stays the §3a EntityLocker "build in progress" guard;
distinct codes → distinct SPA UX. New
PreconditionFailedError : BaseError→ 412 inApiResults.ToErrorResult. - Pre-check AND EF token both required. The handler pre-check (a standalone
Eitherintroduced AFTER the validation pipeline — never viaApply, whichJoin()-flattens the subtype to 422) gives a clean 412; the unconditionalroot.Version+++IsConcurrencyTokenUPDATE-guard + aSaveChangesWithConcurrencyGuardbackstop closes the residual load→save TOCTOU (DbUpdateConcurrencyException→ 412). - Unconditional bump (not "only when a child changed"): EF writes the root row only when a scalar differs, so a no-op PUT-back must still bump to fire the token and rotate every other client's ETag.
- Config-only aggregate boundary: every mutating handler of a root's editor-visible config state
bumps
Version(incl. bulkExecuteUpdate/Deletewriters via.SetProperty); regenerated build output (playout items/history) is outside the token — neither bumped nor guarded. - Header-only ETag, strong tag of the decimal
Version; parsed/emitted byConcurrencyHeaders. The successful PUT returns the new ETag (else a same-tab second save 412s against its own write). - Phasing: Phase 1 (this arc) = a missing
If-Matchforce-writes (zero breakage) while the SPA starts echoing; Phase 2 (a later PR) flips missing → 428 after every editor echoes and one release soaks.If-Match: *stays the scripted force-write escape hatch. - Child stable-identity is OUT of #253 (the "moved fill-group item inherits the wrong slot's state" concern on the positional reconcile) — root-anchored versioning is orthogonal to it; split to #259.