# 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/`). - 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.