115 lines
6.2 KiB
Markdown
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.
|