@@ -0,0 +1,546 @@
# 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=<email>&licenseKey=<key>`
(`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": "<text>" }` .
- **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" : "<opaque string; also used as the ETag>" ,
"type" : "daily" | "weekly" | null ,
"daily" : [ B l o c k , . . . ] ,
"weekly" : { "mon" : [ B l o c k ] , "tue" : [ B l o c k ] , "wed" : [ B l o c k ] ,
"thu" : [ B l o c k ] , "fri" : [ B l o c k ] , "sat" : [ B l o c k ] ,
"sun" : [ B l o c k ] } ,
"blocks" : [ L e g a c y B l o c k , . . . ]
}
```
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": "<code>"}` ).
- 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: "<version>"` 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=<email>&licenseKey=<key>` 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 (names & purpose only)
Finalized in Phases 3 and 4. They are **not ** Kiosk-facing.
- `POST /api/v2/...` / server actions for Schedule CRUD (create/update/delete
Block, set `type` , assign Proclamations) — Phase 3.
- `POST .../copy` to copy a Schedule between License Keys on the same account —
Phase 4.
Exact paths and payloads are deferred; they will be recorded here when added.
---
## 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 and conservative choices. Each is subject to change in a
later phase.
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".
3. **Block cap. ** The existing `MAX_BLOCKS = 4` per day list is retained; the
brief does not state a cap. Whether weekly allows 4/day or 4 total 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.** Algorithm (content hash vs max `updatedAt` +
shape) deferred to implementation; it must be stable for equal content and
change on 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 changes `false` → `true` for
**new ** Blocks; existing Blocks are left at their stored values (no
backfill assumed).
8. **Per-user → per-License-Key migration. ** Accounts with multiple License
Keys: which key inherits the existing per-user Schedule is unresolved;
conservative default is the account's first/oldest key.
9. **Cross-day spillover validation. ** Weekly `sun` spillover into `mon` (and
daily same-list spillover) must be validated; exact error messaging is
deferred.
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.
11. **Geoip timezone display. ** Informational only, not implemented in the
Portal today.
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. ** The brief's boot/network-up/5-minute defaults are
targets; the current Kiosk has no such timer.
16. **Atomic cache file. ** Path and format deferred; SQLite is the current
durable store.
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.
---
## 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.