# Schedule API & Data Contract Status: Phase 0 (contract only — no application code). This document is the authoritative API/data contract for the multi-phase scheduler overhaul. It is kept **byte-identical** in both repositories: - `radiantwave` (C++/SDL3 Kiosk app) — `docs/schedule-api-contract.md` - `portal` (SvelteKit/Node) — `docs/schedule-api-contract.md` Any change is applied to both copies in the same step, with a dated line appended to the [Changelog](#changelog). Glossary terms (Kiosk, Portal, Proclamation, Category, Group, Block, Schedule, License Key, System Name) are used with the meanings defined in the scheduler overhaul brief. --- ## Current state This section reflects the code as it exists at the start of Phase 0. It is descriptive, not aspirational; the target behaviour is defined in the later sections. ### Kiosk (`radiantwave`) - **License validation.** `POST /api/v2/license` with body `{"license_key": "...", "email_address": "..."}`. Implemented in `src/network/api_client.cpp` (`validateLicense`, `parseLicenseJson`) and consumed by `src/network/license_service.cpp` (`validateStoredLicense`, `markLicenseVerified`, `isLicenseWithinGracePeriod`). A 2xx response is the device's "subscribed" flag; a 7-day offline grace window is stored as `license_last_verified` in `system_kv` (`src/db/db_keys.h`). Response body currently read: `{key, email_address}`; additional fields are ignored. - **Schedule fetch.** `GET /api/v2/schedule?email=&licenseKey=` (`src/network/api_client.cpp`: `fetchSchedule`, `parseScheduleJson`; `PATH_SCHEDULE = "/schedule"`). Response shape read by the Kiosk: `{email, blocks: [{id, startMinutes, endMinutes, overnight, durationMinutes, dynamicAudio, proclamationIds}]}`. `dynamicAudio` is optional on the wire and defaults to `true` in `parseScheduleJson`. Missing/blank credentials cause an early `false`. - **Schedule persistence.** SQLite tables `schedule_blocks` and `schedule_block_proclamations` (`src/db/database_manager.cpp:185`, `replaceScheduleBlocks` at `:1105`, `getScheduleBlocks` at `:1169`). Full replace; block `sort_order` and per-block proclamation `position` preserved. No day-of-week column and no schedule `type`; the model is implicitly daily. - **Schedule sync.** `syncScheduleFromApi` (`src/network/proclamation_sync.cpp:172`) is invoked only as a **best-effort** side effect of `syncProclamationsFromApi` (`:168`). There is no boot sync, no network-up sync, no periodic timer, and no conditional GET (`If-None-Match`/ETag). `syncProclamationsFromApi` is called from `Home::syncProclamationsIfEmpty` (`src/home.cpp:461`) and `ProclamationOptionsPage::onPush` (`src/proclamation_options_page.cpp:49`). There is no explicit atomic schedule cache file; the SQLite database is the durable store. - **Schedule playback.** `src/scroller_page.cpp` selects the active block by comparing `timezone::currentLocalMinutes(cfg.timezone)` (`src/timezone_helper.h`) against each block via `scroller::minuteInScheduleBlock` (`src/scroller_streams.h:31`). Block selection is daily-only (no day-of-week). An active block's ordered `proclamation_ids` replace the local selection (`src/scroller_streams.cpp:57`). `effectiveDynamicAudio()` (`src/scroller_page.cpp:302`) forces dynamic audio when the active block has `dynamic_audio == true`, otherwise the device-wide `config.audio.dynamic_audio` applies. A block change is latched in `scheduleChangePending_` and applied at the next proclamation boundary. - **Settings persistence.** `~/.local/radiantwave/config.yaml` (YAML) via `ConfigManager` (`src/config/config_manager.cpp`, `src/config/yaml_parser.cpp`, schema in `src/config/app_config.h`): `timezone`, `audio.dynamic_audio`, `proclamation.schedule_active` (Kiosk-local on/off toggle, default `false`). Identity lives in SQLite `system_kv` (`email_address`, `license_key`, `license_last_verified`). There is no System Name anywhere. - **Conventions.** `API_BASE_URL = "https://portal.radiantwavetech.com/api/v2"` (`src/network/api_client.h`). Kiosk API knowledge is mirrored in `openapi.yaml` at the repo root. ### Portal (`portal`) - **Framework/tooling.** SvelteKit 2 + Svelte 5 + TypeScript + Tailwind v4; Prisma 7 over PostgreSQL; Vitest. Migrations: Prisma Migrate (`prisma/schema.prisma`, `prisma/migrations/`, `npm run migrate`, `npm run deploy`; config in `prisma.config.ts`). API routes under `src/routes/api/v1/` (API-key gated) and `src/routes/api/v2/` (unauthenticated). v2 helpers: `src/lib/server/v2-helpers.ts` (`jsonOk`, `jsonError`, `readJsonBody`, `rateLimited`, `clientIp`); errors are `{ "message": "" }`. - **License validation.** `POST /api/v2/license` (`src/routes/api/v2/license/+server.ts`): validates `license_key` + `email_address`, requires the key be active and owned by the account, stamps `LicenseKey.lastValidatedAt`, returns `serializeLicense({key, emailAddress})` (`src/lib/server/v2-serializers.ts`). A v1 variant exists (`src/routes/api/v1/license/+server.ts`). - **Schedule storage.** `Schedule` is **per user** (`userId String @unique`, `prisma/schema.prisma:263`), daily-only, `0..4` `Block`s. `Block` (`:275`) stores `startMinutes`/`endMinutes`, `dynamicAudio Boolean @default(false)`, and ordered `BlockProclamations` (`sortOrder`). The schema comment states overnight ranges are disallowed in v1, yet the serializer and Kiosk both support `overnight` — a known inconsistency. - **Schedule service/validation.** `src/lib/server/schedule.ts` (DB orchestration: `getOrCreateSchedule`, `listBlocks`, `createBlock`, `updateBlock`, `deleteBlock`) and the isomorphic `src/lib/schedule-utils.ts` (`MAX_BLOCKS = 4`, `SNAP_MINUTES = 15`, `validateBlock`, `findOverlap`, `isOvernight`, `blockDuration`, `blockSegments`, `formatTime`, `toHm`, `snapMinutes`). Validation enforces a 4-block maximum on create, `startMinutes !== endMinutes`, at least one Proclamation, and no overlaps (overnight-aware via linear segments). - **Schedule read API.** `GET /api/v2/schedule` (`src/routes/api/v2/schedule/+server.ts`): auth by paired `email` + `licenseKey` (legacy alias `key`) query params; checks only that the key belongs to the email's account (entitlement deliberately not checked); rate limited 10/min/IP; returns `{email, blocks: [serializeScheduleBlock(...)]}` where `serializeScheduleBlock` (`src/lib/server/v2-serializers.ts:165`) emits `{id, startTime, startMinutes, endTime, endMinutes, overnight, durationMinutes, dynamicAudio, proclamationIds}`. - **Schedule UI.** Protected user route `src/routes/(protected)/(user)/schedule/` (`+page.server.ts`, `+page.svelte`) with `src/lib/components/schedule/DaySchedule.svelte` (single 24h day timeline, drag-to-create) and `BlockModal.svelte`. Dashboard (`(protected)/(user)/dashboard/`) lists the user's License Keys. No System Name is displayed or editable. ### Gaps between current state and target - Schedule is per-user, not per-License-Key. - No `daily`/`weekly` typing; no day-of-week storage or UI. - No `version`/ETag, no conditional GET. - Schedule GET requires `email` + `licenseKey`; there is no license-key-only route, no `/api/v2/licenses/...` namespace, and no system-name route. - `dynamicAudio` DB default is `false`; the target default for new Blocks is `true`. - No System Name field, validation, or display. - Sync is opportunistic with no boot/network-up/5-minute cadence and no atomic cache file. - Portal stores no timezone (matches target) and shows no informational geoip-derived timezone. --- ## Time semantics - Times are **minute precision**, stored as 24-hour `"HH:MM"` strings representing **local wall-clock** time. AM/PM is display-only. - Numerators may also be carried as integer minutes-from-midnight (`startMinutes`/`endMinutes`, `0..1440`) for the Kiosk; the string form is canonical on the wire. - A Block is the **half-open interval `[start, end)`**. - A Block may start on the same minute the previous Block ends (`end 07:00`, next `start 07:00` is valid and has no gap). - A Block whose `end` is earlier than its `start` **crosses midnight** and belongs to its start day. In weekly mode, Sunday's crossing Block spills into Monday. `start == end` is **invalid** (zero- or full-length ambiguity). - **Overlaps are invalid**, including spillover from a midnight-crossing Block into the next day's Blocks (or, in daily mode, into the same list's early Blocks). Touching boundaries are **not** overlaps. - Gaps between Blocks are allowed. During a gap the Kiosk plays whatever Proclamations are selected locally on the Kiosk. - The Kiosk evaluates Schedules in its own Timezone setting (`config.timezone`, IANA code). **The payload carries no timezone.** The Portal may show the user's geoip-derived timezone informationally only. --- ## Schedule data model (JSON) Each License Key has **at most one** Schedule. `type` is `"daily"` or `"weekly"` and determines the mode; there is no separate global mode. **No** Schedule means `type` is `null`. Suggested shape from the brief, adapted to existing field names (`start`/`end` → `startTime`/`endTime`, retaining the existing `startMinutes`/`endMinutes` integers for the Kiosk): ```json { "version": "", "type": "daily" | "weekly" | null, "daily": [ Block, ... ], "weekly": { "mon": [Block], "tue": [Block], "wed": [Block], "thu": [Block], "fri": [Block], "sat": [Block], "sun": [Block] }, "blocks": [ LegacyBlock, ... ] } ``` Block: ```json { "id": "3f1c2b7a-9d4e-4c8a-b2f6-1a2b3c4d5e6f", "startTime": "21:00", "startMinutes": 1260, "endTime": "09:00", "endMinutes": 540, "overnight": true, "durationMinutes": 720, "proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"], "dynamicAudio": true } ``` - `version` is an opaque string derived from the Schedule content (see [Endpoints](#endpoints)). It is implementation-defined and must change whenever the serialized schedule changes. - **Only the fields for the active type are populated.** For `type: "daily"`, `daily` is populated and `weekly` is omitted or empty. For `type: "weekly"`, `weekly` is populated with all seven keys and `daily` is omitted or empty. For `type: null`, both are empty/omitted. - `weekly` always contains all seven lowercase keys `mon`..`sun`; a day with no Blocks is an empty array. - `blocks` is the **legacy flattening** retained for older Kiosks (see [Backward compatibility](#backward-compatibility)). It uses the existing `LegacyBlock` shape serialized by the current portal (`{id, startTime, startMinutes, endTime, endMinutes, overnight, durationMinutes, dynamicAudio, proclamationIds}`). For `type: "weekly"`, `blocks` is an **empty array** (the server does not know the Kiosk's timezone and cannot flatten weekly to a single day). - `proclamationIds` is the ordered play list (order is significant); duplicates are not meaningful and are de-duplicated by the Portal. - `dynamicAudio` defaults to `true` for new Blocks created on the Portal. - Proclamation **content** is never included; the Kiosk fetches content separately via `/api/v2/proclamations`. Portal users only ever see title/category/group. --- ## Validation rules These are enforced by the Portal (authoritative) and defended against by the Kiosk. 1. A Schedule belongs to exactly one License Key; a License Key has at most one Schedule. 2. `type` is one of `"daily"`, `"weekly"`, or `null`. 3. A Block has `start` and `end` times; `start == end` is invalid. 4. `end < start` denotes a midnight-crossing Block belonging to its start day. 5. Blocks within a day (and the spillover of a midnight-crossing Block into the following day) must not overlap. Touching boundaries are allowed. 6. Weekly modes apply rules 3–5 independently per day; a `sun` Block crossing midnight must not overlap `mon` early Blocks. 7. A Block must contain at least one Proclamation. Empty Blocks are rejected server-side. The Kiosk defensively **skips invalid Blocks** and treats them as gaps. 8. Gaps are allowed; during a gap local Kiosk selection applies. 9. `dynamicAudio` is a boolean; absent means `true` for new Blocks. 10. Times are minute-precision `HH:MM` strings; the Portal snaps UI input to `SNAP_MINUTES` (15) with a `MINUTE_STEP` (5) constraint from the existing helpers. 11. The existing Portal cap of `MAX_BLOCKS = 4` per day list is **retained** for Phase 0 (see [Open items](#open-items)). The Kiosk must not crash on, and must not play, an invalid Block: it skips it. --- ## Endpoints The API remains **unauthenticated** (no API keys) for v2. This is a known limitation and is recorded under [Open items](#open-items). All new endpoints live under the existing versioned namespace `/api/v2/...` to preserve conventions. v1 routes are unchanged. ### (a) License validation — existing, additive `POST /api/v2/license` Request (existing fields unchanged; `systemName` additive and optional): ```json { "license_key": "ABCD-EFGH-IJKL-MNOP", "email_address": "jane@example.com", "systemName": "Living Room" } ``` Response (existing fields unchanged; `systemNameStatus` additive): ```json { "key": "ABCD-EFGH-IJKL-MNOP", "email_address": "jane@example.com", "systemNameStatus": "ok" } ``` - `systemName` is optional. When present it is trimmed; when absent the stored name is left untouched. - `systemNameStatus` ∈ `{"ok", "unchanged", "conflict", "invalid"}`: - `ok` — name was set or updated. - `unchanged` — omitted, or identical after trim/case-insensitive compare. - `conflict` — another License Key on the same account already holds the name. - `invalid` — trimmed length outside 1–40. - **Validation itself succeeds regardless of name status.** A non-`ok` status never fails the license check; the Kiosk treats it as advisory. - Errors and rate limiting follow the existing endpoint (400 messages, 413 body cap, 429 with `Retry-After`). ### (b) Set System Name — Kiosk-facing `PUT /api/v2/licenses/{licenseKey}/system-name` Request: ```json { "systemName": "Living Room" } ``` Responses: | Status | Condition | Body | | --------------------- | ----------------------------------------- | ------------------------------------------------- | | 200 | Name set/updated (or unchanged) | `{"systemName": "...", "systemNameStatus": "ok"}` | | 400 `invalid_name` | Trimmed length outside 1–40 | `{"message": "invalid_name"}` | | 404 `unknown_license` | No License Key matches | `{"message": "unknown_license"}` | | 409 `name_taken` | Another key on the account holds the name | `{"message": "name_taken"}` | - The Kiosk is the **only** writer. The Portal has no rename capability. - The response body uses the existing v2 error convention (`{"message": ""}`). - Rate limited at the existing 10 requests/min/IP convention. ### (c) Get Schedule — Kiosk-facing, key-only, conditional `GET /api/v2/licenses/{licenseKey}/schedule` - 200 with `ETag: ""` and the payload from [Schedule data model](#schedule-data-model-json). A License Key with no Schedule returns 200 with `type: null` and empty lists. - `304 Not Modified` when the request's `If-None-Match` matches the current `ETag`. - `404` (`unknown_license`) when no License Key matches. - `version` doubles as the ETag value; the header value is the quoted form. - The response is the **target** payload (`version`, `type`, `daily`, `weekly`, `blocks`). - Rate limited at the existing 10 requests/min/IP convention. ### (d) Legacy schedule read — existing, evolved additively `GET /api/v2/schedule?email=&licenseKey=` remains supported with its existing auth semantics. Its response is extended **additively** with the same `version`, `type`, `daily`, and `weekly` fields, while continuing to populate `email` and `blocks` exactly as before. Old Kiosks ignore the new fields; new Kiosks may read either endpoint. This endpoint does **not** emit an ETag and does **not** return 304. ### (e) Portal-internal endpoints (not Kiosk-facing) These are session-authenticated Portal actions; they are **not** part of the Kiosk API and are not served by `/api/v2/...`. - Schedule CRUD — implemented in Phase 3 as SvelteKit form actions on `(protected)/(user)/schedule` (`?/setType`, `?/createBlock`, `?/updateBlock`, `?/deleteBlock`), backed by `$lib/server/schedule.ts`. - **Copy** — implemented in Phase 4 as the `?/copy` action plus `copySchedule()` in `$lib/server/schedule.ts`. Copies one License Key's Schedule to one or more other License Keys **by value, in a single database transaction** (all targets succeed or none). The target `type` becomes the source's; Block rows get fresh IDs; each block's `dynamicAudio` and ordered Proclamation references are preserved. The source and every target must belong to the caller's account; a target equal to the source, an empty target list, or a source without a schedule are rejected. The resulting schedule is run through the shared validator before commit. Because Block IDs are part of the serialized payload, every target's content-derived `version`/ETag changes, so its Kiosk refetches. --- ## Sync behavior Target Kiosk sync (defaults; adjust only if the code forces it): - The Kiosk pulls the Schedule: - on boot, - on network-up, - every 5 minutes. - Pulls use conditional GET (`If-None-Match` / ETag); a `304` leaves the cache untouched. - The last Schedule is cached to disk with an **atomic write** and used offline. - The schedule on/off toggle is **Kiosk-local** (existing `proclamation.schedule_active`) and is not synced. - With no cache and no network, the Kiosk behaves as if schedules are off (local Proclamation selection applies). - During gaps, or when schedules are off, the local audio selection applies. Current-state deltas (to be closed by later phases): no boot/network-up/5-minute cadence, no ETag, no explicit atomic cache file. These are recorded under [Touchpoints per phase](#touchpoints-per-phase). Identity for sync is the License Key. The legacy schedule endpoint additionally requires the paired email; the new endpoint uses the License Key alone. --- ## System Name rules - A System Name is a human-readable label for a License Key (e.g. "Living Room"). It is a **label only** — never used as a reference or key. - Unique **per account**, compared **case-insensitively**. - Trimmed, **1–40 characters**. - Set or changed **only via the Kiosk**. The Portal displays it **read-only** and has no rename capability. - The Portal falls back to displaying the License Key when no name is set. - A Kiosk that omits `systemName` never clears an existing name. - Wire representations: request field `systemName`; status field `systemNameStatus` ∈ `{"ok","unchanged","conflict","invalid"}` (see [Endpoints](#endpoints)). --- ## Dynamic audio - Each Block has a boolean `dynamicAudio`. - The Portal defaults it to **`true` for new Blocks**. - `true` ⇒ the Kiosk uses dynamic audio for that Block. - `false` ⇒ the Kiosk uses its locally chosen audio. - During gaps, or when schedules are off, the local audio selection applies (existing `config.audio.dynamic_audio` / fixed track). - Kiosk parsing defaults an absent `dynamicAudio` to `true`. --- ## Backward compatibility All existing endpoints and payloads keep working for Kiosks already in the field. New fields are **additive**; no v2 field is removed or renamed, and v1 is untouched. - **Old Kiosk vs new Portal.** An old Kiosk calls `POST /api/v2/license` (no `systemName`) and `GET /api/v2/schedule?email&licenseKey`, reading `blocks` and ignoring unknown fields. The Portal keeps both, keeps the `blocks` shape byte- compatible, and adds `version`/`type`/`daily`/`weekly` as extra fields. The old Kiosk is unaffected and ignores `systemNameStatus`. - **Daily schedules.** For `type: "daily"`, `blocks` is the flattened daily list; an old Kiosk behaves exactly as today. - **Weekly schedules.** `blocks` is an **empty array**. An old Kiosk therefore sees no schedule and falls back to local Proclamation/audio selection. It cannot interpret weekly schedules; this is accepted and recorded under [Open items](#open-items). - **No schedule.** `type: null`, `blocks: []`; both old and new Kiosks fall back to local selection. - **Omission of `systemName`** by an old Kiosk never clears an existing name; `systemNameStatus` is simply ignored. - The new endpoints are additive; their absence on old Kiosks is irrelevant. --- ## Touchpoints per phase File/module candidates each later phase will change. Phase numbering is assumed from the brief (Phase 3 = Portal schedule CRUD, Phase 4 = Portal schedule copy) and is recorded under [Open items](#open-items). ### Kiosk (`radiantwave`) - **Phase 1 (Kiosk scheduler UI, storage, sync, playback)** - `src/network/api_client.h` / `src/network/api_client.cpp` — parse `version`/`type`/`daily`/`weekly`, ETag/`If-None-Match`, `PUT` system-name, `systemNameStatus`. - `src/network/proclamation_sync.h` / `.cpp` — boot/network-up/5-minute schedule sync, conditional GET, atomic cache. - `src/network/license_service.h` / `.cpp` — send `systemName`, handle `systemNameStatus`. - `src/db/db_keys.h` — System Name / ETag keys; `src/db/database_manager.h` / `.cpp` — schedule `type`/day-of-week storage, cache. - `src/scroller_page.h` / `.cpp`, `src/scroller_streams.h` / `.cpp` — day-of-week Block selection, spillover. - `src/timezone_helper.h` — local day-of-week. - `src/config/app_config.h`, `src/config/config_manager.cpp`, `src/config/yaml_parser.cpp` — settings surface. - New page(s) for setting the System Name; tests in `tests/` registered in `CMakeLists.txt`. ### Portal (`portal`) - **Phase 2 (System Name + license validation)** - `prisma/schema.prisma` + migration — `LicenseKey.systemName` and case-insensitive uniqueness. - `src/routes/api/v2/license/+server.ts` — `systemName` / `systemNameStatus`. - `src/routes/api/v2/licenses/[licenseKey]/system-name/+server.ts` — new. - `src/lib/server/v2-serializers.ts`, `src/lib/server/v2-helpers.ts`. - Dashboard read-only display. - **Phase 3 (Schedule CRUD: daily/weekly, per-License-Key)** - `prisma/schema.prisma` + migration — Schedule per License Key, `type`, day-of-week. - `src/lib/server/schedule.ts`, `src/lib/schedule-utils.ts` — weekly, per-license orchestration, cross-day validation. - `src/lib/server/v2-serializers.ts` — `version`/`type`/`daily`/`weekly`. - `src/routes/api/v2/licenses/[licenseKey]/schedule/+server.ts` — new (ETag/304). - `src/routes/api/v2/schedule/+server.ts` — additive evolution. - `src/routes/(protected)/(user)/schedule/*`, `src/lib/components/schedule/*` — weekly UI, per-license selection. - Specs: `schedule.spec.ts`, `page.server.spec.ts`, `schedule-utils.spec.ts`. - **Phase 4 (Schedule copy)** - Copy service in `src/lib/server/schedule.ts`; copy route/action; UI. - **All phases** — `docs/openapi.yaml`, this contract, `docs/fixtures/`. --- ## Open items Recorded ambiguities, conservative choices, and remaining known limitations. Items 1–21 were resolved or restated during Phases 1–4; items 22–24 are Phase 5 findings. Everything marked "Resolved" is settled for this overhaul. 1. **Phase numbering.** The brief names Phases 3 (CRUD) and 4 (copy) only; Phase 1 (Kiosk) and Phase 2 (System Name) are assumed here. 2. **`blocks` for weekly.** Chosen as an empty array (server cannot know the Kiosk timezone). Consequence: old Kiosks see weekly schedules as "no schedule". Implemented in Phase 3. 3. **Block cap.** The existing `MAX_BLOCKS = 4` per day list is retained. Phase 3 applies it **per day list** (each of the seven weekly days gets its own 4-block budget). Whether the cap should be lower is unresolved. 4. **Field naming.** The brief's `start`/`end` were mapped to the existing `startTime`/`endTime`; `startMinutes`/`endMinutes` retained for the Kiosk. 5. **`version` derivation.** Resolved in Phase 3: a SHA-1 content hash of the serialized `{type, daily, weekly}` shape. Stable for equal content, changes on any mutation. 6. **Unauthenticated key-only access.** The new endpoints authenticate by License Key alone (per glossary). Anyone who knows a key could read the schedule or set the name. Mitigated only by the 10/min/IP rate limit. Known limitation. 7. **`dynamicAudio` default flip.** DB default changed `false` → `true` for **new** Blocks in Phase 3; existing Blocks keep their stored values. 8. **Per-user → per-License-Key migration.** Resolved in Phase 3: each existing per-user Schedule is mapped to that account's **oldest** License Key (`createdAt ASC`), as a daily schedule. Schedules with no owning user/license are retained with `licenseKeyId NULL` and reported via a migration `NOTICE` (not dropped). 9. **Cross-day spillover validation.** Implemented in Phase 3: weekly `sun`→`mon` and daily self-wrap spillover are rejected as overlaps; the shared `validateSchedule` is authoritative and mirrored client-side. 10. **Account scope for name uniqueness.** "Per account" = the owning User. Legacy License Keys with only an `email` and no `userId` are an edge case (written without a per-account uniqueness check). 11. **Geoip timezone display.** Phase 3 shows an informational note that times run in the **Kiosk's** timezone and uses the **browser-resolved** timezone as a fallback; no geoip dependency (paid or external) was added. The Portal still stores no timezone. 12. **ETag on the legacy endpoint.** The legacy `GET /api/v2/schedule` emits no ETag and never returns 304, to preserve old behaviour. 13. **`systemNameStatus` on v1.** Not added; v1 is unchanged. 14. **Trim/case representation.** Names are trimmed; uniqueness is case-insensitive; the stored casing is preserved as provided. 15. **Sync cadence.** Resolved in Phase 3: the Kiosk loads its on-disk cache on boot, then pulls on boot, on a 5-minute timer, and on demand. 16. **Atomic cache file.** Resolved in Phase 3: `~/.local/radiantwave/schedule_cache.json`, written atomically (temp file + rename); a corrupt cache is ignored. 17. **`type` encoding for no schedule.** `type: null`; `daily`/`weekly` absent or empty. 18. **Legacy alias.** `key` remains accepted as a `licenseKey` alias on the legacy schedule endpoint. 19. **Kiosk local toggle.** `proclamation.schedule_enabled` in `config.yaml`, default **on**, surfaced as System Settings → Schedule. Not synced. When off, the engine plays local selections but the cache keeps updating. The older `proclamation.schedule_active` flag is retained in config but is not consulted by the Phase 3 engine. 20. **Copy semantics.** Copies are **independent, by value** snapshots: no link is kept to the source, and editing the source later does not affect targets (and vice versa). A copy replaces any existing target Schedule in full (type and blocks) and requires explicit confirmation in the UI when a target already has one. No "copy to all" shortcut and no scheduled/linked copies (per the Phase 4 brief). The copied schedule is validated before commit; a source schedule that fails validation is rejected rather than copied. 21. **Copy and Kiosk code.** Verified against the Phase 3 Kiosk engine/fixture tests that a copied payload (new Block IDs, same type/times/audio/refs) is parsed and evaluated identically to any other; **no Kiosk changes were required**. 22. **Phase 3 migration down is lossy for new-model data.** The `up` migration (`20260929130000_move_schedule_to_license_key`) is additive and safe. Its `down` path is **reversible in structure but lossy in data**: the down SQL drops `Schedule.licenseKeyId`/`type` and `Block.day`, then deletes `Schedule` rows whose `userId IS NULL`. Any schedule created under the per-License-Key model (which sets `licenseKeyId` and leaves `userId` NULL) is therefore **deleted** by a down-migration. Consequences: - Back up `Schedule`/`Block`/`BlockProclamations` before any down-migration. - Rolling back past Phase 3 requires restoring that data from backup; it is **not** a data-preserving rollback. - Rollback guidance is in [scheduler-overhaul-release-notes.md](scheduler-overhaul-release-notes.md). 23. **Kiosk UI/E2E on real hardware not run here.** The verification ran the Portal and the Kiosk schedule logic (engine, client, cache) via the test harness and real HTTP against a locally running Portal; the SDL Kiosk UI was not driven interactively because the build environment has no display. See the manual checklist in the release notes and the Phase 5 report. 24. **Unauthenticated Kiosk endpoints remain a known limitation** (restated from item 6): the key-only schedule and system-name endpoints authenticate by License Key alone, mitigated only by the 10/min/IP rate limit. Authentication is out of scope for this overhaul. --- ## Changelog - 2026-09-29 — Initial contract created (Phase 0). Documents current state, target time semantics, schedule data model, validation rules, endpoints (`/api/v2/licenses/...`, additive license-validation fields), sync behavior, System Name rules, dynamic audio, backward compatibility, touchpoints, open items, and fixtures. - 2026-09-29 — Phase 3. Schedule is now per License Key with daily/weekly and `Block.day`; the shared `validateSchedule` is authoritative; the key-only `GET /api/v2/licenses/{licenseKey}/schedule` (ETag/304) ships and the legacy endpoint is extended additively; the Portal editor is per-system with a browser-fallback timezone note; the Kiosk adds the conditional client, atomic cache, schedule engine, and a System Settings On/Off toggle. Open items 2, 5, 7, 8, 9, 11, 15, and 16 are updated to reflect the changes. - 2026-09-29 — Phase 4. Schedule copy is implemented as a Portal-internal action (`?/copy`) backed by a single-transaction service that copies by value with fresh Block IDs (so target ETags change), preserving `dynamicAudio` and Proclamation order, scoped to the caller's account, and validated before commit. Endpoint (e) is finalized; open items 20 and 21 record the copy semantics and the Kiosk no-change verification. - 2026-09-29 — Phase 5 (final). End-to-end verification against a locally running Portal on a fresh database: schedule create/type/copy services and the Kiosk-facing HTTP endpoints (validation with/without `systemName`, system-name PUT, key-only schedule GET with ETag/304/404, legacy endpoint parity, cross-account rejection) all pass; old-client compatibility confirmed; migrations verified up/down (the down path is lossy, see open item 22). Adds open items 22–24 (lossy down-migration, unrun UI-on-hardware checks, and the restated unauthenticated-endpoint limitation) and the release notes.