# 0036: A display device is its own principal, paired by an administrator - Status: Accepted - Date: 2026-08-24 - Extends: [0013](0013-core-authorization-and-owner-bootstrap.md), [0032](0032-native-module-realtime-transport.md), [0033](0033-display-kiosk-host-contract.md), [0034](0034-host-owned-browser-authentication.md), [0035](0035-initial-platform-owner-bootstrap.md) ## Context [ADR 0033](0033-display-kiosk-host-contract.md) gave Ekklesia a chrome-free display host, and [ADR 0034](0034-host-owned-browser-authentication.md) gave it a login page. Put together, the only way to light up a television in a foyer was to sign a human being into it and leave that session running on a screen anyone can walk up to. That is not a display model. It means a person's credentials live on a wall, a person's session is what a screen depends on, and revoking that person — or their session simply expiring — takes the signage down. The alternatives are worse: a public unauthenticated display, a hardcoded token, or a credential in a URL. A screen is not a person, and pretending otherwise is the problem. ## Decision Ekklesia recognizes **three** principal classes: anonymous, authenticated human, and managed device. A device is a first-class principal with its own identity, its own credential, and its own explicit permissions. ```text screen opens /display/announcements → no identity → shows a short code administrator types the code in the Dashboard, names the screen, approves screen collects a credential → device principal → display and realtime work administrator revokes → the screen loses access and shows a code again ``` ### Devices are not users There is no device row in the identity tables and no foreign key from a device to a user. A device has no email, no password, and no human session. This matters beyond tidiness. Every "is a person signed in?" question in the platform is answered by `isAuthenticatedPrincipal`, and a device that satisfied it would inherit whatever a signed-in human may do. So a device satisfies the `authenticated` baseline **never** — not with one permission, not with ten. The only thing that makes a device allowed is an explicit permission granted to that exact device. ### Device permissions are their own table, and the evaluator is still the only judge `ekklesia_core_grant.user_id` is a non-null foreign key into the user table. Generalizing it into a polymorphic subject would rewrite the platform's central authorization record — its relationship-derived grants, its audit semantics, its indexes — to serve one narrow case. Device permissions therefore live in `ekklesia_core_device_permission`. That is a schema decision, **not** a second decision point. `createAuthorizationEvaluator` gained one branch, so a device is allowed or denied by the same function, with the same deny-by-default, as anyone else. There is no `if (device) bypass`, no owner check a device could reach — a device is never a platform owner — and no baseline that stands in for a grant. A deployment that composed no device support at all simply denies every device. ### The pairing code is not the credential Three secrets are involved and conflating any two of them would break the model: | value | who sees it | what it does | | --- | --- | --- | | pairing code (6 digits) | **anyone who can see the screen** | identifies the request to an administrator | | claim secret (256 bits) | only the requesting browser, HttpOnly | proves "I am the screen that asked" | | device credential (256 bits) | only the paired browser, HttpOnly | is the device's identity | The code is printed across a television in a public hallway. It is therefore treated as public: it is stored in the clear, and knowing it is worth nothing on its own. Approving it requires an administrator holding `core.devices.manage`, and **collecting** the resulting credential requires the claim cookie, which no onlooker has. A flow that issued a credential to whoever presented the code would hand a screen's identity to anyone who read it off the wall. Six digits are therefore not carrying the security of this design. They are bounded anyway: ten minutes, single-use, one live request per code enforced by a partial unique index, and an administrator in the loop. ### The permission comes from the display, never from the request A pairing request names only the module whose display the screen is sitting on, and even that is not taken at its word — the module is looked up in the publication registry and the permission is read from its validated display declaration. A browser cannot ask to be granted `core.devices.manage`, another module's permission, or anything else. Approval grants exactly the one permission the host derived, never a bundle. ### State machine `pending → approved → consumed`, plus expiry derived from `expires_at`. Expiry is deliberately *not* a fourth state: nothing has to run for a code to stop working, so there is no sweeper whose failure would leave live codes lying around. Each transition is a conditional `UPDATE` — approval matches only a `pending`, unexpired row, and consumption matches only an `approved` one — so approving twice, approving something expired, and two screens racing to claim one approval all affect zero rows rather than racing. Exactly one credential can ever come out of one code. ### The credential is hashed, and revocation is server-side The device receives an opaque 256-bit credential in an HttpOnly cookie; the server stores only SHA-256 of it. A fast hash is correct here — unlike a password, there is nothing to brute-force in CSPRNG output, and a slow KDF would only add latency to every request a display makes. The raw value exists in exactly two places: the response that sets the cookie, and the device's own cookie jar. It is never logged. Revocation is `revoked_at` on the device row, checked on **every** principal resolution. It is not a request that a remote browser discard anything: a revoked screen keeps its cookie and stops working. ### Cookies Two host-owned cookies, named nothing like Better Auth's: `ekklesia_device_pairing` (short-lived, cleared on use) and `ekklesia_device_session`. Both are `HttpOnly` — module display code runs in this very page and must never be able to read the credential — `SameSite=Strict`, host-only, `Path=/`, and `Secure` whenever the deployment's own base URL is https. Nothing about the credential is ever returned in JSON, put in a query string, or written to `localStorage`. **A human session always wins.** The device cookie is not even read when Better Auth resolves a person, so an administrator who opens a display on their laptop stays themself. ### `/api/v1/principal` stays human-only A device is reported as `anonymous` there, deliberately. That endpoint is what the Dashboard shell uses to decide whether someone is signed in, and its shape — "not anonymous means logged in" — was load-bearing in the shell before this pass. Returning a device from it would have made a television a Dashboard user. The shell's test is now `=== "authenticated"` as a second lock, but the first lock is that the endpoint never says "device" at all. The display host instead reads its viewer from the **descriptor**, which is the display's actual authorization boundary and already performed the check. So displays are descriptor-first while the Dashboard remains principal-first, and neither has to infer an identity by elimination. ### The display page is served to anyone; the descriptor is not An unpaired screen has no identity, so a permission gate on the page would leave a new television with nowhere to render a code. The page is the same inert, module-agnostic bytes for every display — no module id, no data, one already-public script URL — and every authorization decision stays in the descriptor. What this does reveal to an anonymous caller is that a module with a display exists at that path, which any pairing flow must reveal to the screen it is pairing. ### Session lifetime Device credentials last 180 days; human sessions are unchanged. A screen mounted near a ceiling should not need re-pairing on a fortnightly cycle. What makes a long horizon safe is that it is not the security boundary — the server re-checks revocation on every request — and the expiry exists so an abandoned, forgotten screen eventually stops being an identity at all. `lastSeenAt` is updated at most every 15 minutes, in SQL as well as in the resolver, so a display polling realtime does not become a write load. ## Consequences ### Revocation is prompt, but not instantaneous, and this ADR says so A revoked device fails its next HTTP request immediately, and its next realtime *connection* immediately. An **already open** SSE stream is not torn down at the moment of revocation: realtime already forces a reconnect every five minutes (ADR 0032), so the worst case is a bounded five-minute window during which a screen keeps receiving a channel it has just lost. Closing live streams on revocation would mean reaching into the realtime registry by principal, which is a change to a subsystem this pass has no other reason to touch. The bound is documented rather than described as instant. ### Re-pairing creates a new identity A revoked screen that is paired again becomes a **new** device row with a new credential. The old credential is never reactivated, and the revoked row stays in the list as evidence. Reactivating the original would mean a revoked credential could come back, which is exactly what revocation must prevent. ### What was deliberately not built QR codes (a future scanner can encode the same short code — the backend protocol does not change, and a QR must never carry the credential, an admin token, or a session), per-device content targeting, device groups, health monitoring, remote control, and mobile dashboards. The pairing API is not desktop-specific, so a future mobile dashboard host can consume it unchanged. ### Ekklesia Lite has none of this Lite is a minimal trusted-network module runtime: module loading, config, database, storage, HTTP, realtime, and UI hosting. It deliberately provides no human authentication, no permission enforcement, and **no managed device identity or pairing** — a Lite-hosted display simply runs on a trusted local network. Device identity is a full-Ekklesia capability, and nothing here belongs in a future Lite contract. No module code branches on which host is running it. ### Module UI is untouched A module's display receives a principal and its permissions, exactly as before. The principal union gained a `device` variant so a module can render honestly for a screen with nobody behind it, and that is the whole change — there are no pairing controls in `ModuleUiContext`, and a module cannot approve, list, or revoke anything.