Schedule-item recurrence fields: an omitted array means "never applies" on write but "unrestricted" on read #880
Closed
opened 2026-08-29 20:21:30 +02:00 by timothy
·
2 comments
No Branch/Tag Specified
main
renovate/meziantou.analyzer-3.x
release/v26.15.0-notes
fix/830-add-items-error-surface
renovate/lucene.net
renovate/cliwrap-3.x
issue-806-guard-populations
renovate/dotnet-monorepo
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
docs/510-skill-logo-bug-policy
fix/510-watermark-resolution-policy
fix/629-verdict-classifier-falseopens
fix/609-decisions-edit-token-scope
issue-135-clear-to-none
release/v26.12.0-notes
fix/409b-lastscan-api-parity
fix/401-updatechannel-mirror-422
fix/327-playlist-rename-validation
fix/410-scancancel-log-level
fix/409-447-librariesscreen-neverscanned
fix/338-zap-exit-code
fix/367-plex-budget-message
fix/310-debom-legacy-cs
ci/604-lane-rebalance
feat/388-design-mirror
feat/247-test-ownership
feat/247-primary-action
feat/357-player-owned-playback
feat/357-jellyfin-plugin-poc
fix/289-mcp-hardening
issue58-mcp
feat/244-channels-extract
ci/auto-bump-prod-compose
feat/multi-rerun-collections-api
feat/collections-api
feat/quick-wins
feat/185-docs-part2
feat/140-collections-screen
feat/146-channel-edit
feat/147-classic-ui-link
issue22-renovate-dashboard
feat/91-cutover
feat/63-composite-create
feat/65-library-browse
feat/85-epg
feat/86-schedule-editor
feat/109-dashboard-data
feat/99-session-tracking
fix/dockerfile-node-tag
feat/59-spa-foundation
docs/59-ui-redesign-brief
feat/102-json-guide
feat/111-schedule-durations
feat/104-artwork-upload
feat/103-media-sources-api
feat/playouts-read-api
feat/108-health-api
feat/105-picker-list-endpoints
issue-97-channel-state-api
issue42-jellyfin-musicvideos
issue46-rest-api-error-contract
dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
qsv-improvements
hdr-vulkan-cuda-test
v26.15.0
v26.14.0
v26.13.0
v26.12.0
v26.11.0
v26.10.0
v26.9.0
v26.8.0
v26.7.0
blazor-final
v26.6.0
v26.5.0
v26.4.0
v26.3.1
v26.3.0
v26.2.0
v26.1.1
v26.1.0
v25.9.0
v25.8.0
v25.7.1
v25.7.0
v25.6.0
v25.5.0
v25.4.0
v25.3.1
v25.3.0
v25.2.0
v25.1.0
v0.8.8-beta
v0.8.7-beta
v0.8.6-beta
v0.8.5-beta
v0.8.4-beta
v0.8.3-beta
v0.8.2-beta
v0.8.1-beta
v0.8.0-beta
v0.7.9-beta
v0.7.8-beta
v0.7.7-beta
v0.7.6-beta
v0.7.5-beta
v0.7.4-beta
v0.7.3-beta
v0.7.2-beta
v0.7.1-beta
v0.7.0-beta
v0.6.9-beta
v0.6.8-beta
v0.6.7-beta
v0.6.6-beta
v0.6.5-beta
v0.6.4-beta
v0.6.3-beta
v0.6.2-beta
v0.6.1-beta
v0.6.0-beta
v0.5.8-beta
v0.5.7-beta
v0.5.6-beta
v0.5.5-beta
v0.5.4-beta
v0.5.3-beta
v0.5.2-beta
v0.5.1-beta
v0.5.0-beta
v0.4.5-alpha
v0.4.4-alpha
v0.4.3-alpha
v0.4.2-alpha
v0.4.1-alpha
v0.4.0-alpha
v0.3.8-alpha
v0.3.7-alpha
develop
v0.3.6-alpha
v0.3.5-alpha
v0.3.4-alpha
v0.3.3-alpha
v0.3.2-alpha
v0.3.1-alpha
v0.3.0-alpha
v0.2.5-alpha
v0.2.4-alpha
v0.2.3-alpha
v0.2.2-alpha
v0.2.1-alpha
v0.2.0-alpha
v0.1.5-alpha
v0.1.4-alpha
v0.1.3-alpha
v0.1.2-alpha
v0.1.1-alpha
v0.1.0-alpha
v0.0.62-alpha
v0.0.61-alpha
v0.0.60-alpha
v0.0.59-alpha
v0.0.58-alpha
v0.0.57-alpha
v0.0.56-alpha
v0.0.55-alpha
v0.0.54-alpha
v0.0.53-alpha
v0.0.52-alpha
v0.0.51-alpha
v0.0.50-alpha
v0.0.49-prealpha
v0.0.48-prealpha
v0.0.47-prealpha
v0.0.46-prealpha
v0.0.45-prealpha
v0.0.44-prealpha
v0.0.43-prealpha
v0.0.42-prealpha
v0.0.41-prealpha
v0.0.40-prealpha
v0.0.39-prealpha
v0.0.38-prealpha
v0.0.37-prealpha
v0.0.36-prealpha
v0.0.35-prealpha
v0.0.34-prealpha
v0.0.33-prealpha
v0.0.32-prealpha
v0.0.31-prealpha
v0.0.30-prealpha
v0.0.29-prealpha
v0.0.28-prealpha
v0.0.27-prealpha
v0.0.26-prealpha
v0.0.25-prealpha
v0.0.24-prealpha
v0.0.23-prealpha
v0.0.22-prealpha
v0.0.21-prealpha
v0.0.20-prealpha
v0.0.19-prealpha
v0.0.18-prealpha
v0.0.17-prealpha
v0.0.16-prealpha
v0.0.15-prealpha
v0.0.14-prealpha
v0.0.13-prealpha
v0.0.12-prealpha
v0.0.11-prealpha
v0.0.10-prealpha
v0.0.9-prealpha
v0.0.8-prealpha
v0.0.7-prealpha
v0.0.6-prealpha
v0.0.5-prealpha
v0.0.4-prealpha
v0.0.3-prealpha
v0.0.2-prealpha
v0.0.1-prealpha
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
No labels
priority: medium
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: timothy/ersatztv#880
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Surfaced by the independent reviews of #823 (PR #879). Pre-existing, and deliberately NOT fixed there — #823 decided what a legacy database NULL means on the READ side; this is the WRITE side of the same field, which is a different question.
The asymmetry
After #823, the two halves of "this field is absent" point in opposite directions:
AlternateScheduleSelector/ the twoMapper.ProjectToViewModeloverloadsAll*()sets)daysOfWeek/daysOfMonth/monthsOfYearin a PUT, normalized byPlayoutAlternateScheduleItemRequest.ToReplaceItem/PlayoutTemplateItemRequest.ToReplaceItem[], i.e. matches no dayNeither
ReplacePlayoutAlternateScheduleItemsHandlernorReplacePlayoutTemplateItemsHandlervalidates emptiness — onlyItems.Count == 0is rejected.The concrete outcome
An MCP or curl client PUTs an alternate-schedule item omitting
daysOfWeek. It gets HTTP 200. The row is stored with an empty set, and therefore silently never applies — the playout falls through to the default schedule and nothing anywhere says why. The SPA does not hit this (it always sends the full arrays it received), so this is an API-client-only trap.Why it was left out of #823
#823's scope is the read path and the meaning of a legacy NULL. The reviewers were split on whether this belongs there, and the deciding argument is that a client omitting a field on a write says nothing about what a legacy NULL meant — so folding it in would have widened that PR past its issue and mixed two different judgements. It is recorded as residual (3) on
media.nullable-primitive-collection-mutation.What to decide
api.ffmpeg-profile-numeric-bounds' shape (reject out of range rather than accept-then-rewrite). Most honest; a breaking change for any client currently sending[]deliberately.All*()so absence means the same thing on both sides, while an explicitly-sent[]keeps meaning "no days". Requires distinguishing absent from empty in the request record —List<T>?plus?? All*()rather than?? [].docs/api-conventions.md.Option 2 looks right and is the one that actually removes the asymmetry, but it needs a check of how the generated SPA client and the MCP server serialize an absent vs empty array before committing to it.
Done-when
media.nullable-primitive-collection-mutationresidual (3) updated to point at the outcomeClaiming #880 (Claude Code / Opus 5 session, 2026-08-30).
Context for the other sessions live right now: I first claimed #887, found it was a three-way collision, and yielded it there. Picking this one specifically because it is disjoint from the CI/Dockerfile/workflow surface those sessions are editing — this is
ErsatzTV.Applicationwrite-path + a test, no.gitea/, nodocker/, noscripts/.Starting from the issue's option 2 (normalize an OMITTED field to
All*(), keep an explicit[]meaning "no days"), but the issue is explicit that it needs a check of how the generated SPA client and the MCP server serialize absent-vs-empty before committing — doing that check first and will report what it says.Closing record
Outcome: Shipped in PR #892 (squash-merged to
main). An absent recurrence array (daysOfWeek/daysOfMonth/monthsOfYear) on both playout replace-list write paths now normalizes toAlternateScheduleSelector.All*()— unrestricted — instead of[]; an explicitly empty array is rejected with a 422 naming the consequence, via a new sharedRecurrenceSetBounds(ErsatzTV.Application/Scheduling) called from both replace handlers. Follow-up #894 filed for the SPA half.Root cause: The three recurrence sets are read conjunctively by
AlternateScheduleSelector.GetScheduleForDate— a miss on any onecontinues — so the empty set is the maximally restrictive value, not a neutral one.?? []in the request records read as "default to no filter" and meant "default to matching nothing": the API returned HTTP 200 and stored an item that could never apply on any date, with nothing logged. #823 had already decided the READ side the other way (a NULL column reads asAll*()), so the two halves of "this field is absent" pointed in opposite directions.Decisions/conventions changed:
api.absent-collection-means-unrestricted(docs/decisions/records/api/).media.nullable-primitive-collection-mutation— residual (3) now points at the outcome instead of forward-referencing this issue.docs/api-conventions.md§3e.Reusable knowledge:
[]too makes "no days" inexpressible; rejecting absence breaks every client that omits an optional field.List<T>?left all three inrequiredinv1.json(type["null","array"]). An error message saying "omit the property" would instruct a schema violation — it says "send null". A byte-identicalupdate-openapi.shdiff is therefore not evidence a change shipped.ValidateDateRangesvalidates every item uniformly" was used to justify validating the catch-all, whose recurrence the handler discards — producing a 422 whose stated reason is false for that item..ThenInclude(t => t.Template)navigation is required, so aPlayoutTemplateseeded without itsTemplaterow is joined out ofexisting— the stored row goes invisible and an exemption test fails claiming the rule is broken when the fixture is.scripts/tests/test_mutation_harness.pybuilds its sandbox from git, so a new decision record written but notgit added makes the regenerated catalog look stale — the red namesdecisions_validate.pyand the real cause is the untracked file.Verification:
ErsatzTV.Testsfull suite: 2099 passed / 0 failed (6 skipped).?? All*()reddens exactly the 3 normalization tests; neuteringIsNewlyEmptyreddens exactly the 3 rejection tests; dropping only the stored comparison reddens the 2 exemption tests. Rejection tests also assert the version did not bump (anti-vacuity).nulldirectly; an explicit[]on a stored item → 422 verbatim;[]on the catch-all → 200 with the sibling's explicit['Friday']preserved./config/ersatztv.sqlite3):ProgramScheduleAlternateandPlayoutTemplateboth hold 0 rows. The exemption is justified by the shape of a whole-list replace, not by that count.3500bf3: all 14 checks success (image build skipped — PR path).Deferred: #894 — the SPA (
PlayoutScheduleEditors.tsx) still lets a user deselect every day and build the empty state the server now rejects, so the feedback arrives at save time rather than at the field. Labelledpriority: low/frontend.Docs updated:
docs/api-conventions.md(§3e, new),docs/decisions/records/api/absent-collection-means-unrestricted.md(new),docs/decisions/records/media/nullable-primitive-collection-mutation.md(residual 3),docs/decisions/README.md(regenerated).