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:
co-authored by
Claude Opus 5
parent
e36485bf99
commit
ca3f37d5f9
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user