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

6.2 KiB

0002: The content-provider contract, and what it deliberately cannot express

  • Status: Accepted
  • Date: 2026-08-27
  • Related: 0001, 0003

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

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:

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.