The three recurrence arrays are read CONJUNCTIVELY by
AlternateScheduleSelector.GetScheduleForDate, so an empty set matches no date.
`?? []` on an omitted array therefore returned HTTP 200 while storing an
alternate-schedule or template item that could never apply, silently -- while the
read side (#823) already read a NULL column as the All*() sets.
Absent and explicitly-empty are two different requests and get two answers:
ABSENT (missing, or explicit null) normalizes to AlternateScheduleSelector.All*(),
the same symbols the read side substitutes; EXPLICIT [] is rejected with a 422
naming the consequence, via RecurrenceSetBounds called from both replace handlers.
The rejection lives in the handlers, not the controller, because
api.ffmpeg-profile-numeric-bounds' "accept an UNCHANGED bad value" rule binds
hardest here: both PUT paths are whole-list replaces, so rejecting a pre-existing
empty set would make every OTHER item in the list uneditable. That comparison
needs the stored row. The validated set is derived from `incoming`, so the
highest-Index catch-all -- whose recurrence the handler discards -- is excluded by
construction.
Verified: full ErsatzTV.Tests suite green; three mutation proofs with disjoint
reddened sets; live-E2E against a real instance confirmed an OMITTED property
round-trips as unrestricted (the Newtonsoft missing-property chain unit tests
cannot reach), an explicit [] returns the 422, and [] on the catch-all is accepted.
Cross-family cold review BLOCKED the first implementation with 3 findings, all real
and all fixed; re-review returned MERGEABLE.
Follow-up #894 filed: the SPA can still build the empty state the server rejects.
fixes#880
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#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>
Added ChannelId to PlayoutNameViewModel and all 6 construction sites
(Mapper, GetPlayoutByIdHandler, and the Update{,Scripted,ExternalJson,Sequential}
PlayoutHandler commands), plus the list DTO PlayoutListItemResponseModel and the
PlayoutController list projection. Regenerated OpenAPI (v1.json) and the TS client
(v1.d.ts); endpoint-index.md unchanged (no endpoint/operation delta). Simplified
PlayoutsScreen resetSelectedChannel to key directly on selectedSummary.channelId
instead of resolving via channelStates. Updated controller + SPA tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 3 of ersatztv#71 (reshuffle-playout). Appends Seed as the last
positional field on PlayoutNameViewModel, threading it through the
Mapper list projection, GetPlayoutByIdHandler detail projection, and
the four Update*PlayoutHandler VM constructions, then exposing it on
PlayoutListItemResponseModel/PlayoutResponseModel via the controller's
ToResponse/ToListItemResponse mappers.
ChannelControllerTests.MakePlayout was an additional construction site
not listed in the task brief (positional-record break); updated to
append 0 for Seed alongside the brief's ErsatzTV.Tests changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Version every /api route to /api/v1 (251 controller routes + ~24 Location
headers + the scanner callback URL + the Startup request-log literal),
uniform across the machine API, auth, scanner and scripted-build surfaces.
Add ApiVersionRewriteMiddleware: a legacy unversioned /api/* request is
rewritten (NOT redirected) to /api/v1/* in-pipeline — method, body, auth
headers and query survive — carrying RFC 8594 Deprecation/Sunset headers,
so curl / the future MCP server / bookmarks keep working. An already-
versioned path passes through; a future /api/v2 is never forced to v1.
Standardize the route convention (leading-slash absolute route per method,
no class-[Route] — except the two Scanner/Scripted controllers whose ~all
actions share a parametrized {id} prefix), enforced by ApiRouteVersioningTests
(^/api/v\d+/ over the whole Controllers.Api surface; browser-nav
/auth/oidc/login is out of scope).
Regenerate v1.json (160 paths, all /api/v1)/endpoint-index/v1.d.ts; sweep 945
SPA request literals + the test mocks (regex + positional URL parsers). /api/v1
is additive-only after freeze; the legacy-rewrite shim sunsets in ~2 releases
(owner decision) with removal tracked as a Phase-3 follow-up.
Docs: decisions.md 2026-07-13, api-conventions §1/§9, rest-api/spa-conventions/
blazor-route-parity/e2e-local/domain-model.
fixes#286
refs #197
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Posture-independent safe hardening from the #197 cold API security review
(the clear-cut fixes that don't depend on the fail-closed/CORS/versioning
posture design, which is tracked separately):
- ApiKeyAuthorizationFilter: compare X-Api-Key with
CryptographicOperations.FixedTimeEquals instead of ordinal string.Equals
(removes the response-timing oracle on the write key). [S10]
- PlayoutController: clamp pageNum/pageSize on GET /api/playouts and
/api/playouts/{id}/items to Math.Clamp(_, 1, 100), matching the documented
api-conventions §1 convention every other paged endpoint already follows —
these two were passing the raw value straight to EF Take(). [S8]
- SecurityHeadersMiddleware: emit X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin on
every response (nosniff backstops the artwork content-type MIME-sniffing
risk). CSP/HSTS deferred to the #197 posture design (CSP needs SPA
validation; HSTS is proxy/TLS-owned). [S10]
Refs #197.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the frozen ETag/If-Match/412 recipe (Block reference implementation)
onto the Template and DecoTemplate aggregates:
- ReplaceTemplateItems / ReplaceDecoTemplateItems commands gain
Option<int> ExpectedVersion; ToCommand() on the request DTOs threads it
through from If-Match.
- Handlers introduce the version check as a standalone Either after
validation (never via Apply), bump Version unconditionally before
saving, and persist through SaveChangesWithConcurrencyGuard so a losing
writer maps to 412 instead of 500. DecoTemplate's post-commit playout
Reset enqueue now only runs after a successful save.
- TemplateViewModel / DecoTemplateViewModel carry Version (header-only,
not echoed in the response body), populated in Mapper.
- TemplateController / DecoTemplateController: GET items emits a strong
ETag of the root's version; PUT parses If-Match (400 on malformed),
threads the expected version into the command, and returns the new
ETag from the refreshed root on success. Both PUT actions now use the
handler's returned item list directly instead of re-querying items.
- SPA: templates.ts / decoTemplates.ts gain getXItemsWithMeta and an
If-Match-aware replaceX; TemplateEditor / DecoTemplateEditor hold the
ETag in a ref, read items-with-meta first on load, and open a
"changed elsewhere" ConfirmDialog on a 412 instead of navigating away.
Tests: new ReplaceTemplateItemsHandlerConcurrencyTests /
ReplaceDecoTemplateItemsHandlerConcurrencyTests mirror the Block
concurrency contract tests (stale/matching/absent If-Match, no-op bump,
racing-save 412, non-vacuous backstop). TemplateControllerTests /
DecoTemplateControllerTests gain ETag/If-Match/412 coverage.
TemplatesScreen.test.tsx / DecoTemplatesScreen.test.tsx gain a 412
conflict-dialog test mirroring BlocksScreen's.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slice C of the async-op contract normalization:
- channel reset (POST /api/channels/{channelNumber}/playout/reset) now
returns 202 Accepted (was 200 Ok) — it only queues a background rebuild
- reset-all (POST /api/playouts/reset-all) still 202 but now returns a
ResetAllPlayoutsResponseModel body reporting QueuedPlayoutIds /
SkippedLocked / SkippedUnsupported instead of silently swallowing skips;
handler returns a new ResetAllPlayoutsResult record
- single-playout GET (GET /api/playouts/{id}) now exposes IsLocked on
PlayoutResponseModel, set from IEntityLocker.IsPlayoutLocked mirroring
the list projection — gives a polling client the lock flag
Tests: channel reset asserts 202; reset-all asserts 202 + skipped-body
shape; single GET asserts IsLocked; new ResetAllPlayoutsHandlerTests
(in-memory SQLite) asserts locked/ExternalJson/None land in skipped lists
and eligible playouts in queued. docs/api-conventions.md §3a updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Closes the reviewer-verification gap from adversarial-reviewer#18: the
locked-path 409 guard tests already existed for Delete, EraseItems,
EraseItemsAndHistory, Update, and UpdateDefaultDeco, but were missing for
PlayoutController.ReplaceAlternateSchedules and .ReplaceTemplates even
though their production guards (entityLocker.IsPlayoutLocked) were in
place. Added two tests mirroring the existing pattern exactly (409
ConflictObjectResult + ProblemDetails + DidNotReceive() on the mediator
command). ApiErrorResponseMetadataTests and OpenApiErrorResponseContractTests
already carried 409 rows for both PUT endpoints, so no changes were needed
there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Blazor disabled per-playout Reset/Erase/Delete/Edit while a BuildPlayout was
in flight (EntityLocker.IsPlayoutLocked); 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. After Blazor removal this safety invariant would vanish
entirely (adversarial-reviewer#18 removal gate).
Server:
- Add public ApiResults.ConflictProblem(title, detail) (409, mirrors NotFoundProblem).
- Inject IEntityLocker into PlayoutController; guard every id-keyed mutation
(PUT {id}, PUT .../deco, PUT .../alternate-schedules, PUT .../templates,
POST .../erase-items, POST .../erase-items-and-history, DELETE {id}) → 409
when IsPlayoutLocked(id); add [ProducesResponseType(...409)] to each.
- Guard ChannelController.ResetPlayout the same way after resolving the id.
- reset-all stays 202 (ResetAllPlayoutsHandler already skips locked playouts).
- Stamp IsLocked onto PlayoutListItemResponseModel from IsPlayoutLocked.
SPA:
- Disable Reset/Erase/Erase-and-history/Delete for a locked row + show a
"Building…" Badge; on a 409 surface the error and refresh the list.
Tests: controller-level 409 guard tests (delete/erase/PUT/deco/channel-reset)
+ IsLocked projection test; new OpenAPI contract + metadata 409 rows.
Docs: api-conventions §3a, blazor-route-parity playouts verdict, decisions.md.
Regenerated v1.json + web types.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SPA needs the item's row id to call
GET /api/playouts/items/{id}/scheduling-context. Plumbed through
PlayoutItemViewModel -> PlayoutItemResponseModel as a nullable Id
(null for synthesized UNSCHEDULED gap rows, which are PlayoutGaps,
not PlayoutItems). Additive for existing consumers (Playouts.razor
reads the VM by property). Regenerated v1.json + v1.d.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Backend slice for the ChicoryTV playouts and collections screens.
PlayoutController:
- POST /api/playouts/{id}/erase-items (204; 404 pre-check; 422 unless
Block/Sequential/Scripted) -> ErasePlayoutItems
- POST /api/playouts/{id}/erase-items-and-history (204; 404; 422 unless
Classic/Block/Sequential/Scripted) -> ErasePlayoutHistory
- GET /api/playouts/items/{id}/scheduling-context (200/404) decodes a
playout item's stored context by row id via a new
GetPlayoutItemSchedulingContext query that reuses ProcessSchedulingContext
- PlayoutItemResponseModel gains HasSchedulingContext (no raw JSON in list)
- PlayoutListItemResponseModel gains PlayoutMode (ChannelNumber already present)
CollectionController:
- PUT /api/collections/{id}/custom-order (204; 404 pre-check; 422) with
UpdateCollectionCustomOrderRequest deriving CustomIndex from array order
- GetCollectionItemsHandler orders by CustomIndex (nulls last) then title/id
when the collection's UseCustomPlaybackOrder is set
Tests: controller route + behavior tests, OpenAPI ProblemDetails TestCases,
GetCollectionItems custom-order handler test. Regenerated v1.json, v1.d.ts,
endpoint-index.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Route assertions, paging clamp, 404/422/400 paths and mediator interaction
for the new PlayoutController/TroubleshootController endpoints; handler tests
for ValidateSequentialSchedule (valid/invalid/throwing) and
GetPlayoutHistoryDetails (found/not-found/malformed JSON via in-memory SQLite).
Refs #145#158
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AlternateScheduleSelector.GetScheduleForDate constructs
new DateTime(year, StartMonth, StartDay) and only recovers from an
out-of-range *day* (rolling to the 1st of the next month); an out-of-range
*month* (e.g. 0 or 13) throws again from inside that recovery path and is
never caught, crashing playout building.
Validate StartMonth/EndMonth (1..12) and StartDay/EndDay (1..31) on both
PUT /api/playouts/{id}/alternate-schedules and PUT /api/playouts/{id}/templates
whenever an item sets LimitToDateRange, returning 422 before dispatching to
the mediator. Both actions already documented 422 in their ProducesResponseType
metadata.
Adds per-playout REST for classic-playout alternate schedules and
block-playout templates, plus the default-deco read-side deferred from S3:
- GET/PUT /api/playouts/{id}/alternate-schedules (Classic only; 422 otherwise)
- GET/PUT /api/playouts/{id}/templates (Block only; 422 otherwise)
- PlayoutResponseModel gains decoId/decoName (GetPlayoutById includes Deco)
PUT assigns Index from array order (top = highest priority, last = catch-all
default), mirroring the Blazor editors. Alternate-schedule PUT requires a
non-empty list and existing ProgramScheduleIds; template PUT requires existing
TemplateIds and any supplied DecoTemplateId. Regenerates the OpenAPI spec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- CreatePlayoutRequest now carries a PlayoutScheduleKind discriminator, a
nullable ProgramScheduleId, and a ScheduleFile so POST /api/playouts can
create Classic, Block, Sequential, Scripted, or ExternalJson playouts (not
just Classic). ToCommand() validates per-kind requirements and returns
Either<BaseError, CreatePlayout>, surfacing 422 on mismatched fields via the
existing ToErrorResult() mapping.
- Add PUT /api/playouts/{id} (UpdatePlayoutDetailsRequest): DailyRebuildTime is
always applied (null clears the daily reset, matching the Blazor
SchedulePlayoutReset "Do not automatically reset" semantics); ScheduleFile is
only valid for Sequential/Scripted/ExternalJson playouts (422 otherwise) and
dispatches the matching Update*Playout command.
- Playout existence is checked via GetPlayoutById (real 404) before dispatching
UpdatePlayout, since the command's own "Playout does not exist." validation
produces a plain BaseError (422), not NotFoundError -- an existing quirk in
UpdatePlayoutHandler left as-is (out of scope for this slice).
- Extend ApiErrorResponseMetadataTests + PlayoutControllerTests for the new
Update action and the widened Create action (block/sequential/file-kind
validation paths).
- Regenerate ErsatzTV/wwwroot/openapi/v1.json via update-openapi.sh.
- GET /api/playouts — paged list over GetPagedPlayouts (query/pageNum/pageSize), list DTO with buildStatus (#100, #107)
- GET /api/playouts/{id}/items — paged future items + UNSCHEDULED gaps over GetFuturePlayoutItemsById; 404 ProblemDetails for unknown playout (matches /api/schedules/{id}/items precedent) (#101)
- buildStatus {lastBuild, success, message} on GET /api/playouts/{id} (BuildStatus now included by GetPlayoutByIdHandler) (#107)
- GET /api/playouts/warnings/count — failed-build count for the warnings badge (#107)
- POST /api/playouts/reset-all — 202 Accepted, wraps ResetAllPlayouts (#110)
- POST /api/channels/{channelNumber}/playout/reset — optional ?mode= override; default branches by ScheduleKind (Classic→Refresh, others→Reset) to match Blazor semantics (#110)
- Option<FillerKind>→FillerKind? projection uses MatchUnsafe (Match throws on null-returning branch; idiom per Health/Mapper.cs)
- Tests: controller unit tests (routes, projections, 404s, reset-mode defaults), OpenAPI ProblemDetails contract entry for /api/playouts/{id}/items get 404
- Regenerated wwwroot/openapi/v1.json
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>