31 KiB
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.mdportal(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.
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/licensewith body{"license_key": "...", "email_address": "..."}. Implemented insrc/network/api_client.cpp(validateLicense,parseLicenseJson) and consumed bysrc/network/license_service.cpp(validateStoredLicense,markLicenseVerified,isLicenseWithinGracePeriod). A 2xx response is the device's "subscribed" flag; a 7-day offline grace window is stored aslicense_last_verifiedinsystem_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}]}.dynamicAudiois optional on the wire and defaults totrueinparseScheduleJson. Missing/blank credentials cause an earlyfalse. - Schedule persistence. SQLite tables
schedule_blocksandschedule_block_proclamations(src/db/database_manager.cpp:185,replaceScheduleBlocksat:1105,getScheduleBlocksat:1169). Full replace; blocksort_orderand per-block proclamationpositionpreserved. No day-of-week column and no scheduletype; the model is implicitly daily. - Schedule sync.
syncScheduleFromApi(src/network/proclamation_sync.cpp:172) is invoked only as a best-effort side effect ofsyncProclamationsFromApi(:168). There is no boot sync, no network-up sync, no periodic timer, and no conditional GET (If-None-Match/ETag).syncProclamationsFromApiis called fromHome::syncProclamationsIfEmpty(src/home.cpp:461) andProclamationOptionsPage::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.cppselects the active block by comparingtimezone::currentLocalMinutes(cfg.timezone)(src/timezone_helper.h) against each block viascroller::minuteInScheduleBlock(src/scroller_streams.h:31). Block selection is daily-only (no day-of-week). An active block's orderedproclamation_idsreplace the local selection (src/scroller_streams.cpp:57).effectiveDynamicAudio()(src/scroller_page.cpp:302) forces dynamic audio when the active block hasdynamic_audio == true, otherwise the device-wideconfig.audio.dynamic_audioapplies. A block change is latched inscheduleChangePending_and applied at the next proclamation boundary. - Settings persistence.
~/.local/radiantwave/config.yaml(YAML) viaConfigManager(src/config/config_manager.cpp,src/config/yaml_parser.cpp, schema insrc/config/app_config.h):timezone,audio.dynamic_audio,proclamation.schedule_active(Kiosk-local on/off toggle, defaultfalse). Identity lives in SQLitesystem_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 inopenapi.yamlat 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 inprisma.config.ts). API routes undersrc/routes/api/v1/(API-key gated) andsrc/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): validateslicense_key+email_address, requires the key be active and owned by the account, stampsLicenseKey.lastValidatedAt, returnsserializeLicense({key, emailAddress})(src/lib/server/v2-serializers.ts). A v1 variant exists (src/routes/api/v1/license/+server.ts). - Schedule storage.
Scheduleis per user (userId String @unique,prisma/schema.prisma:263), daily-only,0..4Blocks.Block(:275) storesstartMinutes/endMinutes,dynamicAudio Boolean @default(false), and orderedBlockProclamations(sortOrder). The schema comment states overnight ranges are disallowed in v1, yet the serializer and Kiosk both supportovernight— a known inconsistency. - Schedule service/validation.
src/lib/server/schedule.ts(DB orchestration:getOrCreateSchedule,listBlocks,createBlock,updateBlock,deleteBlock) and the isomorphicsrc/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 pairedemail+licenseKey(legacy aliaskey) 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(...)]}whereserializeScheduleBlock(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) withsrc/lib/components/schedule/DaySchedule.svelte(single 24h day timeline, drag-to-create) andBlockModal.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/weeklytyping; 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. dynamicAudioDB default isfalse; the target default for new Blocks istrue.- 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, nextstart 07:00is valid and has no gap). - A Block whose
endis earlier than itsstartcrosses midnight and belongs to its start day. In weekly mode, Sunday's crossing Block spills into Monday.start == endis 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):
{
"version": "<opaque string; also used as the ETag>",
"type": "daily" | "weekly" | null,
"daily": [ Block, ... ],
"weekly": { "mon": [Block], "tue": [Block], "wed": [Block],
"thu": [Block], "fri": [Block], "sat": [Block],
"sun": [Block] },
"blocks": [ LegacyBlock, ... ]
}
Block:
{
"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
}
versionis an opaque string derived from the Schedule content (see 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",dailyis populated andweeklyis omitted or empty. Fortype: "weekly",weeklyis populated with all seven keys anddailyis omitted or empty. Fortype: null, both are empty/omitted. weeklyalways contains all seven lowercase keysmon..sun; a day with no Blocks is an empty array.blocksis the legacy flattening retained for older Kiosks (see Backward compatibility). It uses the existingLegacyBlockshape serialized by the current portal ({id, startTime, startMinutes, endTime, endMinutes, overnight, durationMinutes, dynamicAudio, proclamationIds}). Fortype: "weekly",blocksis an empty array (the server does not know the Kiosk's timezone and cannot flatten weekly to a single day).proclamationIdsis the ordered play list (order is significant); duplicates are not meaningful and are de-duplicated by the Portal.dynamicAudiodefaults totruefor 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.
- A Schedule belongs to exactly one License Key; a License Key has at most one Schedule.
typeis one of"daily","weekly", ornull.- A Block has
startandendtimes;start == endis invalid. end < startdenotes a midnight-crossing Block belonging to its start day.- Blocks within a day (and the spillover of a midnight-crossing Block into the following day) must not overlap. Touching boundaries are allowed.
- Weekly modes apply rules 3–5 independently per day; a
sunBlock crossing midnight must not overlapmonearly Blocks. - 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.
- Gaps are allowed; during a gap local Kiosk selection applies.
dynamicAudiois a boolean; absent meanstruefor new Blocks.- Times are minute-precision
HH:MMstrings; the Portal snaps UI input toSNAP_MINUTES(15) with aMINUTE_STEP(5) constraint from the existing helpers. - The existing Portal cap of
MAX_BLOCKS = 4per day list is retained for Phase 0 (see 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.
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):
{
"license_key": "ABCD-EFGH-IJKL-MNOP",
"email_address": "jane@example.com",
"systemName": "Living Room"
}
Response (existing fields unchanged; systemNameStatus additive):
{ "key": "ABCD-EFGH-IJKL-MNOP", "email_address": "jane@example.com", "systemNameStatus": "ok" }
systemNameis 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-
okstatus 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:
{ "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. A License Key with no Schedule returns 200 withtype: nulland empty lists. 304 Not Modifiedwhen the request'sIf-None-Matchmatches the currentETag.404(unknown_license) when no License Key matches.versiondoubles 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 (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
?/copyaction pluscopySchedule()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 targettypebecomes the source's; Block rows get fresh IDs; each block'sdynamicAudioand 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-derivedversion/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); a304leaves 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.
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
systemNamenever clears an existing name. - Wire representations: request field
systemName; status fieldsystemNameStatus∈{"ok","unchanged","conflict","invalid"}(see Endpoints).
Dynamic audio
- Each Block has a boolean
dynamicAudio. - The Portal defaults it to
truefor 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
dynamicAudiototrue.
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(nosystemName) andGET /api/v2/schedule?email&licenseKey, readingblocksand ignoring unknown fields. The Portal keeps both, keeps theblocksshape byte- compatible, and addsversion/type/daily/weeklyas extra fields. The old Kiosk is unaffected and ignoressystemNameStatus. - Daily schedules. For
type: "daily",blocksis the flattened daily list; an old Kiosk behaves exactly as today. - Weekly schedules.
blocksis 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. - No schedule.
type: null,blocks: []; both old and new Kiosks fall back to local selection. - Omission of
systemNameby an old Kiosk never clears an existing name;systemNameStatusis 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.
Kiosk (radiantwave)
- Phase 1 (Kiosk scheduler UI, storage, sync, playback)
src/network/api_client.h/src/network/api_client.cpp— parseversion/type/daily/weekly, ETag/If-None-Match,PUTsystem-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— sendsystemName, handlesystemNameStatus.src/db/db_keys.h— System Name / ETag keys;src/db/database_manager.h/.cpp— scheduletype/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 inCMakeLists.txt.
Portal (portal)
- Phase 2 (System Name + license validation)
prisma/schema.prisma+ migration —LicenseKey.systemNameand 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.
- Copy service in
- 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.
- Phase numbering. The brief names Phases 3 (CRUD) and 4 (copy) only; Phase 1 (Kiosk) and Phase 2 (System Name) are assumed here.
blocksfor 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.- Block cap. The existing
MAX_BLOCKS = 4per 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. - Field naming. The brief's
start/endwere mapped to the existingstartTime/endTime;startMinutes/endMinutesretained for the Kiosk. versionderivation. Resolved in Phase 3: a SHA-1 content hash of the serialized{type, daily, weekly}shape. Stable for equal content, changes on any mutation.- 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.
dynamicAudiodefault flip. DB default changedfalse→truefor new Blocks in Phase 3; existing Blocks keep their stored values.- 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 withlicenseKeyId NULLand reported via a migrationNOTICE(not dropped). - Cross-day spillover validation. Implemented in Phase 3: weekly
sun→monand daily self-wrap spillover are rejected as overlaps; the sharedvalidateScheduleis authoritative and mirrored client-side. - Account scope for name uniqueness. "Per account" = the owning User.
Legacy License Keys with only an
emailand nouserIdare an edge case (written without a per-account uniqueness check). - 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.
- ETag on the legacy endpoint. The legacy
GET /api/v2/scheduleemits no ETag and never returns 304, to preserve old behaviour. systemNameStatuson v1. Not added; v1 is unchanged.- Trim/case representation. Names are trimmed; uniqueness is case-insensitive; the stored casing is preserved as provided.
- 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.
- Atomic cache file. Resolved in Phase 3:
~/.local/radiantwave/schedule_cache.json, written atomically (temp file + rename); a corrupt cache is ignored. typeencoding for no schedule.type: null;daily/weeklyabsent or empty.- Legacy alias.
keyremains accepted as alicenseKeyalias on the legacy schedule endpoint. - Kiosk local toggle.
proclamation.schedule_enabledinconfig.yaml, default on, surfaced as System Settings → Schedule. Not synced. When off, the engine plays local selections but the cache keeps updating. The olderproclamation.schedule_activeflag is retained in config but is not consulted by the Phase 3 engine. - 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.
- 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.
- Phase 3 migration down is lossy for new-model data. The
upmigration (20260929130000_move_schedule_to_license_key) is additive and safe. Itsdownpath is reversible in structure but lossy in data: the down SQL dropsSchedule.licenseKeyId/typeandBlock.day, then deletesSchedulerows whoseuserId IS NULL. Any schedule created under the per-License-Key model (which setslicenseKeyIdand leavesuserIdNULL) is therefore deleted by a down-migration. Consequences:- Back up
Schedule/Block/BlockProclamationsbefore 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.
- Back up
- 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.
- 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 sharedvalidateScheduleis authoritative; the key-onlyGET /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), preservingdynamicAudioand 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.