feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
@@ -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.
+20
View File
@@ -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