Files
Signage/docs/architecture/decisions/0002-content-provider-contract.md
2026-08-28 16:41:54 -05:00

115 lines
6.2 KiB
Markdown

# 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.