Files
Ekklesia/docs/architecture/platform-architecture.md
T
ekklesiaandClaude Opus 5 8d651c427d
CI / verify (pull_request) Successful in 30s
CI / infrastructure-smoke (pull_request) Successful in 23s
CI / migration-smoke (pull_request) Successful in 45s
feat: define the module UI authoring model and host contract
ADR 0021 established that a module may own UI and added a statically
validated, never-executed `entrypoints.ui` path, but deliberately left
five questions open: what the artifact exports, what a host provides,
how one artifact runs under both Ekklesia and a standalone host, what
the browser-side trust model is, and how module UI avoids being welded
to a Dashboard that does not exist. This answers them.

React and TypeScript are the supported module UI authoring model. The
repository already leaned this way: `packages/typescript-config` ships a
`browser.json` preset and a `react-native.json` preset with
`"jsx": "react-jsx"`, and ADR 0002 names browser and React Native
configurations. One framework is supported; every other stays rejected.

React is deliberately not part of the contract. A module's compiled
artifact default-exports `defineModuleUi({ id, version, mount })`, and
`mount(container, context)` takes a DOM element and returns an unmount
function. `id` and `version` are build-artifact identity markers a host
verifies against the manifest, mirroring `defineModuleRuntime` exactly.
A host needs an element and a context, never a matching rendering
library, so a module's framework, a host's framework, and the platform
boundary between them stay independent.

The contract lives in a new `@ekklesia/module-ui-sdk` rather than in
`@ekklesia/module-sdk`, for a concrete reason: that package re-exports
all of `@ekklesia/contracts`, so routing module UI through it would drag
Zod, Drizzle, and semver into every browser bundle. The new package has
no runtime dependencies, and its one dependency on contracts is
type-only — the emitted `host-context.js` is `export {};`, and a test
pins that so it cannot regress.

The host context is four members: `moduleId`, `api`, `principal`, and
`permissions`. Navigation, theme, module configuration, organization
context, and diagnostics are deliberately absent; each needs a design
that does not exist. Module UI reaches its backend through
`api.request(path)` with a path relative to its own API root and never
constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private
deployment detail — a module that builds that URL is silently unhostable
anywhere else. One fixture artifact is mounted under two hosts with
different origins and URL shapes to prove it.

Client-side permission hints are presentation only, and both the docs
and a test say so plainly: a denied hint does not stop the module from
calling its API, because server-side `requirePermission` is the only
authority. Module UI is trusted code in the host's own browser realm —
no iframe, no sandbox — which matches the backend model rather than
overstating it, since a module's server half already holds the API's
database authority.

The boundary guard is narrowed precisely, not removed: `.tsx`, `react`,
and `react-dom` are un-denied and modules may depend on the new
`module-ui-sdk` role. `.jsx`, `.css`, every other framework,
`react-router-dom`, `react-native`, and `@vitejs/plugin-react` stay
denied, layer rules are untouched, and an unsupported framework aliased
behind an allowed name (`"react": "npm:vue@3"`) is resolved and rejected
by its real target. Fixtures prove each case, including that module UI
source still cannot import Core.

Core, apps/api, and the manifest contract are untouched. Discovery still
validates the `entrypoints.ui` path and never evaluates it; hosts
perform their own dynamic import and validate the result with
`parseModuleUiExport`. No Dashboard shell, UI runtime, navigation,
grouping, standalone host, build or styling pipeline, installer,
hot-loading, or feature module is added.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 20:52:47 -05:00

14 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:

  1. Core orchestrates generic platform infrastructure: startup, identity, permissions, configuration, database connectivity, storage, events, jobs, module discovery, and lifecycle.
  2. 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.
  3. 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.
  4. 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.

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. It is now possible to publicly register, sign in, resolve a principal, and sign out.

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:

  1. 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;
  2. identity session resolution, and a mandatory certificate- and hostname-verified PostgreSQL transport with no plaintext or rejectUnauthorized: false exception;
  3. durable authorization history that survives user deletion, a one-time owner bootstrap that deleting a user cannot reopen, and bounded, unambiguous explicit-grant management;
  4. module route-overlap rejection at registration, finite startup rollback and shutdown deadlines, and hardened migration command timeouts and SQL execution;
  5. 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 is four members — moduleId, api, principal, permissions. 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. The Dashboard shell, a UI runtime, the module UI build and styling pipeline, the standalone host, and every feature module remain future work. 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.