feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
# 0001: Signage owns timing; a provider owns content
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Related: Ekklesia [ADR 0006](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia)
|
||||
(versioned capabilities), [ADR 0041](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia)
|
||||
(browser contributions)
|
||||
|
||||
## Context
|
||||
|
||||
Church screens show announcements, scripture, trivia, service times, giving prompts, and photographs.
|
||||
The obvious design — one module that knows about all of them — is the design the legacy Announcements
|
||||
container has, and it is why adding trivia to that product means changing that product.
|
||||
|
||||
Signage exists to make the opposite true: adding trivia to the screens means installing a Trivia
|
||||
module, and Signage never changes.
|
||||
|
||||
That only works if the line between the two is drawn somewhere principled. Drawn in the wrong place,
|
||||
Signage ends up with an `announcementId` column, or a provider ends up owning a timer and two
|
||||
displays drift apart.
|
||||
|
||||
## Decision
|
||||
|
||||
**Signage owns everything about *when*. A provider owns everything about *what*.**
|
||||
|
||||
| Signage decides | A provider decides |
|
||||
| --- | --- |
|
||||
| Which slot is on screen, starting exactly when, until exactly when | Which of its items is next in a given selection scope |
|
||||
| That a slot's turn has come and a selection must be made | Whether that item may repeat, and when its pool is exhausted |
|
||||
| That every channel in a group advances together | What the item looks like, in its own React renderer |
|
||||
| How long a slot lasts when nothing overrides it | Its own default duration, and its own availability window |
|
||||
| That a resolved selection is persisted, revised, and replayed identically | What is inside the payload it hands back |
|
||||
|
||||
Concretely:
|
||||
|
||||
- Signage calls a provider; a provider never calls Signage's playback. A provider can *notify*
|
||||
Signage that something changed (invalidation) but cannot start, stop, skip, extend, or reorder
|
||||
anything.
|
||||
- Signage supplies a **stable opaque selection-scope id** and asks for the next item. The provider
|
||||
advances its own cursor within that scope, in its own tables. Signage stores no cursor of the
|
||||
provider's, and the provider stores no timing of Signage's.
|
||||
- Signage persists the *result* — item identity, revision, payload per audience, duration,
|
||||
availability — so that the same slot renders identically for a viewer that joined ten seconds later,
|
||||
survives a restart, and can be replayed by `previous` without asking the provider again.
|
||||
- Signage has no column, field, route, or type naming an announcement, a passage, a question, a
|
||||
campus, or a service. A payload is opaque JSON on the way through and opaque JSON at rest.
|
||||
|
||||
## The one exception, and why it is not one
|
||||
|
||||
Native images are Signage's own content: it stores them, processes them, and renders them. That looks
|
||||
like an exception to the rule until you see how it is built — as **Signage's own built-in content
|
||||
provider**, `signage:native-images`, implementing the same registration shape an external provider
|
||||
implements, resolving through the same code path, and persisting into the same resolved-selection
|
||||
table.
|
||||
|
||||
There is therefore exactly one selection pipeline, not two. A native image slot and a Trivia slot
|
||||
differ in which provider answers, and in nothing else. Had native images been special-cased, every
|
||||
later feature — history, offline continuation, audience variants, `previous` — would have had to be
|
||||
written twice and would have drifted.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A provider module can be written, installed, versioned, and removed without Signage knowing it
|
||||
exists. Signage never enumerates provider modules and declares no dependency on one.
|
||||
- A church that never installs Trivia has a Signage that is not missing anything; a timeline simply
|
||||
has no Trivia slot.
|
||||
- Provider failure cannot corrupt playback, because playback state was never the provider's to hold.
|
||||
What a failure costs is at most the freshness of one slot's content.
|
||||
- Signage cannot offer a feature that requires understanding content — "show announcements sorted by
|
||||
campus" is a Trivia-shaped request that belongs to whichever module owns campuses.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **One module that knows every content type.** Rejected: it is the legacy design, and every new kind
|
||||
of content is a change to the module every screen depends on.
|
||||
- **Providers push content into Signage's tables.** Rejected: two writers to one timeline, no way to
|
||||
attribute a bad row, and a provider outage that leaves stale rows nobody owns.
|
||||
- **Providers own their own timing and Signage only composes screens.** Rejected outright: two
|
||||
independent clocks cannot hold two televisions on the same slide, which is the single thing a
|
||||
synchronized channel group exists to guarantee.
|
||||
@@ -0,0 +1,114 @@
|
||||
# 0002: The content-provider contract, and what it deliberately cannot express
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Related: [0001](0001-signage-owns-timing-providers-own-content.md),
|
||||
[0003](0003-timelines-slots-and-selection-scopes.md)
|
||||
|
||||
## Context
|
||||
|
||||
`signage.content-providers` is the seam every future content module reaches Signage through. It is
|
||||
the one contract in this repository that will have implementations Signage's authors never see, so
|
||||
its shape has to be right before there is a second implementation rather than after.
|
||||
|
||||
## Decision
|
||||
|
||||
A provider module resolves the capability during its own `start` and registers once. Registration
|
||||
returns a handle; the handle is released during `stop`. Signage keeps registrations **in memory
|
||||
only** — a registration is a running fact, not a stored one, so a provider that is uninstalled leaves
|
||||
no row behind claiming it exists.
|
||||
|
||||
### What a registration carries
|
||||
|
||||
- `providerId`, `providerVersion` — stable identity, chosen by the provider.
|
||||
- `contractVersion` — which version of *this* contract the provider was written against. Signage
|
||||
refuses a registration outside its supported range rather than guessing.
|
||||
- `renderer: { contributionId, contributionVersion }` — the browser renderer this provider's content
|
||||
is drawn by. Signage never imports it; it hands the identity to its own display, which asks the
|
||||
host to mount it (ADR 0041). A provider whose renderer the platform did not publish is registered
|
||||
and reported as unrenderable rather than refused, because publication is the platform's decision
|
||||
and can change without the provider changing.
|
||||
- `defaultDurationMs` — how long this provider's content is shown when no slot overrides it.
|
||||
- `listSources(query)` — the sources an operator may put in a timeline. Management discovery only.
|
||||
- `listItems(query)` — optional. Specific items, so an operator can pin one.
|
||||
- `resolveNext(request)` — **the only call that advances anything.**
|
||||
- `refresh(request)` — re-resolve one already-selected item without advancing.
|
||||
|
||||
### What Signage supplies, and nothing more
|
||||
|
||||
```text
|
||||
selectionScopeId stable, opaque, and the same string every time this source occurs in this timeline
|
||||
sourceId which of the provider's own sources is being asked
|
||||
timelineId which timeline is running
|
||||
channelGroupId which synchronized group is asking
|
||||
audienceKeys every audience channel in that group, in a stable order
|
||||
at the instant being resolved for, as an absolute ISO 8601 instant
|
||||
timezone the installation's IANA zone, for a provider whose availability is calendar-shaped
|
||||
```
|
||||
|
||||
Signage does not supply a principal, a session, a database, storage, a URL, or a permission. A
|
||||
provider is already running inside the same trusted process; what it needs from Signage is *when*,
|
||||
and that is the whole list.
|
||||
|
||||
### What a provider returns
|
||||
|
||||
One **logical** selection — never one per channel:
|
||||
|
||||
```text
|
||||
itemId the provider's own stable identity for the content
|
||||
itemRevision changes whenever the payload changes and the item stays the same thing
|
||||
durationMs? overrides the provider's default for this one item
|
||||
availableUntil? when this selection stops being valid, as an absolute instant
|
||||
withdrawn? this item is explicitly gone; remove it at the next safe boundary
|
||||
presentation? opaque presentation values the provider itself supplies to its own renderer
|
||||
variants one payload per audience key, plus one default
|
||||
```
|
||||
|
||||
The default variant is required. An audience-specific variant is optional, and a provider that has
|
||||
none simply returns its default — which is what makes "in-person" and "online" a provider feature
|
||||
rather than a Signage one.
|
||||
|
||||
### Three distinct failure answers, deliberately
|
||||
|
||||
| The provider says | Signage does |
|
||||
| --- | --- |
|
||||
| Returns `null` | Nothing is available for this source right now. **Skip the slot immediately.** No error, no slate, no gap. |
|
||||
| Returns `{ withdrawn: true }` | This item is explicitly gone. Drop it at the next safe reconciliation boundary. |
|
||||
| Throws, rejects, or exceeds its deadline | A temporary failure. **Keep the last known good selection, mark it stale**, and never reset the slot. |
|
||||
|
||||
Collapsing these into one would be the whole design's biggest mistake: an empty Trivia pool would
|
||||
show an error, and a provider restart would clear the screen.
|
||||
|
||||
### Bounded, and sanitized
|
||||
|
||||
Every provider call is wrapped: a deadline (`providerTimeoutMilliseconds`), a `try`/`catch`, and a
|
||||
result validated before it is believed. A malformed result is rejected with a stable
|
||||
`SIGNAGE_PROVIDER_*` code carrying no provider value, no payload, and no caught exception. A provider
|
||||
cannot crash the Signage runtime, corrupt playback, or put its own words on a screen or in a log.
|
||||
|
||||
### Invalidation is a notification, not a command
|
||||
|
||||
`handle.invalidate({ ... })` tells Signage that something it already resolved may have changed.
|
||||
Signage decides what to do: refresh the affected selections, bump their revision, and broadcast. The
|
||||
provider cannot say *when*, cannot skip a slot, and cannot extend a deadline. This is what lets a
|
||||
cancelled event update a mounted React renderer within a second while its slot's authoritative
|
||||
deadline stays exactly where it was.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A provider is a small object of pure functions over its own data. It needs no timer, no socket, no
|
||||
knowledge of channels, and no Signage table.
|
||||
- Signage's provider code is the same for every provider, including its own built-in image provider.
|
||||
- A provider written against contract v1 keeps working when v2 exists, because v2 will be a new
|
||||
version a provider opts into by declaring it.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Let providers write resolved selections directly.** Rejected: two writers, no attribution, and
|
||||
stale rows after an uninstall.
|
||||
- **One `resolve(at)` call with no scope.** Rejected: five Announcement slots in one pattern must
|
||||
share one cursor, and without a scope id a provider cannot tell "the next one" from "the first one".
|
||||
- **Per-channel resolution.** Rejected: it makes audience variants a race. One logical selection
|
||||
resolved once, with variants derived from it, is what keeps two channels on the same slide.
|
||||
- **An error means "skip".** Rejected: a provider that is briefly down would silently rewrite the
|
||||
running order, and nobody would ever find out.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,62 @@
|
||||
# 0004: A channel group is one clock; an audience channel is one rendering of it
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Related: [0002](0002-content-provider-contract.md), [0005](0005-authoritative-playback.md)
|
||||
|
||||
## Context
|
||||
|
||||
A church wants the lobby screens, the sanctuary side screens, and the online stream overlay to be on
|
||||
the same slide at the same moment — but the online audience should see "watching from home" wording
|
||||
where the room sees "welcome in". That is one program with two renderings, not two programs.
|
||||
|
||||
It also wants the nursery hallway to run a completely different loop. That *is* two programs.
|
||||
|
||||
## Decision
|
||||
|
||||
**A channel group owns exactly one authoritative timeline clock.** It has one active timeline, one
|
||||
current slot, one start instant, one deadline, one pause state, one blackout state, and one pending
|
||||
activation. Everything a group's channels do, they do together.
|
||||
|
||||
**A channel is an audience within a group.** Each has a `key` (`default`, `in-person`, `online`, …),
|
||||
and exactly one channel per group is the default. Channels differ in exactly one way: which variant of
|
||||
a resolved selection they render.
|
||||
|
||||
- Signage resolves **one logical selection per slot occurrence**, for the whole group.
|
||||
- The provider is asked once, with every audience key the group has.
|
||||
- Each channel renders the variant matching its key, or the default variant if the provider offered
|
||||
none.
|
||||
|
||||
A truly independent clock requires another channel group. There is no per-channel pause, no
|
||||
per-channel skip, and no per-channel timeline — and no route that could express one.
|
||||
|
||||
**One default channel is created automatically**, and no other. `in-person` and `online` are examples
|
||||
in a document, not rows in a migration: a church that never needs a second audience never sees one.
|
||||
|
||||
**A viewer never chooses its channel.** A display registers with its persistent viewer identity and
|
||||
joins the default channel of the default group; an administrator moves it from the management
|
||||
dashboard. A public display that could pick its own channel would let anyone who reaches the page
|
||||
select what a screen shows.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Two viewers that connected minutes apart render the same slot with the same deadline, because both
|
||||
read the same persisted absolute instants rather than counting from when they arrived.
|
||||
- Adding an audience is additive: the provider starts returning a second variant, and the default
|
||||
channel is unaffected.
|
||||
- A provider that returns no variant for `online` is not a failure and produces no warning. Returning
|
||||
the default is the contract.
|
||||
- "Pause the lobby but not the sanctuary" is impossible by construction, and correctly so — those are
|
||||
two groups.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **One clock per channel, synchronized by convention.** Rejected: two clocks drift, and no amount of
|
||||
correction makes two televisions in one room agree.
|
||||
- **Per-channel timelines with a shared cursor.** Rejected: the cursor is only meaningful against one
|
||||
pattern; two patterns of different length make "the same slot" undefined.
|
||||
- **Let the display pick its audience from a query parameter.** Rejected: it is an unauthenticated
|
||||
content selector on a screen in a public hallway.
|
||||
- **Create `in-person` and `online` channels on install.** Rejected: it presumes a broadcast ministry
|
||||
every church does not have, and an unused channel is a thing an operator has to understand and
|
||||
ignore forever.
|
||||
@@ -0,0 +1,98 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,111 @@
|
||||
# 0006: Two permanent layers, real readiness, and an offline continuation that never becomes authority
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Related: [0005](0005-authoritative-playback.md), Ekklesia
|
||||
[ADR 0043](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (display offline code)
|
||||
|
||||
## Context
|
||||
|
||||
The legacy `v1` renderer rebuilt image elements per slide and faded the outgoing frame to
|
||||
transparent, which let the black page background show through the middle of every transition. `v2`
|
||||
fixed it with two permanent layers and never fading the outgoing one, and that fix is the single most
|
||||
visible quality difference on a real television.
|
||||
|
||||
Signage additionally has to transition between things that are not images at all: a provider's live
|
||||
React renderer, mounted through the platform's contribution ABI.
|
||||
|
||||
## Decision
|
||||
|
||||
### Two permanent scene layers, and one direction of fade
|
||||
|
||||
The display owns exactly two full-screen layers, created once and never rebuilt. For every
|
||||
transition:
|
||||
|
||||
1. Resolve and mount the incoming content in the **hidden** layer — a native image, or a contributed
|
||||
renderer mounted through `context.contributions.mount(...)`.
|
||||
2. Wait for **actual readiness**: `img.decode()` for an image, and the renderer's own `mount`
|
||||
resolution for a contribution, which the contribution ABI defines as asynchronous precisely so a
|
||||
renderer can say "not yet".
|
||||
3. Discard completion belonging to a stale generation. A newer transition always wins.
|
||||
4. Keep the outgoing layer **fully opaque** the entire time.
|
||||
5. Raise the prepared incoming layer above it at opacity zero.
|
||||
6. Fade **only the incoming layer** in.
|
||||
7. Unmount and release the old layer after the fade completes.
|
||||
|
||||
Both layers are never faded at once, so the black root can never contribute to the composite.
|
||||
|
||||
### Failure keeps the last good frame
|
||||
|
||||
If incoming content cannot be decoded or mounted: keep the frame that is already on screen, record a
|
||||
bounded structured error, mark **that selection** locally unusable, and continue. A broken item is
|
||||
never retried in a tight loop, and one broken item never blanks a screen.
|
||||
|
||||
An individually empty provider slot is different, and is skipped immediately with no error at all —
|
||||
that is the provider saying "nothing right now", which is ordinary.
|
||||
|
||||
Only when there is neither renderable timeline content nor a cached good frame does the display show
|
||||
its built-in slate: restrained, non-flashing bars and the words *Content unavailable*, carrying no
|
||||
exception, URL, payload, credential, database detail, or identifier. Detail belongs in Signage's
|
||||
sanitized management telemetry, where an already-authorized operator can read it.
|
||||
|
||||
A deliberate blackout is not a failure and never reaches the slate: it is pure black.
|
||||
|
||||
### The management preview is the same code
|
||||
|
||||
The Dashboard's live preview mounts the identical renderer host, the identical native-image
|
||||
presentation resolver, and the identical layer transition. It differs in exactly two ways: it does not
|
||||
register a viewer, and it sends no heartbeat. A preview that reimplemented the transition would drift
|
||||
from what the wall actually shows, which makes it worse than no preview.
|
||||
|
||||
### Offline continuation, and its honest limit
|
||||
|
||||
Once mounted, a display survives losing the host. Signage persists its own domain state — viewer
|
||||
identity, the last authoritative snapshot, resolved selections and revisions, audience payloads, the
|
||||
timing anchor, transport state, renderer contribution identity, and the most recent safe error — in
|
||||
**IndexedDB**, and native image bytes in **Cache Storage**. Ordinary HTTP cache is not an asset
|
||||
manager and is not used as one.
|
||||
|
||||
While offline the display continues deterministically from the last authoritative anchor, applying
|
||||
cached scheduling and expiration, preserving pause, blackout, static hold, and remaining duration,
|
||||
skipping explicitly expired or withdrawn content, keeping the last good frame when expected content
|
||||
is unusable, and never inventing provider data. Catch-up is arithmetic, mirroring the server's own
|
||||
rule, so a long outage does not replay linearly.
|
||||
|
||||
**A local cursor is never reported back as authority.** On reconnect the server's state wins
|
||||
immediately; incoming artifacts and data are staged before the current frame is replaced, no missed
|
||||
slides are replayed, and superseded payloads and image bytes are pruned only after the new snapshot
|
||||
is usable.
|
||||
|
||||
### The limit this cannot fix, stated rather than papered over
|
||||
|
||||
**A cold browser start while Ekklesia is unreachable cannot mount Signage at all.** The platform's
|
||||
display service worker caches the shell and the declared code, deliberately not the display
|
||||
descriptor — a descriptor is authorization-bearing, and caching one would let a revoked screen keep
|
||||
showing content. Signage cannot and must not work around that: everything above applies to a display
|
||||
that is **already mounted** when its network fails, which is the overwhelmingly common case (a router
|
||||
reboot, an uplink drop, a switch power-cycle).
|
||||
|
||||
True offline cold start requires something only Core can provide: an authorized offline display
|
||||
descriptor or lease with its own expiry and revocation. That is tracked as an Ekklesia prerequisite,
|
||||
not as Signage work, and this repository does not claim the capability it does not have.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Transitions are visually identical for images and for live React renderers, because readiness is
|
||||
awaited rather than assumed for both.
|
||||
- A provider renderer that throws costs one slide, not the screen.
|
||||
- The preview and the wall cannot disagree.
|
||||
- A screen that reboots during an outage shows Ekklesia's own offline state and recovers by itself
|
||||
when connectivity returns, but shows no Signage content until it can resolve its descriptor.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Crossfade both layers.** Rejected: it is the `v1` bug, and it is visible on every transition.
|
||||
- **Treat `onload`, or a fixed delay, as readiness.** Rejected: a large image paints mid-fade, and a
|
||||
renderer that is still fetching a font shows a flash of the wrong thing.
|
||||
- **Cache the display descriptor so a cold start works offline.** Rejected: it is a stored permission
|
||||
decision, and a revoked screen would keep showing content. This is the platform's rule, and Signage
|
||||
respecting it is the point.
|
||||
- **Use the HTTP cache for image bytes.** Rejected: no eviction control, no way to prune superseded
|
||||
renditions deliberately, and no way to know what is actually available offline.
|
||||
@@ -0,0 +1,108 @@
|
||||
# 0007: A native image is content-addressed, archived once, and never baked
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Related: [0001](0001-signage-owns-timing-providers-own-content.md), Ekklesia
|
||||
[ADR 0030](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (host-provided storage),
|
||||
[ADR 0042](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (streaming bodies)
|
||||
|
||||
## Context
|
||||
|
||||
Native images are the only content Signage owns, and the one thing it must do well with no other
|
||||
module installed. A church's actual usage is an operator dragging twenty exported slides onto a page,
|
||||
some of them the same file they uploaded last week.
|
||||
|
||||
## Decision
|
||||
|
||||
### Identity is the content hash
|
||||
|
||||
An asset's identity is the **SHA-256 of the uploaded bytes**, and the database enforces it as unique.
|
||||
Uploading the same bytes twice returns the existing asset instead of storing a second copy, and two
|
||||
operators uploading the same file — even simultaneously — produce one asset and one set of stored
|
||||
objects: the loser of that race finds the winner's asset and reports success, because the image the
|
||||
operator uploaded is stored.
|
||||
|
||||
Object keys are scoped to the asset (`assets/<asset-id>/…`), and until that asset's row commits they
|
||||
are **temporary**: nothing references them, nothing can select them, and a failed ingest deletes
|
||||
every one it wrote. Scoping them to the asset rather than to a throwaway upload id is what makes a
|
||||
successful upload cost exactly one write of each object instead of two — the commit is the database
|
||||
row, not a second copy of 25 MiB.
|
||||
|
||||
### One file per request, streamed, bounded
|
||||
|
||||
Uploads use `bodyMode: "streamed"` with a **25 MiB** Signage limit, inside the platform's 32 MiB
|
||||
absolute ceiling. The unit is deliberately one file: a dashboard offering twenty images issues twenty
|
||||
individually bounded requests, each of which can fail cheaply, rather than one request whose failure
|
||||
wastes everything already sent. Nothing in Signage can express an aggregate upload.
|
||||
|
||||
Because a `413` can arrive *after* the handler starts in streamed mode, **nothing is written anywhere
|
||||
until the whole request has been read and validated**. An upload that is aborted, oversized,
|
||||
malformed, or unsupported has therefore written zero objects and zero rows. Once validation passes,
|
||||
the objects are written to the temporary asset-scoped keys above and the row commits last; any
|
||||
failure in between removes every object that attempt wrote. A partial write is never promoted to an
|
||||
asset.
|
||||
|
||||
### Accepted, and refused
|
||||
|
||||
Accepted: still PNG, JPEG, and WebP. Refused: SVG, GIF and any animation, PDF, audio, video,
|
||||
multipage input, anything malformed, anything over 25 MiB, and anything whose decoded size exceeds
|
||||
**40 million pixels** — the last because decoded pixels, not file size, are what a decompression bomb
|
||||
actually costs.
|
||||
|
||||
Format is decided by **decoding the bytes**, never by the filename or the declared content type.
|
||||
|
||||
### Three stored objects, and what each is for
|
||||
|
||||
| Object | Purpose |
|
||||
| --- | --- |
|
||||
| Original | Archived **byte-for-byte, unmodified**. It is the church's file; Signage is not entitled to rewrite it. |
|
||||
| Display WebP | Inside-fit within 1920×1080, quality 82. What a television actually renders. |
|
||||
| Thumbnail WebP | Inside-fit within 480×270, quality 72. What the library grid renders. |
|
||||
|
||||
Both renditions are auto-oriented, **never cropped**, **never upscaled**, stripped of metadata, and
|
||||
flattened against black. The original's real dimensions, orientation, and useful metadata stay in
|
||||
PostgreSQL, where they are queryable without touching storage.
|
||||
|
||||
### Presentation is resolved at render time, never baked
|
||||
|
||||
A 16:9 image (within a documented tolerance of 0.02) fills the frame on its own: no blurred
|
||||
duplicate, no pillarbox, no floating-card shadow. Anything else is shown **whole** with
|
||||
`object-fit: contain` over a blurred, solid, or black background, optionally darkened, with a
|
||||
foreground shadow.
|
||||
|
||||
None of that is written into a stored rendition. Changing a slot's background from blurred to black
|
||||
is a database update, not a re-encode, and the same rendition serves both. These controls apply to
|
||||
native images only — an external provider owns its rendering completely.
|
||||
|
||||
### Keys are persisted; URLs are not
|
||||
|
||||
Signage stores **logical object keys** and asks the host for a fresh read URL when something is about
|
||||
to be delivered. A read URL is a bearer capability with an expiry in it; storing one in a table would
|
||||
be storing a credential that stops working. Signage never configures, names, or identifies a storage
|
||||
service.
|
||||
|
||||
### Deletion is reference-aware
|
||||
|
||||
Deleting an asset referenced by a timeline slot, by a pinned item, or by a live static hold is refused
|
||||
with a sanitized code naming what refers to it. An asset with a null expiration is permanent; local
|
||||
age never invents one. Rendition identity is immutable — a rendition is replaced by uploading
|
||||
different bytes, which by definition produces a different asset.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Twenty uploads are twenty independent, individually recoverable requests, and the dashboard stays
|
||||
responsive because none of them blocks another.
|
||||
- Re-uploading last week's file costs one hash and one row read.
|
||||
- A partially failed ingest leaves a temporary object that is removed, never an asset in a state
|
||||
playback could select.
|
||||
- Changing presentation is instant and lossless, because nothing was baked.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **A generated UUID as identity.** Rejected: it makes deduplication impossible and retries dangerous.
|
||||
- **Trust the declared content type.** Rejected: it is attacker-controlled and routinely wrong even
|
||||
when it is not.
|
||||
- **Bake the background and shadow into the rendition.** Rejected: every presentation change becomes a
|
||||
re-encode, and the archived original stops being the source of truth.
|
||||
- **One multi-file upload request.** Rejected: one bad byte wastes every byte already sent, and the
|
||||
platform's ceiling is per request for exactly that reason.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Signage architecture decision records
|
||||
|
||||
These record the durable choices behind Signage itself. Ekklesia's own ADRs remain authoritative for
|
||||
everything the platform owns — manifests, capabilities, permissions, storage, realtime, browser
|
||||
artifacts, contributions, and request bodies — and are never restated here.
|
||||
|
||||
- Filenames use a four-digit sequence followed by a short kebab-case title.
|
||||
- Status is `Proposed`, `Accepted`, `Superseded`, or `Rejected`.
|
||||
- Accepted ADRs are immutable except for status and cross-reference corrections.
|
||||
- A changed decision receives a new ADR that links to and supersedes the old one.
|
||||
|
||||
## Index
|
||||
|
||||
- [0001: Signage owns timing; a provider owns content](0001-signage-owns-timing-providers-own-content.md) — Accepted
|
||||
- [0002: The content-provider contract, and what it deliberately cannot express](0002-content-provider-contract.md) — Accepted
|
||||
- [0003: Timelines are ordered patterns; a source instance is a selection scope](0003-timelines-slots-and-selection-scopes.md) — Accepted
|
||||
- [0004: A channel group is one clock; an audience channel is one rendering of it](0004-channel-groups-and-one-clock.md) — Accepted
|
||||
- [0005: Playback is absolute instants reconciled forward, never a countdown](0005-authoritative-playback.md) — Accepted
|
||||
- [0006: Two permanent layers, real readiness, and an offline continuation that never becomes authority](0006-display-rendering-and-offline-continuation.md) — Accepted
|
||||
- [0007: A native image is content-addressed, archived once, and never baked](0007-native-images.md) — Accepted
|
||||
Reference in New Issue
Block a user