feat: let a display survive its network — as code, not as authorization

A display is the one surface expected to outlive its network: a television on
a wall in a building whose uplink is a consumer router, with nobody standing
in front of it. When that uplink drops and the screen reboots, the browser
shows its own error page — not the display shell, not an Ekklesia message,
nothing that recovers on its own, because there is no Ekklesia code left
running to recover.

Cache the shell and the declared code, in a service worker scoped to
`/display/`: the page, the host script, the module's display artifact, and
every contributed renderer that display actually mounted. All of them are code
whose bytes already confer no authority, and all of them are addressed by a
URL carrying an exact version.

Cache nothing else, ever. Not the descriptor, not `/api/v1/principal`, not a
module or platform API response, not the realtime stream, not a pairing
exchange, and not any cross-origin request — which is what a signed
object-delivery URL is. It is an allowlist rather than a deny list, so the
cost of forgetting a new endpoint is that code goes uncached rather than that
a permission decision gets stored.

The consequence is stated rather than papered over: a display cannot render
module content while offline. Mounting needs a descriptor, a descriptor is
authorization-bearing, and it is never cached — so a revoked device stays
revoked and cached code can never manufacture current authorization. What the
cache buys is that a kiosk rebooting mid-outage boots into Ekklesia's own
offline state and keeps retrying, that reconnecting mounts with nothing left
to download, and that a hiccup fetching 600 KB of artifact after the tiny
descriptor already resolved falls back to identical bytes instead of failing
the display.

Network-first everywhere, so a screen picks up new code the moment the server
returns and can never be trapped on an obsolete build — the failure mode a
cache-first worker produces on a screen nobody visits. Only a complete
same-origin 200 is stored. Cache names carry a policy version and superseded
generations are deleted; artifacts are pruned to the current version per
module, keeping every artifact belonging to that version because a display
artifact and its contributed renderers share one. The worker never caches
itself.

The page gets a small signal built from two sources, because either alone
lies: `navigator.onLine` is optimistic behind a captive portal, and only the
worker knows it actually served from cache. A display shows a quiet corner
indicator rather than blanking a lobby wall, and resolves its descriptor again
by itself when the network returns — nobody presses Retry on a lobby TV.

Every caching decision lives in a module that touches no global, so the
behaviour is exercised directly rather than through a worker a test runner
cannot host. Registration is an enhancement: no service workers, plain HTTP,
or a refusing policy all leave a display that works exactly as before.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-27 16:08:34 -05:00
co-authored by Claude Opus 5
parent e36485bf99
commit ca3f37d5f9
22 changed files with 1726 additions and 29 deletions
@@ -0,0 +1,173 @@
# 0043: The display caches its shell and declared code, and nothing else
- Status: Accepted
- Date: 2026-08-27
- Supersedes: none
- Related: [ADR 0025](0025-trusted-module-ui-asset-publication.md),
[ADR 0031](0031-direct-object-delivery.md),
[ADR 0032](0032-native-module-realtime-transport.md),
[ADR 0033](0033-display-kiosk-host-contract.md),
[ADR 0036](0036-managed-device-identity-and-pairing.md),
[ADR 0041](0041-module-browser-contributions.md)
## Context
A display is the one surface in this platform that is expected to outlive its network. It is a
television on a wall in a building whose uplink is a consumer router, nobody is standing in front of
it, and the person who could press a button is not in the building on a Tuesday.
Today, when that network drops and the screen reboots — a power blip, a scheduled restart, a
janitor's foot — the browser shows **its own error page**. Not the display shell, not an Ekklesia
message, not a retry: a network failure with no Ekklesia in it at all, until someone notices and
does something about it. Nothing about that page recovers on its own, because there is no Ekklesia
code running on it to recover.
The temptation is to solve this by caching more: the descriptor, the principal, the module's data,
so a screen can keep showing content through an outage. That is exactly the wrong instinct. A cached
descriptor is a cached permission decision, and a screen replaying one is a revoked device still
showing content it is no longer entitled to.
## Decision
Cache **the shell and the declared code**, and nothing else, in a service worker scoped to the
display.
### What is cached
- The display page (`/display/<module-id>`).
- The fixed display host script.
- The versioned browser artifacts the display actually loaded: its module's display artifact, and
every contributed renderer it mounted (ADR 0041).
All three are code. All three are already public bytes that confer no authority (ADR 0025, ADR 0041).
All three are addressed by a URL that carries an exact version, so a cache entry is unambiguous about
what it is.
### What is never cached, under any condition
The display descriptor, `/api/v1/principal`, every other platform and module API response, the
realtime stream, pairing requests and responses, and **every cross-origin request** — which is what a
signed object-delivery URL is (ADR 0031), a bearer capability with an expiry that a cache would
outlive.
This is an **allowlist**, not a deny list. A deny list has to be updated every time the platform gains
an endpoint, and the cost of forgetting is a stored permission decision. Here the cost of forgetting
is that a new kind of code is simply not cached, which is a bug rather than a vulnerability.
### The consequence, stated rather than papered over
**A display cannot render module content while its network is down.** Mounting requires a descriptor;
a descriptor is authorization-bearing; it is never served from a cache. A revoked device therefore
stays revoked, and cached code can never manufacture current authorization — the two properties this
pass must not trade away.
What the cache buys is real and worth having on its own:
- A kiosk that reboots during an outage boots into **Ekklesia's** honest offline state instead of the
browser's error page, and keeps retrying by itself.
- The moment connectivity returns it resolves and mounts with **nothing left to download** — the
600 KB artifact is already there.
- A momentary failure fetching an artifact, after a descriptor already resolved, falls back to the
identical bytes instead of failing the whole display. The descriptor is tiny and the artifact is
not, so this is the failure that actually happens.
Module-owned offline content — a module persisting its own state and media so a screen can keep
showing yesterday's slides — remains the module's responsibility. This is the code foundation it
would build on.
### Network first, always
Every cached kind is fetched from the network first and only falls back when that fetch fails. A
kiosk therefore picks up new code the moment the server returns and can never be trapped on an
obsolete build — which is precisely the failure mode a cache-first worker produces on an unattended
screen, and the one that requires physically visiting it to fix.
Only a complete, successful, same-origin `200` is ever stored. A `304`, a `404`, a redirect, an
opaque response, and a `503` from an API mid-restart are all things a screen must never later replay
as though they were the artifact.
### Versioned keys and pruning
Cache names carry a policy version; superseded generations are deleted on activation, so a policy
change cannot leave a screen serving entries from rules that no longer apply.
Artifact URLs already carry an exact module version, so a new build lands *beside* the old one. After
storing an artifact the worker removes every entry for the same module at a different version — and
deliberately keeps every entry at the current version, because a module's display artifact and each
contributed renderer are separate URLs under one version and dropping any of them would half-cache a
working display. Another module's artifacts are never touched.
The worker never caches **itself**, which is the other way a screen gets stuck: it is served
`no-cache` with a strong validator, exactly like the host script.
### Scope is the directory
The worker is served at `/display/sw.js`, chosen for its *directory*: a service worker's default
scope is where it was served from, so this one controls display pages and nothing else. The Dashboard
is never controlled by it and no Dashboard request is ever intercepted. Serving it beside the host
script under `/assets/` would scope it to a directory where no page lives; widening it with
`Service-Worker-Allowed` would put the Dashboard under a worker it has no use for.
A controlled page's requests are intercepted wherever they point, which is how an artifact under
`/api/v1/module-ui/` is cached without widening the scope to the whole origin.
### Reporting, not deciding
The page gets a small browser-safe signal built from two independent sources, because either alone
would lie: `navigator.onLine` (cheap, immediate, famously optimistic — a captive portal reports
`true`) and the worker's own report that it actually served from cache (ground truth about this
application). A display renders a quiet corner indicator, never a full-page failure: a screen whose
switch rebooted must not blank a lobby wall.
When the browser reports the network is back, the display resolves its descriptor again on its own.
Nobody presses Retry on a lobby television, and that fresh descriptor is also what stops a screen
sitting on cached code after a deployment — it names the current version, and the network-first worker
fetches it.
Registration is an **enhancement and never a prerequisite**: a browser without service workers, a page
served over plain HTTP in development, and a policy that refuses all leave a display that works
exactly as it did before, online only.
### Testable by construction
Every caching decision lives in a module that touches no global: the cache storage, `fetch`, and the
way a status reaches the page all arrive as an environment. The worker file is thin wiring that
supplies the real ones. That is what lets the behaviour be exercised directly — first load, offline
reload, a missing uncached artifact, contribution fallback, version upgrade and pruning, reconnect,
and the proof that nothing authorization-bearing is ever stored — rather than through a service
worker a test runner cannot host.
## Consequences
- An unattended screen degrades to an Ekklesia message and recovers by itself, instead of degrading
to a browser error page and staying there.
- The platform serves a third fixed display asset, read once before the listener binds like the other
two.
- A display page is now controlled by a service worker, which is a real operational surface: an
operator debugging a screen must know a worker is in front of it. It is `no-cache`, claims clients
immediately, and caches only code, so the ways it can go wrong are bounded.
- Offline *content* is still absent, and that is the honest state of this foundation rather than an
oversight.
## Alternatives
- **Cache the descriptor so a display can mount offline.** Rejected outright. It is a permission
decision and a device identity; replaying one is a revoked screen still showing content.
- **Cache-first for speed.** Rejected: it is how an unattended screen ends up serving a build from
months ago with no way to notice, and fixing it means visiting the screen.
- **Precache everything at install.** Rejected: the worker would have to know which module a screen
runs and at which version — facts only the descriptor carries — and an install would fail whenever
an asset a future version needs did not exist yet.
- **Cache at the whole origin with `Service-Worker-Allowed`.** Rejected: the Dashboard gains a worker
it has no use for, for no benefit.
- **Use `navigator.onLine` alone.** Rejected: it reports `true` behind a captive portal and a dead
uplink, so a screen would insist it was fine while showing nothing.
- **Store artifacts in IndexedDB and import them from a blob URL.** Rejected: it costs a host its
`script-src` CSP, exactly as ADR 0024 rejected for the loader, and reimplements what a cache
already does correctly.
## Deferred
Module-owned offline content and media persistence, background sync, periodic background update,
push, a Dashboard service worker, offline pairing, precaching a display's artifact before its first
visit, and any cached response that carries identity or authorization.
+1
View File
@@ -51,3 +51,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0040: Module stylesheets, and every browser artifact gets an isolated root](0040-module-stylesheets-and-artifact-isolation.md) — Accepted
- [0041: One module may contribute a browser renderer to another module's capability](0041-module-browser-contributions.md) — Accepted
- [0042: Explicit, bounded streaming request bodies](0042-bounded-streaming-request-bodies.md) — Accepted
- [0043: The display caches its shell and declared code, and nothing else](0043-display-offline-code-fallback.md) — Accepted
+7 -1
View File
@@ -119,7 +119,13 @@ These rules map platform ownership to filesystem and package boundaries. The exe
receiving only the host-neutral principal and permission hints already in the UI SDK.
- The display browser application is a separate surface entrypoint under `surfaces/shell`; it also
statically imports no module and mounts only the exact display descriptor/artifact supplied by the
API. It contains no Dashboard chrome and does not move display rendering into the surface—the
API. It ships a third fixed asset, a service worker scoped to `/display/`, that caches **only** the
display shell and the declared browser artifacts it actually loaded
([ADR 0043](decisions/0043-display-offline-code-fallback.md)). It never caches the descriptor,
`/api/v1/principal`, any module or platform API response, the realtime stream, a pairing exchange,
or any cross-origin request — so a revoked device stays revoked and cached code can never
manufacture current authorization. Caching is network-first, so a screen cannot be trapped on
obsolete code. It contains no Dashboard chrome and does not move display rendering into the surface—the
module still owns that renderer.
- Separate surfaces do not import each other's internals.
- Builder code composes contracts and surface renderer identifiers; it does not import module or
+26 -3
View File
@@ -746,7 +746,7 @@ eligible, running, and declares `entrypoints.display`, Ekklesia registers exactl
The page and descriptor both require the manifest's display permission through the ordinary Core
authorization evaluator and an authenticated cookie session. A `public` display permission is
rejected in manifest version 6; there is no anonymous exception or device token. Artifact bytes are
rejected; there is no anonymous exception or device token. Artifact bytes are
anonymous for the same reason as primary UI bytes: code alone carries no data authority. Unknown,
primary-only, disabled, and dependency-blocked modules have no display routes and return the normal
sanitized `404`. Enabling, disabling, installing, or changing the manifest takes effect on restart,
@@ -764,8 +764,31 @@ artifact remains compatible with a future standalone host.
The display host fills the viewport but deliberately does **not** invoke the browser Fullscreen API.
It shows fixed loading/failure states and offers manual retry; your module owns only the rendered
feature experience and cleanup inside its container. There is one display per module. Display
inventory/management, named variants, targeting, public/device identity, a standalone host, shared
CSS, Announcements migration, and Companion integration are deferred.
inventory/management, named variants, targeting, public/device identity, a standalone host,
Announcements migration, and Companion integration are deferred.
### Your display survives a network outage — as code, not as content
A display page is controlled by a service worker that caches **the shell and the declared code**: the
page, the host script, your display artifact, and every contributed renderer that display actually
mounted ([ADR 0043](../architecture/decisions/0043-display-offline-code-fallback.md)). Caching is
network-first, so a screen picks up a new build the moment the server returns and can never be
trapped on an obsolete one.
What that means for you, concretely:
- **Your artifact will be served from cache when a fetch fails.** The bytes are identical and the
loader still verifies the artifact's declared id and version, so a fallback can never mount
something the descriptor did not name.
- **Your display cannot render while the network is down.** Mounting requires the descriptor, the
descriptor is authorization-bearing, and it is never cached — so a device whose access was revoked
stays revoked, and cached code can never manufacture authorization it no longer has. The screen
shows Ekklesia's own quiet offline indicator and resolves again by itself when connectivity
returns.
- **Nothing of yours is cached but code.** Your module's API responses, your realtime stream, and
every signed object-delivery URL are deliberately never stored. If you want a screen to keep
showing content through an outage, persisting that content is *your module's* job — this is the
foundation it would build on, not a substitute for it.
### What a module UI will never own