205 lines
12 KiB
Markdown
205 lines
12 KiB
Markdown
# 0032: Native module realtime is scoped snapshot state over SSE
|
|
|
|
- Status: Accepted
|
|
- Date: 2026-08-23
|
|
- Extends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
|
|
[0013](0013-module-permission-contract.md),
|
|
[0015](0015-module-http-transport.md),
|
|
[0021](0021-modules-as-hostable-products.md),
|
|
[0022](0022-module-ui-authoring-and-host-contract.md),
|
|
[0028](0028-module-operational-lifecycle.md),
|
|
[0031](0031-direct-object-delivery.md)
|
|
|
|
## Context
|
|
|
|
Ekklesia has a host-neutral module HTTP contract, but no way for a running module to deliver a state
|
|
change to its browser UI without polling or owning a second server. The first real consumer is
|
|
`The_River/Announcements` at commit `9aab486`: its server sends one complete `slideshow:state`
|
|
snapshot on connection and broadcasts the same complete shape after a slide tick, slide-list reload,
|
|
or mode change. The browser sends no application messages back, uses no acknowledgements, and on
|
|
reconnection needs current state rather than a history of missed transitions.
|
|
|
|
That application currently uses Socket.IO, hard-coded LAN origins, and MQTT mode control. Those are
|
|
properties of the old deployment, not requirements for a module contract. Browser commands already
|
|
have the authenticated, CSRF-protected module HTTP path from ADR 0015. MQTT is a legacy external
|
|
control integration that will be removed; it is not an Ekklesia internal transport.
|
|
|
|
The existing Node adapter could be extended with a WebSocket server and upgrade handling, but the API
|
|
does not have any of that wiring today. Its public module vocabulary is Fetch `Request`/`Response`,
|
|
and it already preserves streaming response bodies. Server-Sent Events fit that boundary without a
|
|
new bidirectional protocol, dependency, upgrade/authentication path, reconnect implementation, or
|
|
shutdown stack.
|
|
|
|
## Decision
|
|
|
|
### The public contract names state, not a network protocol
|
|
|
|
A module declares a small number of local realtime channels during `register`, each protected by one
|
|
permission its own manifest declares:
|
|
|
|
```ts
|
|
context.realtime.channel({
|
|
channel: "state",
|
|
permission: "announcements.slideshow.view",
|
|
});
|
|
```
|
|
|
|
Its `start` hook, running work, and HTTP handlers receive the same module-scoped publisher:
|
|
|
|
```ts
|
|
context.realtime.publish("state", slideshowState);
|
|
```
|
|
|
|
The module does not pass a module id, origin, URL, transport, credential, or permission when it
|
|
publishes. It cannot name another module's channel through this facade. `channel` is one validated
|
|
lowercase kebab-case identifier, not a hierarchical topic: there are no slashes, wildcards, global
|
|
namespace, arbitrary cross-module subscription, or broker semantics.
|
|
|
|
Registration remains declarative and I/O-free. Publication is a live operation and is therefore
|
|
absent from registration, exactly like database and storage access. HTTP handlers receive the
|
|
publisher because an ordinary authenticated command frequently changes the state that should be
|
|
shown immediately; modules must not capture a late-bound lifecycle context to achieve that.
|
|
`stop` receives the same context shape for lifecycle consistency, but its publisher has already been
|
|
made inert: stopped code cannot emit new live state.
|
|
|
|
The first Ekklesia host implementation is an authenticated `GET` SSE stream. SSE is private host
|
|
machinery: neither the server module contract nor `ModuleUiHostContext` names SSE, EventSource,
|
|
WebSocket, or Socket.IO. A standalone host may implement the exact contracts using another transport
|
|
without changing a module.
|
|
|
|
WebSocket is rejected for this slice. The real consumer has no persistent client-to-server messages,
|
|
while commands already fit normal module HTTP. Choosing WebSocket would add an upgrade-specific auth
|
|
path, ping/pong, reconnect wrapper, dependency, server wiring, and shutdown behavior without buying a
|
|
required capability. It can be reconsidered if a real module demonstrates genuine persistent
|
|
bidirectional traffic; that would be a new transport decision, not an invisible change here.
|
|
|
|
### Snapshot first, then latest-state updates
|
|
|
|
Every declared channel must publish one authoritative state value before its module's `start` hook
|
|
completes. If it does not, startup fails and the existing reverse-order rollback runs. The API is not
|
|
bound until every eligible module starts, so every reachable channel necessarily has a snapshot.
|
|
|
|
Core validates and serializes a publication once, assigns the channel's next monotonically increasing
|
|
process-local sequence, and stores that exact serialized value as its latest snapshot. Subscription
|
|
atomically registers the consumer and captures that snapshot, eliminating a separate `GET state` then
|
|
`subscribe` race. A new connection always receives `snapshot` first and then later `update` values.
|
|
|
|
The sequence detects ordering, duplicates, and gaps; it is not a durable cursor. It may reset when the
|
|
process restarts. The transport deliberately does not promise replay, history, exactly-once delivery,
|
|
or recovery through `Last-Event-ID`. A reconnect is a new authorized subscription and receives the
|
|
fresh latest snapshot, followed by future updates. The module remains the semantic authority and
|
|
reconstructs its state from its own persistence/runtime after restart; Core's in-memory copy is only
|
|
ephemeral transport state.
|
|
|
|
### JSON and resource bounds
|
|
|
|
Payloads are JSON-compatible data only. Core rejects functions, symbols, bigints, cycles, non-finite
|
|
numbers, unsupported object values, and anything whose UTF-8 serialized representation exceeds the
|
|
fixed platform ceiling. The byte budget is consumed while traversing strings, keys, containers, and
|
|
values, before building or stringifying an arbitrarily large clone. The first ceiling is 64 KiB per
|
|
publication: far beyond an Announcements state document, while making it unambiguous that media bytes
|
|
belong in object storage.
|
|
|
|
The channel count, subscribers per channel, and per-client pending work are all finite. Publishing is
|
|
synchronous and never awaits a browser. A consumer receives publications in increasing sequence while
|
|
it keeps up; if it is slow, it retains at most the newest pending state and intermediate updates are
|
|
coalesced. Sequence gaps make that behavior visible. This is correct for authoritative state and
|
|
prevents one display from blocking a module or creating an unbounded queue.
|
|
|
|
### Browser host contract
|
|
|
|
`ModuleUiHostContext` exposes a transport-neutral, module-scoped subscription method. Module UI names
|
|
only its local channel and receives snapshot/update/status callbacks plus an idempotent close handle.
|
|
The browser does not supply a module id, permission, origin, URL, cookie, or bearer token.
|
|
|
|
Ekklesia's server adds a root-relative realtime base to the existing module UI mount descriptor, just
|
|
as it already supplies the module HTTP base. The shell strictly validates that root-relative value,
|
|
resolves it against the configured API origin, appends only the validated local channel, and opens
|
|
native `EventSource` with credentials enabled. A standalone host supplies its own root and transport
|
|
adapter behind the same browser contract.
|
|
|
|
The browser reports only stable transport-neutral states (`connecting`, `connected`, `reconnecting`,
|
|
`closed`) and fixed safe errors. A recoverable EventSource transport error reports `reconnecting` and
|
|
uses the browser's native retry; a terminal `CLOSED` state removes every listener and reports
|
|
`closed` instead of promising a retry that will never occur. It does not expose EventSource objects,
|
|
raw server responses, socket exceptions, or URLs to module UI. This is a portability and correctness
|
|
boundary, not a sandbox: installed UI is trusted same-realm JavaScript and could use browser globals
|
|
directly. Server-side permission enforcement remains the security authority.
|
|
|
|
### Authentication and authorization
|
|
|
|
The SSE request uses the existing session cookie. Ekklesia evaluates the normal principal and the
|
|
channel's registered permission before allocating a subscriber, using the same permission registry
|
|
and evaluator as module HTTP. There is no query-string token and no second realtime auth system.
|
|
Unknown, disabled, blocked, stopped, or undeclared channels have no mounted route.
|
|
|
|
Authorization is checked when a stream is established. Ekklesia closes every stream after five
|
|
minutes so EventSource reconnects and authorization is re-evaluated; changing a grant does not
|
|
otherwise reach backward into an already-established connection. A deliberate public
|
|
display/device principal remains deferred. This transport is proven only with existing authenticated
|
|
principals and permission checks, and no channel becomes public as a shortcut.
|
|
|
|
### Lifecycle and cleanup
|
|
|
|
Only the exact eligible module set that successfully starts publishes channels. Disablement and
|
|
dependency blocking therefore remove realtime the same structural way they remove HTTP and UI: the
|
|
module is never imported or mounted. There is no hot unload; lifecycle changes remain
|
|
restart-required.
|
|
|
|
Before normal stop or rollback invokes a module's stop hook, Core deactivates that module's publisher.
|
|
Deactivation happens per module in reverse dependency order: a dependency remains operational while
|
|
its dependents finish stopping, but a stale captured publisher fails with a fixed runtime error once
|
|
its own stop begins. Browser cancellation, transport failure, module stop, startup rollback, server
|
|
shutdown, and connection-age expiry all remove subscriptions idempotently.
|
|
|
|
Server shutdown closes realtime streams before draining the HTTP listener. Otherwise a healthy
|
|
long-lived stream would keep the drain open until its deadline and turn ordinary shutdown into a
|
|
forced failure. Publishers remain active for finite requests already in flight; module stop hooks run
|
|
only after the listener has drained, and Core then deactivates each publisher immediately before its
|
|
own hook. This preserves both a command's publish-after-commit path and the guarantee that no ordinary
|
|
request handler runs concurrently with stop.
|
|
|
|
### Proxy and keepalive behavior
|
|
|
|
Ekklesia sends `text/event-stream`, `Cache-Control: no-store`, disables common reverse-proxy response
|
|
buffering where supported, and emits host-owned comment keepalives while a stream is idle. Keepalives
|
|
do not enter module code, consume a module sequence, or mutate state. Deployments and reverse proxies
|
|
must pass streaming responses incrementally and allow the configured idle interval; there is no
|
|
Caddy- or Cloudflare-specific API in the contract.
|
|
|
|
### Object delivery is unchanged
|
|
|
|
Realtime carries small state, logical identifiers, metadata, and — when a module intentionally
|
|
includes one — an opaque delivery URL. It never carries image, video, or audio bytes. Core transports
|
|
the module's serialized payload unchanged; it does not inspect object keys or call `createReadUrl` for
|
|
each publication, subscriber, reconnect, or keepalive.
|
|
|
|
That preserves ADR 0031's byte-stable/quantized URLs and browser cache keys. A module deliberately
|
|
refreshes an expiring URL by creating it once and publishing new state. Realtime never re-signs an
|
|
unchanged URL behind the module's back.
|
|
|
|
### Realtime is not Companion's global bus
|
|
|
|
A future Companion module may observe deliberately exported module capabilities and invoke explicit
|
|
module actions, and may bridge supported external protocols. It does not gain privileged access to
|
|
every module's browser stream. Browser realtime and external automation/control are separate
|
|
concerns; this contract has no cross-module subscription seam to repurpose.
|
|
|
|
## Consequences
|
|
|
|
The first real state consumer gets immediate useful state, ordered live updates, automatic browser
|
|
reconnect, permission enforcement, bounded memory, and deterministic cleanup without owning a port or
|
|
transport server. Modules with no realtime declarations behave exactly as before.
|
|
|
|
The contract deliberately loses intermediate states for a slow or disconnected client. Modules must
|
|
publish authoritative state, not use this channel as an audit log, work queue, payment ledger, or sole
|
|
record of a domain transition. A future event-oriented or durable facility would be a separate design.
|
|
|
|
SSE is one-way. Browser commands remain ordinary authenticated module HTTP, retaining its existing
|
|
body limits, trusted-origin checks, and request/response semantics. A demonstrated bidirectional need
|
|
may justify WebSocket later, without renaming the module-facing API.
|
|
|
|
This slice does not add the Announcements module, MQTT, Companion, a kiosk/display host, a public or
|
|
device principal, a standalone host, hot lifecycle changes, multi-node fanout, durable history, or a
|
|
message broker.
|