feat: initial Signage 0.1.0
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user