SPA lets a user build an empty recurrence set the server now rejects (#880 residual) #894

Open
opened 2026-08-30 10:59:44 +02:00 by timothy · 0 comments
Owner

Split out of #880 (PR #892), which is the write-path half. Filed as a known residual, not a defect in that PR — the server contract is now correct and this is the client mirroring it.

The gap

Since #892 an explicitly empty recurrence array is rejected with a 422 naming the consequence (api.absent-collection-means-unrestricted). The SPA can still build that state: web/src/screens/PlayoutScheduleEditors.tsx toggles days individually (onToggle={(day) => onChange({ daysOfWeek: toggle(draft.daysOfWeek, day) })}) with no floor at one selection, and the "Weekdays"/"Weekend" shortcuts do not prevent unchecking everything afterwards.

So a user can uncheck every day, see nothing wrong in the form, and only learn at save time — and because both PUT paths are whole-list replaces, that save carries every other item in the playout with it.

Before #892 the same action silently produced a row that never applied, so this is strictly an improvement; the residual is that the feedback arrives late and at list granularity rather than beside the field.

Why this is the documented shape, not an oversight

docs/api-conventions.md §3d already names this obligation in the other direction — "mirror the exemption in the client, or the form blocks a save the server would have accepted". Here the asymmetry runs the opposite way: the form allows a save the server will refuse. Both are the same underlying rule (the client restates a server bound and nothing pins the two together), and §3d records that drift as a known residual rather than something a test catches.

What to decide

  1. Disable the last toggle so an empty set is unreachable in the form — simplest, but silently prevents an action rather than explaining it.
  2. Inline validation on the field, mirroring the server's message and its consequence wording — matches §3d's "mirror the value AND the wording", and keeps the reason visible.
  3. Leave it and rely on the 422 surfacing in the existing error path.

Option 2 looks right. Note §3d's warning that the SPA restating a server bound as a literal is itself the drift risk — whatever is chosen, the wording should be recognisably the server's.

Done-when

  • A user cannot reach save with an empty recurrence set without being told why, at the field
  • The client's wording is recognisably the server's (RecurrenceSetBounds), not an independent restatement
  • The behaviour is covered by a web test that fails if the guard is removed
  • Adversarial review passed
Split out of #880 (PR #892), which is the write-path half. Filed as a **known residual, not a defect in that PR** — the server contract is now correct and this is the client mirroring it. ## The gap Since #892 an explicitly empty recurrence array is rejected with a 422 naming the consequence (`api.absent-collection-means-unrestricted`). The SPA can still **build** that state: `web/src/screens/PlayoutScheduleEditors.tsx` toggles days individually (`onToggle={(day) => onChange({ daysOfWeek: toggle(draft.daysOfWeek, day) })}`) with no floor at one selection, and the "Weekdays"/"Weekend" shortcuts do not prevent unchecking everything afterwards. So a user can uncheck every day, see nothing wrong in the form, and only learn at save time — and because both PUT paths are **whole-list replaces**, that save carries every other item in the playout with it. Before #892 the same action silently produced a row that never applied, so this is strictly an improvement; the residual is that the feedback arrives late and at list granularity rather than beside the field. ## Why this is the documented shape, not an oversight `docs/api-conventions.md` §3d already names this obligation in the other direction — "mirror the exemption in the client, or the form blocks a save the server would have accepted". Here the asymmetry runs the opposite way: the form **allows** a save the server will refuse. Both are the same underlying rule (the client restates a server bound and nothing pins the two together), and §3d records that drift as a known residual rather than something a test catches. ## What to decide 1. **Disable the last toggle** so an empty set is unreachable in the form — simplest, but silently prevents an action rather than explaining it. 2. **Inline validation** on the field, mirroring the server's message and its consequence wording — matches §3d's "mirror the value AND the wording", and keeps the reason visible. 3. **Leave it** and rely on the 422 surfacing in the existing error path. Option 2 looks right. Note §3d's warning that the SPA restating a server bound as a literal is itself the drift risk — whatever is chosen, the wording should be recognisably the server's. ## Done-when - [ ] A user cannot reach save with an empty recurrence set without being told why, at the field - [ ] The client's wording is recognisably the server's (`RecurrenceSetBounds`), not an independent restatement - [ ] The behaviour is covered by a web test that fails if the guard is removed - [ ] Adversarial review passed
timothy added the frontendpriority: low labels 2026-08-30 10:59:53 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: timothy/ersatztv#894