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]>
9.6 KiB
0043: The display caches its shell and declared code, and nothing else
- Status: Accepted
- Date: 2026-08-27
- Supersedes: none
- Related: ADR 0025, ADR 0031, ADR 0032, ADR 0033, ADR 0036, ADR 0041
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.onLinealone. Rejected: it reportstruebehind 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-srcCSP, 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.