52 KiB
Platform architecture
Ekklesia is a long-lived platform from which churches can compose many applications and user experiences. It begins as a modular monolith so contracts can stabilize before distribution is introduced.
Trust boundary. Installed modules are trusted compiled code, not sandboxed plugins: a module runs arbitrary JavaScript in the API process and shares its PostgreSQL role. The layering described below is a correctness and ownership boundary, not hostile-code isolation. A module that ships its own UI (ADR 0021) is exactly as trusted as one that does not. See Security model and deployment trust boundaries.
Dependency model
The arrows below mean "depends on." Presentation and composition depend on stable lower-level contracts; infrastructure never learns individual feature names.
Surface Builder configuration -> Surfaces -> public capability contracts
^
|
HTTP/API and client contracts
|
Feature Modules -> Module SDK / platform contracts <- Core implementations
The four ownership layers are:
- Core orchestrates generic platform infrastructure: startup, identity, permissions, configuration, database connectivity, storage, events, jobs, module discovery, and lifecycle.
- Modules own domain behavior, data schemas, resources, actions, routes, events, permissions,
jobs — and, per ADR 0021, their own UI, authored
in React against
@ekklesia/module-ui-sdk(ADR 0022). A module is an independently versioned product, not only a backend capability. It does not own the containing application shell or any platform policy. - Surfaces own the presentation concerns a module does not: the containing application shell, layout, navigation, accessibility, interaction, branding, and client state — including the shell that hosts a module's own UI.
- Surface Builder stores validated declarative composition. It selects capabilities and surface-owned renderers but contains neither executable code nor module business logic.
A module owns its own product experience. Calendar data, actions, permissions, and its own
management UI live in modules/calendar; the dashboard or public-website shell that hosts it —
navigation, theme, layout, and platform chrome — lives in a surface. The module supplies the screen
for its feature; the surface supplies the application around it.
Modules are flat peers. video, livestream, announcements, and calendar are independent
first-class modules with no parent/child relationship. A Dashboard may group them into folders such
as Media or Sunday Production, but those groups are presentation configuration an administrator
can reorganize, never module ownership and never a dependency. Genuine module and capability
dependencies remain the only way one module may require another.
Stable seams
- Modules use public SDK ports and injected Core services, never undocumented Core internals.
- Modules do not import another module's schema or private code. Integrations use public service contracts, capabilities, events, or APIs.
- Surfaces consume API/client and capability contracts, never feature-module packages.
- Core discovers feature modules generically. It contains no feature-specific imports or conditional branches.
- Deployable apps are thin composition roots and do not become domain-logic owners.
- Generated bootstrap artifacts may connect discovered entry points at the application edge; they do not become manually maintained Core registries.
The single-module standalone host (architectural target, not built)
Every module-facing contract in this platform is written so that full Ekklesia is not the only
thing that can satisfy it. The intended second implementation is a reusable single-module
standalone host — conceptually @ekklesia/standalone-host — which loads exactly one module and
supplies the same host contracts Ekklesia does: configuration, secrets, object storage, HTTP, module
lifecycle, and realtime. Ekklesia now also has a dedicated display surface, but the reusable
single-module standalone host remains later.
No such package exists yet, and this pass does not create one. It is recorded here because it is
what several contract decisions are for, and because a change that quietly makes it unimplementable
is a change worth catching. The test is simple: a module must never be able to ask which host is
running it. There is deliberately no runningInsideEkklesia, no standaloneMode, and no value a
module could branch on — a module names logical things (a bucket id, a configuration key, a route)
and the host resolves them.
Object storage is the current worked example. StorageProvider is the seam: everything above it —
the scoped facade, binding resolution, delivery URLs and their bounds, lifecycle eligibility — is
host-neutral, and a standalone host implements one interface to get all of it, including direct
browser delivery (ADR 0031).
Realtime is the second worked example. A module declares local state channels and publishes opaque JSON through scoped contracts; its browser UI subscribes through another scoped contract. Neither side names SSE, EventSource, a platform URL, an origin, a cookie, or a module namespace. Ekklesia's host adapter uses permission-gated SSE, while a standalone host may choose another concrete transport without changing the module. See ADR 0032.
Storage servers are shared infrastructure, never per-module
- A module hosted under Ekklesia uses Ekklesia's storage service, through its own bindings.
- A module under a standalone host uses whatever that host was given — a bundled service, or one shared with other deployments.
- A module runtime never starts a storage service itself, and nothing in the module contract could express doing so.
Several standalone applications may share one storage deployment; that is exactly why physical bucket names carry a configurable installation prefix alongside the module and bucket ids. Tying one storage server to one module would multiply operational surface for a church running a handful of small applications, and would make the shared-service deployment — the normal one — the exception.
Security invariants
- Authorization is enforced server-side.
- Secrets are isolated from public configuration and logs.
- Authentication tokens, private form content, prayer requests, and pastoral information are not logged.
- Sensitive actions are designed for auditability without copying sensitive content into audit records.
- Private objects use signed access and namespaced storage paths.
- Every network service starts from least-privilege and local-only development defaults.
Current implementation status
Implemented now: the Phase 1 repository and infrastructure foundation plus public module contracts, static versioned manifests, independent capability versions and dependency ranges, automatic filesystem discovery, compiled server loading, runtime-definition verification, staged capability registration, dependency-ordered startup and reverse shutdown, rollback, and a backend-only module template. Optional compiled schema entrypoints now contribute validated, namespaced module-owned Drizzle tables to a deterministic aggregate without connecting to a database. Optional module-owned Drizzle migration directories now produce an atomic dependency-ordered offline inventory with validated metadata, explicit destructive-change declarations, and exact SHA-256 checksums. Core can now compare that inventory with its fixed PostgreSQL history table, produce a read-only plan, and apply the complete pending batch under a session advisory lock and one transaction. Destructive migrations require an exact externally supplied approval bound to module, identity, and checksum. Hermetic integration coverage now exercises those contracts through the production adapter against the pinned PostgreSQL container, including real DDL rollback and advisory-lock concurrency. The Core-owned Doctor composes those existing services into a deterministic terminal readiness report: static module, schema, and migration validation always run when their prerequisites succeed, while PostgreSQL history planning runs only when configured. Schema inspection evaluates compiled schema definition entrypoints, which the module contract requires to be side-effect-free; Doctor never imports or starts module server entrypoints. It does not apply migrations or modify PostgreSQL, and it honestly skips object storage because no production Core storage service exists yet.
Phase 4A added the Core identity foundation. Core owns a Better Auth-backed identity schema
(ekklesia_core_user, ekklesia_core_session, ekklesia_core_account,
ekklesia_core_verification) reachable only through Ekklesia-owned contracts and an explicit
Core-owned Better Auth construction boundary; modules never import Better Auth or receive a raw
database client. That schema ships as a first-class Core-owned migration source that reuses the
exact migration format, checksum rule, planner, and transactional runner Phase 3 already
established, ordered before module migrations in the same aggregate. See
ADR 0010.
Phase 4B adds identity HTTP transport: a minimal Hono-based apps/api composition root mounts
Better Auth at /api/auth/* and exposes one Ekklesia-owned route, GET /api/v1/principal, behind
Core's own Fetch-compatible identity runtime (@ekklesia/core/identity) — Core still does not
depend on Hono. Public email/password registration is the intended eventual product policy — every
account receives identical lowest-privilege baseline status, and signup order never grants
elevation — but is disabled by default as of Phase 5D pass 1, below. See
ADR 0011 and
the API guide, and
the identity development guide for the underlying schema and
contracts. Email/password sign-in, principal resolution, and sign-out are operational. Public
registration is available only when explicitly enabled for development/test; production rejects it
until verified enrollment and durable shared abuse controls exist.
Phase 4C is complete: Core's authorization foundation — a schema, evaluator, and store for
platform owners, explicit grants, and relationship-derived grants, plus an immutable permission
registry — and an explicit, single-use, auditable platform-owner bootstrap command, plus reusable
Hono enforcement and an /api/v1/grants HTTP API in apps/api. See
ADR 0013 and
the authorization guide.
Phase 5A wired module-declared permissions into that same authorization system — a version-3
manifest, one composed platform permission registry spanning Core's own declarations and every
installed module's, enforced through the identical middleware Core's own routes use. Phase 5B let a
module mount its own framework-neutral HTTP routes in production, under
/api/v1/modules/<module-id>, enforced and response-sanitized exactly like Core's own routes. Phase
5C added typed module database access (context.services.database) for a module's lifecycle hooks
and authorized HTTP handlers, over the platform's one existing PostgreSQL pool.
See ADR 0014,
ADR 0015,
ADR 0016, and the repository root
README for the complete, current status of every phase.
Phase 5D is complete. Five passes hardened the platform against an independent security audit of the Phase 5C checkpoint, and an independent whole-repository re-audit closed the phase:
- the public request perimeter — a server-owned client-address resolution boundary for the authentication rate limiter, public sign-up disabled by default behind an explicit typed opt-in, an enforced request-body ceiling on every public route (including a module route's own declared contract), and finite Node header/request timeouts;
- identity session resolution, and a mandatory certificate- and hostname-verified PostgreSQL
transport with no plaintext or
rejectUnauthorized: falseexception; - durable authorization history that survives user deletion, a one-time owner bootstrap that deleting a user cannot reopen, and bounded, unambiguous explicit-grant management;
- module route-overlap rejection at registration, finite startup rollback and shutdown deadlines, and hardened migration command timeouts and SQL execution;
- deployment and supply-chain closure plus the final re-audit.
See ADR 0017, ADR 0018, ADR 0019, and ADR 0020.
These guarantees hold under a trusted-contributor, not-yet-deployed threat model. The remaining limitations are operational rather than architectural: CI is a trusted-code-only boundary whose runner topology and job-token authority are unverified, and Ekklesia is not deployed, so the origin, proxy, and throttling properties a public launch requires are unproven. Neither set of requirements blocks Phase 6. Read Security model and deployment trust boundaries for what each boundary does and does not protect against, and the repository root README for the current status of every phase.
Phase 6A established the module product contract. ADR 0021 replaces the backend-only module model: a module is an independently versioned product that may own its own UI, hostable inside Ekklesia and — later, from the same implementation — as a standalone application. Core keeps the host shell and every platform policy, the module graph stays flat, and Dashboard grouping stays presentation configuration.
What that pass implemented is the static contract only: manifestVersion: 4 adds one optional
entrypoints.ui field, validated during discovery exactly like the server and schema entrypoints —
package-relative, inside the package, compiled ESM. Discovery remains inert; nothing imports, mounts,
or renders that entrypoint.
Phase 6B pass 1 defines the module UI authoring model and host contract.
ADR 0022 selects React and TypeScript as
the supported way to author module UI, and defines what a compiled entrypoints.ui artifact exports
and what a host provides it. React is the authoring model but deliberately not the contract: an
artifact default-exports defineModuleUi({ id, version, mount }), and mount(container, context)
takes a DOM element and returns an unmount function, so a module's framework and a host's stay
independent.
The contract lives in @ekklesia/module-ui-sdk, a browser-safe shared package with no runtime
dependencies, consumed by module authors and hosts alike. Its host context was five members at that
checkpoint and is six today (ADR 0041) —
moduleId, api, principal, permissions, and realtime. Module UI reaches its own backend through
api.request(path) with a path relative to the module's own API root, never by constructing
Ekklesia's /api/v1/modules/<module-id> prefix, which is what keeps a standalone host possible.
Client-side permission hints are presentation only; server-side enforcement remains the sole
authority. Module UI is trusted code in the host's browser realm — there is no sandbox, matching the
backend trust model rather than overstating it.
Core is untouched by this: discovery still validates the entrypoints.ui path and never evaluates
it, and hosts perform their own dynamic import.
Phase 6B pass 2 makes that contract buildable.
ADR 0023 selects esbuild, compiles a module's
src/ui/index.tsx into the browser ESM artifact its manifest declares, and puts every bundler
option in one tooling package so module authors write no build configuration. React is bundled
rather than externalized, keeping mount() the ABI and a module artifact hostable by any host;
styling was inline style objects only at that checkpoint and is real compiled CSS today (ADR 0040),
and a stylesheet import then failed the build rather than emitting an
asset nothing declares. Building remains an authoring step — Core never builds, and discovery still
only validates the artifact a module shipped. At that checkpoint the Dashboard shell and UI runtime
were still future work; later Phase 6 passes supplied them. A design system, stylesheet loading
contract, standalone host, and every real feature module remain future work.
Phase 6B pass 3 makes the browser boundary real and adds the generic host loader.
ADR 0024 removes the UI SDK's last
dependency — one type-only import of the contracts barrel that pulled Drizzle's types, and Node's
globals, into browser programs — so the SDK now depends on nothing and defines its own plain
ModuleUiPrincipal. @ekklesia/module-ui-host is the loader both Ekklesia and a future standalone
host will use: dynamic import from a browser URL, identity validated against the manifest before
mounting, a host-owned container the loader never mutates, and an idempotent unmount. Modules may not
depend on it.
Phase 6C pass 1 publishes a validated module UI artifact at a browser URL.
ADR 0025 closes the gap the previous pass
named: Core resolves entrypoints.ui to a server filesystem path, and a browser needs a URL.
Core owns the policy and apps/api owns the transport. createModuleUiPublicationRegistry
(core/src/modules/ui-publication.ts) answers "may this be published, and which exact file is it?"
from the discovered, started module inventory — publishable means the module survived discovery
and declares entrypoints.ui, no new lifecycle state and no installer. apps/api serves
GET /api/v1/module-ui/<module-id>/<version>, where both segments are lookup keys, not path
components: an exact match yields the canonical artifact path discovery already validated, and no
request value is ever composed into a filesystem path. There is no wildcard segment, so traversal,
sibling chunks, package-root files, and source maps are unreachable by construction — an installed
module directory never becomes a static file root. The prefix is a sibling of /api/v1/modules/
rather than a child because ui is a legal module route segment and a module could otherwise collide
with the platform.
The route requires no authentication: the bundle confers no authority, every module API stays behind
requirePermission, and a cookie-gated route would break cross-origin import(), which sends none.
The disclosure that buys — a caller who knows a module id and its exact version can download that
bundle — is stated rather than papered over, and the three failure modes are made indistinguishable
so the route is not an enumeration oracle. Caching is no-cache with a strong ETag, not
immutable, because without an installer a version can be rebuilt with different bytes. The server
reads and writes bytes; it never imports, evaluates, or transforms the artifact.
Phase 6C pass 2 builds the browser host that consumes it.
ADR 0026 adds surfaces/shell
(@ekklesia/shell), the repository's first surface: a small React application, built to static files
by its own esbuild script, that discovers installed module UIs and mounts them.
The missing piece was discovery. GET /api/v1/module-ui — the collection root of the same prefix the
artifact route already owns — returns, per publishable module, its id and version, the server-produced
artifact URL, the server-produced roots of the module's own HTTP and realtime APIs, and the subset of
that module's declared permissions the caller actually holds. Nothing else: no filesystem paths, and
no display or navigation metadata, since ADR 0021 keeps Dashboard grouping an administrator's
presentation configuration. The module set is the publication registry, never a rescan, so the
inventory and artifact route cannot disagree. It requires core.modules.ui.read (baseline
authenticated) because it is precisely the enumeration ADR 0025 withheld from the anonymous
artifact route. ADR 0032 later extended ADR 0026's original descriptor with the realtime root.
All three URLs being server-produced is load-bearing. moduleUiArtifactUrl, moduleApiBaseUrl, and
moduleRealtimeBaseUrl are each the single construction of their URL shape, so the browser consumes
them rather than rebuilding a platform module prefix, and none is pinned by having a second author.
The shell builds a ModuleUiHostContext entirely from server-derived data, resolves a module's
relative API calls onto its own API root (refusing anything that escapes it), and hands the artifact
URL to @ekklesia/module-ui-host. It statically imports no module: one frame per selection defeats
the stale-load race, the host owns the container and every loading and error state, and the bundle is
checked to contain exactly one dynamic import with no module identity baked in. Permission hints come
from Core's own evaluator and stay presentation-only — server authorization is proven unchanged by a
test in which a UI holding no grant calls its API anyway and is refused.
Phase 6D pass 1 adds the Dashboard navigation model, and fixes the rule it could have broken. ADR 0027 establishes that a Dashboard group is presentation configuration and never module structure. A group is a label on a rendering bucket: no manifest, version, artifact, API, permission, or lifecycle, and nothing depends on it. Placing two modules in one folder creates no relationship between them; Livestream depends on Video because it calls Video's functionality orders startup, while Livestream is shown under Media orders nothing. Modules stay flat, with no parent, child, owner, or group-derived dependency.
No manifest change was needed: manifest.name has been required since version 1 and already is the
module's product identity, so the shell renders it instead of the module id. suggestedGroup was
deferred rather than shipped — it would force a hard manifest-version cutover for a hint, and its
only correct semantics needs initialization logic that risks a suggestion overwriting an
administrator's decision.
Core owns the persistence: two ekklesia_core_dashboard_* tables (migration 0003) holding groups
and placements, platform-global because no organization model exists yet. group_id is
on delete set null, so deleting a group ungroups its modules and a dangling reference is
impossible; module_id carries no foreign key, because modules are packages rather than rows, so a
placement for an uninstalled module is retained and restores its position on reinstall.
resolveDashboardNavigation is a pure function of configuration plus the mountable set: an unplaced
module is ungrouped and visible with no initialization write, an absent module renders nothing,
empty groups are kept, and ordering is total with the identifier as final tie-break.
GET /api/v1/dashboard/navigation returns the resolved view, layered on top of the host-neutral
module UI inventory rather than merged into it — it embeds each generic descriptor untouched, so a
standalone host consuming the inventory never inherits Dashboard semantics. Reading requires the same
permission the inventory does; organizing requires the new platform permission
core.dashboard.navigation.manage through a narrow, structured, idempotent mutation contract. The
shell renders that arrangement and computes none of it.
Still not the finished Dashboard. No editor or drag and drop, no nested folders, no icons, no Dashboard Home or widgets, no design system, and no client routing. A production CSP and deployment for the built shell, general asset publication, and the standalone host remain future work. Phase 7B pass 1 later supplies the host-owned sign-in flow described below. Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model are all unchanged.
Target architecture not yet implemented includes production approval-provider composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource and action contracts, a Dashboard, a Builder, and reference feature modules; none of these are scheduled by this document.
Phase 7A pass 1 adds the platform's operational module lifecycle
(ADR 0028). Core now has two clearly separated
axes: configuration (enabled/disabled, persistent, administrator-owned, stored in one Core
table) and runtime (running/disabled/blocked, observed per boot). resolveModuleEligibility
is a pure function of the discovered inventory plus that configuration: a module with no stored
record is enabled by default, an explicit decision always wins, a disabled module transitively blocks
every module that requires it (module and capability edges alike, with a structured reason naming
the provider), and an optional dependency never blocks anything. startApi starts only the eligible
set and builds the UI publication registry from that same list, so routes, artifacts, browser
inventory, and Dashboard navigation all follow one authoritative set rather than a filter downstream.
Startup remains all-or-nothing across the eligible set — ADR 0015 and ADR 0020's rollback guarantee
is unchanged, and there is deliberately no failed runtime state, because no live process could
report one. Enablement is what makes a broken module recoverable, with pnpm modules:disable as the
out-of-band path. Changes take effect at the next start, and the read model reports configuration,
runtime, and restartRequired separately rather than claiming a module stopped when it has not.
Migration discovery still spans the deployed inventory regardless of enablement, and permission
composition still spans the whole discovered set. running means a module's start hook completed
and nothing more; there is no runtime health-probe contract. Modules remain trusted compiled code —
disabling one is an operational control, never a sandbox. No installer exists.
Phase 7A pass 2 adds the module configuration and secrets contract
(ADR 0029). That pass introduced static
configuration with the manifestVersion: 5 hard cutover; the current strict manifest is version 6
after ADR 0033. A module uses six declaration types (string, integer, boolean, enum, url,
secret) with bounded constraints and no nesting. Static
declaration is what lets the platform validate configuration before deciding whether a module may
start, so an unconfigured module is reported blocked · configuration_missing rather than taking
startup down from inside its own start hook; discovery, pnpm modules:validate, and Doctor all read
it while remaining manifest-only.
Ordinary values are persisted in one Core table, one row per (module_id, key), stored as unvalidated
text until checked against the declaring module's current manifest. Secret values are never
persisted: Ekklesia has no encryption at rest, so a secret key is supplied by the deployment and
mapped into the module's snapshot by the host, under a name the module never sees. A module receives a
frozen context.config on its registration, runtime, and HTTP handler contexts — the same object
identity across all of them — with no environment access, no database reference, and no way to reach
another module's values, which is what keeps the same module implementation runnable under a future
standalone host.
Configuration decides eligibility through ADR 0028's existing resolver rather than a parallel concept:
three new blocked reasons (configuration_missing, configuration_invalid, secret_missing) name
the offending key, and a configuration-blocked module blocks its required dependents exactly like a
disabled one. Changes take effect at the next restart and the API reports restartRequired; nothing
is hot-reloaded. Migration discovery remains independent of configuration validity, exactly as it is
independent of enabled state. Endpoints and credentials for shared infrastructure stay platform
concerns — a module declares which bucket it needs, not an S3 endpoint — leaving room for the object
storage service Core still does not have. There is no settings UI.
Phase 7A pass 3 turns manifest.storage.buckets — inert since manifest version 1 — into a real
host-provided capability (ADR 0030). A module
declares a logical bucket and receives a scoped facade at context.storage offering list, head,
get, put, and delete, with get returning a Web stream so an object never has to be buffered. The
contract names no provider: there is no endpoint, region, credential, or physical bucket reachable
from it, which is what lets the same module run under a future standalone host backed by its own or a
shared S3-compatible service.
The host owns everything else. Core's first provider speaks the S3-compatible protocol through the
AWS SDK against a configured endpoint, with SeaweedFS as the intended deployment target (ADR 0004) but
never as an API name. A logical bucket maps to a physical one deterministically — a configurable
installation prefix, the module and bucket ids joined by a separator that cannot occur inside either,
and a digest over the full identity — so two modules declaring assets are always isolated and one
storage service can host several installations. Bucket creation is idempotent and lazy on first use;
nothing ever deletes a bucket.
Storage is optional: an installation without a provider is complete, and only a module that declares
a bucket is affected — it is reported blocked · storage_unavailable through ADR 0028's existing
resolver, propagating to required dependents. Crucially, eligibility asks whether storage is
configured, never whether it answered: a configured provider that is briefly unreachable stays an
operational error a module retries through, never a lifecycle state, so the platform is no less
resilient than the products it will absorb. Startup performs no storage I/O at all. Credentials are
deployment-supplied, never persisted, never routed through module configuration, and never visible to
modules, module UI, diagnostics, or API responses.
Phase 7A pass 4 takes Ekklesia out of the data path
(ADR 0031). A module asks a logical bucket for a
temporary, browser-usable URL — createReadUrl(key) returning { url, expiresAt } — and the browser
fetches the object from the storage service directly. The contract still names no mechanism: signing,
expiry format, and endpoint stay below the provider seam, so a host with no S3 in it could satisfy
the same method. The URL is opaque to module code and a bearer capability to whoever holds it, so
the module authorizes the request in its own route before asking for one; storage bounds reach, not
identity. Lifetimes are bounded (a minute to six hours, fifteen minutes by default) and out-of-range
requests are refused rather than clamped. Logical keys stay canonical state; URLs are generated on
demand and never persisted.
This is what makes media practical: a Range request against a delivery URL is answered by the
storage service directly, so byte-range seeking needs no proxy in Core. Signing is a local
computation, so a slideshow can build a URL per slide without a single round trip, and repeated calls
inside a window return a byte-identical URL so browser caches and URL-keyed DOM elements hold.
Because an S3-compatible signature covers the request's Host, a URL cannot be signed for one origin
and rewritten to another. The origin a browser uses is therefore a signing input
(S3_DELIVERY_ENDPOINT), defaulting to the provider endpoint — which is why the standard deployment
configures nothing. A reverse proxy in front of storage must pass the original Host through
unchanged; it may terminate TLS, because the scheme is not signed. Streaming through a module's own
route remains available for responses that must be computed or authorized per byte, and signed
write URLs are deliberately not offered.
Phase 7A pass 5 adds native module realtime (ADR 0032). Registration declares a bounded, module-local channel and one permission owned by that module; live runtime and HTTP contexts receive only that module's publisher. Every channel must publish an authoritative initial state before startup completes. Core serializes opaque JSON once, assigns a process-local per-channel sequence, and atomically installs each subscriber with the latest snapshot, eliminating a GET-then-subscribe race. It retains only the latest channel state and one coalescing pending update per client, so a slow browser never blocks module work or creates an unbounded queue. There is no history, replay, broker namespace, or durable state.
Ekklesia's concrete host transport is one-way Server-Sent Events because the demonstrated Announcements consumer sends complete state server-to-browser while browser commands already fit authenticated module HTTP. The channel permission is evaluated through the existing cookie principal and authorization system before allocation; the host closes streams after five minutes so reconnect re-evaluates that authority. Native EventSource owns recoverable reconnects, each of which starts with a fresh snapshot. The wire sends 15-second comment keepalives, disables common proxy buffering, carries no query token or replay id, and never exposes transport objects through the module UI SDK.
Shutdown closes long-lived subscriptions before finite HTTP drain, while leaving publishers usable for requests already in flight. Reverse-order stop then deactivates each module's publisher immediately before that module's own stop hook, so dependencies remain operational while dependents clean up and captured publishers cannot outlive their module. Direct object-delivery URLs remain opaque payload strings and are never re-signed per publication or subscriber. MQTT remains an external-control concern for a future Companion boundary, not an internal module bus.
Phase 7A pass 6 adds a dedicated display role and Ekklesia host
(ADR 0033). Manifest version 6 gives a module one
optional entrypoints.display object containing a compiled artifact and one module-owned permission.
The permission must have an authenticated or restricted baseline: public/device display identity
is still intentionally unsolved. The display artifact must be distinct from every other configured
entrypoint. Primary UI, display UI, both, and neither are all valid module shapes.
Phase 7B pass 2 makes those screens principals in their own right
(ADR 0036). The platform recognizes
anonymous, authenticated human, and managed device. A device is deliberately not a user — no identity
row, no password, and no satisfaction of the authenticated baseline — and its authority is decided
by the one authorization evaluator, from an explicit permission granted to that exact device.
Pairing is administrator-approved: a screen shows a short, public code that identifies its request,
while the credential is issued only to the browser holding a separate HttpOnly claim secret and is
stored only as a digest. Revocation is server-side and checked on every principal resolution;
re-pairing mints a new identity rather than reviving a revoked one. Ekklesia Lite provides module
runtime primitives and deliberately no human authentication, permission enforcement, or managed
device identity, so pairing is a full-Ekklesia concern only.
Phase 7C pass 1 adds outbound email as a host-provided infrastructure service
(ADR 0037), with the same shape object storage
uses: a narrow provider-neutral contract, a MailProvider seam, and one concrete SMTP
implementation. Feature code owns the recipient, subject, and body; the provider owns the host,
port, TLS, credentials, and transport errors, and no provider hostname appears in Core. The sender
is host configuration and a message has no from field, so nothing above the seam can choose an
identity to send as. Security modes are explicit rather than a boolean plus a port, starttls
requires the upgrade, production refuses plaintext, and every timeout is bounded. There is no HTTP
route that sends and no module-facing mail service yet — services are added one at a time when
something real needs one, and nothing does. Email is optional: an installation without SMTP is a
complete deployment, and Better Auth's verification and reset hooks remain unimplemented until
Pass 2.
Phase 7C pass 1B makes host infrastructure configuration a single shared mapping. Every executable —
apps/api, pnpm doctor, and the migration and owner-bootstrap commands — turns its environment
into typed inputs through one loadHostConfiguration(env) in Core, which takes the environment as
an argument and never reads process.env itself, exactly as the module secret source does.
Validation stays with the existing per-domain validators, so each caller keeps its own failure
behaviour: startApi refuses to start on a rejected value, while Doctor reports it as a sanitized
code. This replaced two executables that each read their own subset of the same variables and had
drifted — Doctor never read S3_* or module secrets at all, so it reported a correctly configured
installation as having neither, and reported modules blocked that the runtime was running. A
diagnostic that disagrees with the runtime it diagnoses is worse than no diagnostic.
The two browser roles share the existing defineModuleUi({ id, version, mount }) export and exactly
the same ModuleUiHostContext; role selection comes from separate artifacts, never a mode,
presentation.kind, or host-identity branch. The canonical builder reads only
src/ui/index.tsx and src/display/index.tsx, bundles React into each declared role, rejects CSS
sidecars, and publishes no output until every requested bundle has compiled. Core validates both
paths inertly and builds both publication records only from the dependency-resolved eligible/running
set; it never imports browser code.
Ekklesia keeps its primary Dashboard host unchanged and adds a separate full-viewport application.
For each running display-capable module, the API registers the stable page /display/<module-id>, a
protected descriptor at /api/v1/module-displays/<module-id>, and the exact display artifact at
/api/v1/module-ui/<module-id>/<version>/display. The page and descriptor use normal cookie identity
and the manifest's display permission; the artifact remains anonymous trusted code because bytes
confer no authority. There is no display inventory in this pass. The descriptor supplies the same
module API root, realtime root, principal-derived permission hints, and exact artifact identity the
generic loader already consumes. The surface adds no Dashboard chrome and does not request the
browser Fullscreen API; failures remain bounded behind a fixed state and manual retry.
Production reads the fixed display HTML and script once before binding the listener and serves that frozen snapshot, never a directory. Enablement, dependency blocking, and runtime startup determine whether routes exist, and installed-manifest changes remain restart-based. Realtime and direct signed object URLs are unchanged and flow through the same portable contracts. Named display variants, targeting, display inventory/management, device/public identity, a standalone host, shared CSS, Announcements migration, and Companion integration remain later work.
Phase 7B pass 1 closes the Dashboard's browser authentication loop
(ADR 0034). The surface, not a module, owns
the email/password form, sign-out control, pending/failure states, and session transitions. Bootstrap
stays principal-first: anonymous renders login, authenticated proceeds to protected Dashboard
navigation, and an identity infrastructure failure remains a retryable failure rather than a false
anonymous result. Successful sign-in is followed by a fresh /api/v1/principal resolution before
any module mounts; sign-out is followed by the same resolution. Leaving authenticated state removes
the Dashboard subtree so the generic loader performs the selected module's normal cleanup, including
its realtime subscription teardown.
The browser posts to Better Auth's existing /api/auth/sign-in/email and /api/auth/sign-out
routes with credentials included. apps/api normalizes any body-bearing successful 2xx email
sign-in response to fixed {"ok":true} while preserving its status and the HttpOnly Set-Cookie;
the redundant Better Auth token and user object never reach browser application JavaScript. The
token-bearing native /api/auth/get-session, /api/auth/list-sessions,
/api/auth/update-session, and /api/auth/change-password paths are unavailable behind a sanitized
404; /api/v1/principal is the browser's only supported session read, and password/account
management remains out of scope. The cookie remains host-only and exact trusted origins,
credentialed CORS, and origin/CSRF checks remain unchanged. Login stays at the current Dashboard URL
and accepts no callback or return target, so this pass introduces no redirect sink.
The five-member module UI context is unchanged; modules receive no auth method, credential, cookie,
or session token. Platform-owned bootstrap 401 maps directly to unauthenticated, but module-owned
responses are not host session authority: module 401 and 403 both trigger a deduplicated
principal revalidation, and only an anonymous result resets authentication and unmounts the module.
An authenticated result preserves the response, session, and mounted UI. Display device identity
remains deferred and the display bundle gains no Dashboard login. Public Website routing,
production Dashboard asset hosting/DNS/TLS/proxy/CSP, enrollment and recovery, shared throttling,
and the missing production first-user creation path are also outside this pass.
Phase 8 supplies the five platform prerequisites the first production feature module needs. None of it is Signage, and none of it names Signage: each capability is generic, and each is exercised by something other than a feature module.
ADR 0039 adds the design layer the platform
never had. @ekklesia/design-tokens is one validated, enumerable registry of semantic tokens —
background.raised, text.danger, state.hover-background — where a token's category and
--ekklesia-* identifier are derived from its name and every value must be a bounded literal, so a
registry that constructs is one that can be emitted as CSS without escaping. That validation is
exactly what makes a future theme editor implementable rather than aspirational: the values it would
edit are already labelled, described, categorised, and enumerable. @ekklesia/ui is the small set of
elements a real module dashboard cannot be built without, every value a token reference, with real
:focus-visible treatment and status that never depends on colour alone. pnpm boundaries opens
exactly two shared roles to modules by name, and pins both design packages beneath everything
else. The Dashboard and display install the defaults at their document root; custom properties
inherit into every module artifact, so a module reads var(--ekklesia-…) with nothing to import at
run time and nothing for a host to forget to pass.
ADR 0040 reverses ADR 0023's
inline-styles-only rule, and supplies the three things that ADR said were missing. A module imports
plain .css; the canonical builder compiles it into the declared artifact as text, so there is
still exactly one file per role and no sidecar can exist — the failure ADR 0023 was protecting
against. Two rules are enforced with their own codes: a module stylesheet may reference
--ekklesia-* and never declare one, and every @import must resolve locally. The generic loader
then mounts every browser artifact inside its own open Shadow DOM root with its own stylesheet
installed, unconditionally, because a conditional isolation boundary is one a module can fall out of
by editing a source file. That root is a correctness boundary for styles and is never described as
a sandbox: it is open precisely because module code is trusted, same-realm code that could reach
document anyway. The browser mount ABI is versioned (MODULE_UI_ABI_VERSION, stamped by
defineModuleUi) with no dual acceptance; migration is one pnpm build.
ADR 0041 lets one module contribute a browser
renderer to a capability another module provides — the browser half of the capability system Core has
had since ADR 0006. manifestVersion: 8 declares a namespaced id, its own version, a target
capability, a compatible range, and one artifact. That is the complete list: there is no payload
schema, slot, ordering, or rendering hint anywhere in the platform, so Core cannot learn what a
"slide" is because the declaration cannot express it. Discovery resolves the artifact through the
identical loop every other compiled entrypoint uses; publication requires that another eligible
module provides the capability within the declared range, and a contribution that fails is absent
rather than broken. A consumer discovers through ModuleUiHostContext.contributions and mounts by
id — never by building a publication URL — and can only see contributions aimed at a capability it
itself provides. The input is opaque JSON validated for serializability and nothing else, so the
domain contract stays entirely between the two modules. A contributed renderer receives its own id
and that input: no API client, realtime, principal, configuration, database, storage, or credential,
and loading one grants its module nothing.
ADR 0042 closes the write path ADR 0031 left
open. ADR 0030 gave modules a bucket and ADR 0031 took Ekklesia out of the read path, but declined
signed write URLs — so bytes reach a bucket only through a module's own route, which was capped at
1 MiB. A route now declares bodyMode: buffered (the default, and what every existing route
meant) keeps that ceiling and materializes the body before the handler runs; streamed raises it to
32 MiB and counts bytes as the handler consumes them, holding nothing. The mode is explicit rather
than inferred from the size, because inferring it would silently change how an author's own handler
receives its body. 32 MiB is also the platform's absolute ceiling in every mode, sized for one
25 MiB image plus bounded overhead, and the unit is deliberately one file per request. The platform's
own limiter replaces the buffering dependency: a declared oversize is refused before authorization,
the buffered path now counts what actually arrived rather than trusting a header, and a streamed
overflow errors the stream, cancels the source, and replaces the response with the same sanitized
413 even if the handler swallowed the error.
ADR 0043 gives the display host-owned offline
resilience, scoped precisely to the shell and the declared code. A service worker scoped to
/display/ caches the page, the host script, the module's display artifact, and every contributed
renderer that display mounted — and never the descriptor, /api/v1/principal, any API response, the
realtime stream, a pairing exchange, or any cross-origin request, which is what a signed
object-delivery URL is. The consequence is stated rather than papered over: a display cannot render
module content while offline, because mounting needs an authorization-bearing descriptor that is
never cached, so a revoked device stays revoked and cached code can never manufacture current
authorization. What it buys is that a kiosk rebooting mid-outage boots into Ekklesia's own offline
state and recovers by itself, and that reconnecting mounts with nothing left to download. Caching is
network-first everywhere, so a screen can never be trapped on obsolete code.
Still deliberately absent after Phase 8: a theme editor, a brand builder, or any second theme; a module store or installer; a standalone host; signed write URLs, resumable uploads, or any body above the absolute ceiling; module-owned offline content; and every real feature module, Signage included. Nothing in this pass claims any of them exists.
ADR 0048 separates the platform artifact from what an installation installs:
- One generic image.
deploy/Dockerfilebuilds one image with zero feature modules, the same for every installation; a checkout'smodules/never reaches it. - Module storage. Installed modules live in installation-owned storage that one Core resolver
selects from
EKKLESIA_MODULES_DIR. The API, every operator command, and Doctor use the same resolver, and Core's own migrations are always found under the platform, never beside the modules. A configured directory that is relative, blank, missing, or unreadable fails closed with a sanitized error. - Self-contained modules. An installed module is a self-contained directory with its own production dependencies; the platform lends it none. Discovery still happens only when a process starts, so module changes take effect at a controlled restart and nothing is hot loaded.
- Removing keeps data. Removing a module's code keeps its data and stored state. The migration runner retains and reports the applied history of a module that is absent, instead of refusing to plan, while Core's history and every installed module's history stay strict, so a returning module that lost or changed an applied migration is still refused.
- Trust is unchanged. Installed modules are still trusted code; module storage is mounted read-only, which keeps Ekklesia from modifying installed code but does not sandbox it.
ADR 0049 defines how a module is distributed: one Ekklesia
Module Package v1, a deterministic .tar.gz with its SHA-256.
- The packager.
@ekklesia/module-packbundles the module's backend with every pure-JavaScript dependency, the SDK, contracts, and Drizzle included. It copies the built browser artifacts and migrations, generates the runtimepackage.json,ekklesia.package.jsonwith every file's digest, andTHIRD_PARTY_NOTICES, and writes the archive. - The verifier. Core's
verifyModulePackagereads the archive in memory and refuses links, special files, and escaping paths. It checks every digest and identity, the target and runtime, runs the platform's own module, schema, and migration validation, and loads the backend once in a short-lived process with an empty environment. Only then does it extract anything. - What exists today. Only the
anytarget. A manifest-v8 ABI module carries notransitional.databaseRuntime; Drizzle may be bundled as its private implementation detail, and Core validates plain schema metadata without comparing ORM versions. A version 8 module that omitsdatabaseremains on the temporary legacy facade and keeps ADR 0049's exact transitional Drizzle agreement. The canonical template now demonstrates ABI v1. The legacy facade still exists.