# 0003: Timelines are ordered patterns; a source instance is a selection scope - Status: Accepted - Date: 2026-08-27 - Related: [0002](0002-content-provider-contract.md), [0004](0004-channel-groups-and-one-clock.md) ## Context An operator building a Sunday-morning loop wants something like: ```text Announcements Announcements Announcements Trivia Announcements Announcements Scripture ↻ ``` Five Announcement slots. They must not all show the same announcement, and they must not each start a separate rotation that happens to begin at the same place. They are five *turns* of one rotation. ## Decision Three concepts, and no more. ### A timeline is an ordered, repeating pattern `signage_timelines` holds a name, an optional eligibility window, and a revision. It holds no cursor, no clock, and no channel: a timeline is a *program*, and running one is what a channel group does. The same timeline may be activated on two groups at once and they will not interfere. ### A slot is a position in that pattern `signage_timeline_slots` rows are `(timeline_id, position)` with `position` **unique and contiguous** from zero. Contiguity is enforced inside the transaction that changes it, never by renumbering on read: a gap would make "the next slot" ambiguous exactly when playback needs it least. A slot's identity is its `id`, which is stable across every reorder. Playback history, resolved selections, and a display's cursor all reference the id, so dragging a slot up the list never rewrites what already happened. Each slot is one of two kinds: - **`provider-source`** — "ask this source for the next item when this slot's turn comes". - **`pinned-item`** — "show exactly this item". A native Signage image is a pinned item of the built-in image provider, which is why the two need no separate code path (ADR 0001). Every slot may carry an `enabled` flag, a duration override, its own eligibility window, and — for native images only — presentation overrides. ### A source instance is a selection scope `signage_provider_sources` is the piece that makes the five-Announcement pattern work. One row is "the Announcements module's `active-slides` source, as used by this timeline", and it carries a stable opaque `selection_scope` string. Every slot referencing that row passes the **same** scope id to `resolveNext`. The provider advances its own cursor within that scope, so the five occurrences walk through five different announcements and the sixth wraps according to *the provider's* repeat policy — which Signage neither implements nor knows about. Two source rows for the same provider source are two independent rotations, which is exactly how an operator expresses "a fast loop here and a slow one there". Signage generates the scope id and stores it. It is opaque to the provider in the sense that its shape means nothing; it is stable in the sense that it survives edits, restarts, and reorders. It is **not** derived from the timeline id or the slot id, because either would silently reset a provider's rotation when an operator renamed or reordered something. ### Eligibility is `[start, end)`, in absolute instants Both a timeline and a slot may carry a window. It is half-open: active exactly at `start`, inactive exactly at `end`, which is the only convention under which two adjacent windows neither overlap nor leave a gap. Every stored instant is absolute (`timestamptz`). The installation's IANA zone is an operator setting used to *write and read* those instants for a human, never to interpret an ambiguous local timestamp at run time. A daylight-saving transition therefore cannot move a slot. Signage decides whether a source *participates*; the provider decides what is *available*. Leaving Trivia out of a service means using a timeline without a Trivia slot, or disabling that slot — and Signage still never learns what Trivia is. ## Consequences - Reordering, renaming, duplicating, and disabling are cheap and safe: none of them touch a scope id or a selection's identity. - A timeline with every slot disabled, or every slot ineligible, is a legitimate state — it resolves to nothing, and the display shows its slate rather than an error. - An operator can express "three announcements, then trivia, then two more announcements" without any concept of grouping, nesting, or weighting. The pattern *is* the expression. ## Alternatives - **Slot position as identity.** Rejected: every reorder would rewrite history and break a display's cursor mid-service. - **Scope derived from `(timeline, provider, source)`.** Rejected: it silently merges two source rows an operator deliberately created as separate rotations, and changes meaning when a timeline is duplicated. - **A `repeat: n` count on one slot instead of repeated slots.** Rejected: the pattern is the thing an operator reads, and a count hides where in the loop the repeats actually fall. - **Local wall-clock schedules.** Rejected: one ambiguous hour every autumn, resolved silently, on the screens a congregation is looking at.