# 0005: Playback is absolute instants reconciled forward, never a countdown - Status: Accepted - Date: 2026-08-27 - Related: [0004](0004-channel-groups-and-one-clock.md), [0006](0006-display-rendering-and-offline-continuation.md) ## Context The proven behaviour this preserves comes from the legacy Announcements `v2` playback engine, which ran unattended church screens through outages, restarts, and mid-service edits. Its three hard-won properties are worth restating, because they are the ones a naive implementation loses: 1. **Absolute instants, not remaining time.** A slide has a `startedAt` and a `deadlineAt`; a timer is only ever a hint about when to look again. 2. **Reconcile forward from the previous authoritative deadline**, never from "now". Otherwise a delayed callback shortens nothing and lengthens everything, and drift accumulates all day. 3. **Catch up arithmetically.** A screen that was off for a week must not replay a week of slides. ## Decision ### One serialized service per channel group Each group has exactly one playback service owning its persisted state and its timers. Every mutation — a timer firing, an operator command, a provider invalidation — runs serialized against that group, inside one explicit transaction, and produces at most one revision increment. Two concurrent commands cannot interleave into a state neither asked for. `revision` increases **only on semantic change**. An exact no-op — resuming something already running, activating the timeline already active — commits nothing and broadcasts nothing. ### Reconciliation, not simulation `reconcile(group, at)` is the single entry point. It reads persisted state and walks it forward to `at`: - If the current slot's deadline is still in the future, **nothing happens**. This is what makes restart recovery show the same item it was showing, rather than choosing a new one. - Otherwise the boundary is the *previous deadline*, not `at`. The next slot starts exactly when the last one ended, so a callback that fired 40 ms late costs 40 ms once instead of 40 ms per slide forever. - Eligibility and expiration boundaries are honoured at their exact instants, and a currently showing slot can only be cut short by *its own* window, never by another slot's. ### Catch-up is bounded by boundaries, not by missed slides When the gap is long, Signage advances the slot cursor **arithmetically**: it computes the effective duration of each enabled, eligible slot from what it already knows — the slot override, or the provider's registered default, or the native-image default — sums one pass of the pattern, and skips whole passes with a division. Only the slot it actually lands on is resolved. That is the property that keeps a provider's rotation sane: catching up a week does **not** call `resolveNext` ten thousand times and does not consume a provider's no-repeat pool. Fast-forwarding stops at the first eligibility boundary in the interval, exactly as `v2` did, so a schedule change inside the outage is still honoured. ### Transport | Control | Meaning | | --- | --- | | Pause | Freeze the current slot. Persist the remainder; clear start and deadline. | | Resume | `deadline = now + remainder`; `start = deadline − duration`, so the reconstructed timeline stays consistent. | | Static hold | Show one native image while **preserving the normal cursor and remainder** underneath. Releasing it resumes from that remainder. | | Next / Previous | Move over **actually resolved playback**: `previous` re-shows the previous resolved selection from history, not "the slot before this one". | | Blackout | Cover the output with black. The hidden timeline keeps running, keeps its deadlines, and keeps resolving. | | Reload | Refresh provider availability and re-resolve upcoming selections **without disturbing the current frame or its deadline**. | | Activate now / at next boundary | Immediate, or queued as `pending` and applied at the next safe slot boundary. | A provider's internal React behaviour may change anything it likes during a slot; it may not extend that slot's authoritative deadline. The deadline was set by Signage before the renderer mounted. ### Realtime carries revisions, not content Signage publishes compact authoritative state — group id, revision, slot identity, absolute start/deadline, transport flags — and nothing large. Timelines, histories, and renderer payloads are fetched over authorized HTTP, keyed by revision, so a client that sees a revision it does not have refetches rather than waiting. That closes the GET-then-subscribe race without a second transport, and keeps every publication far inside the platform's payload ceiling. Signage introduces no socket, no broker, and no second realtime transport. It publishes through the platform's module realtime contract and nothing else. ## Consequences - Server authority is unambiguous: on reconnect, the server's state wins immediately, and a client's local cursor is discarded rather than reconciled. - Timers are never load-bearing. Losing one costs a late transition, not a wrong one. - Two displays that connected minutes apart agree, because they both read the same absolute instants. - A long outage costs one reconciliation, not one per missed slide. ## Alternatives - **A countdown per slide.** Rejected: it drifts, and it cannot be restored after a restart. - **Reconcile from `now`.** Rejected: every late callback permanently lengthens the loop. - **Replay every missed slide to catch up.** Rejected: it burns a provider's rotation and can take longer than the outage. - **Publish full state over realtime.** Rejected: a large timeline exceeds the payload ceiling, and a screen that missed one message would be wrong until the next one.