Files
Ekklesia/docs/architecture/decisions/0021-modules-as-hostable-products.md
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

16 KiB

0021: Modules as independently hostable products

  • Status: Accepted
  • Date: 2026-08-21
  • Extends: 0006, 0014
  • Supersedes (partial): 0001 — only its rule that a module never owns UI and that surfaces/* owns all presentation. The four ownership layers, Core's feature-agnostic role, and the prohibition on Core importing a module remain accepted and unchanged.
  • Supersedes (partial): 0014 — only its manifestVersion: 3 literal. Permission declarations, the composed platform registry, the reserved core.* namespace, and the inert-manifest discovery model remain accepted and unchanged.

Context

Every module contract this repository has accepted so far — static manifests (ADR 0005/0006), module-owned schemas and migrations (ADR 0007/0008/0009), permission declarations (ADR 0014), framework-neutral HTTP routes (ADR 0015), and typed database access (ADR 0016) — describes a backend capability. ADR 0001 placed all presentation in surfaces/* and forbade a module from owning any UI.

That split has a cost the platform can no longer absorb. A module like Announcements owns its domain model, its permissions, its storage, its routes, and its migrations, but cannot own the one screen that makes it useful. Under ADR 0001, an announcement editor must be written independently by whoever builds a surface, against a module the surface author does not own and cannot see the internals of. Every module then needs a second, separately maintained implementation of its own product experience, and the module's author — the person who actually knows the domain — is structurally prevented from shipping it.

The same pressure appears from the other direction. A church that wants only announcements should eventually be able to run that module as a standalone application. If Ekklesia-hosted and standalone experiences are two codebases, they diverge immediately: two editors, two validation rules, two permission interpretations, two release cadences, one domain.

Neither problem is solved by more surface tooling. Both are the same missing idea: a module is a product, not just a backend capability, and a product includes its own presentation.

Decision

Modules are independently versioned, first-class products

A module owns its domain behavior, data, migrations, permissions, capabilities, HTTP routes, and — new in this ADR — its own UI. It remains an independently versioned package with its own semantic version, its own capability versions, and its own compatibility ranges, exactly as ADR 0006 established. Nothing about becoming a product changes how a module is versioned, discovered, ordered, or validated.

Modules may own UI; Core owns the host shell and platform policy

A module may ship its own management screen, its feature-specific presentation behavior, and reusable UI belonging to its feature. It does not own the application that contains it.

When a module's UI runs inside Ekklesia, Core and the host application remain solely responsible for authentication and session establishment, authorization enforcement, organization/platform context, the containing application shell, top-level routing and mount boundaries, module discovery, the platform navigation container, the global theme and design-token contract, platform error boundaries, cross-module coordination contracts, trusted-origin/CORS and other security policy, and every platform service exposed through a deliberate contract. A module UI entrypoint consumes those; it never redefines, replaces, or bypasses them. This mirrors the boundary the backend already has: ADR 0015 lets a module mount routes, but the platform still owns method vocabulary, CORS preflight, body ceilings, and permission enforcement.

The module hierarchy is flat

There is no architectural parent/child relationship between modules, and none is being introduced. video, livestream, announcements, calendar, roster, and signage are peers. A module that ships UI gains no containment relationship over any other module.

The only relationships a module may express are the two that already exist and remain fully supported: a genuine module dependency and a genuine capability dependency, both version-ranged and both resolved by discovery into a dependency-ordered graph (ADR 0006). Those express requirement, not ownership, and they are the correct mechanism when one module truly cannot function without another. Conceptual relatedness is not a dependency and must never be modelled as one.

Dashboard grouping is presentation configuration, not module architecture

A Dashboard may eventually present independent modules inside folders or groups — Media holding Video and Livestream, or Sunday Production holding Livestream, Signage, and Video. Those groupings belong to Dashboard/surface configuration. They are not module ownership, not dependency, and not part of the module contract.

A module may eventually offer presentation metadata such as a suggested default grouping, but it must never own the folder it appears in, and correctness must never depend on that hierarchy. An administrator must be able to reorganize every module without any module changing. Accordingly, the manifest deliberately declares no group, folder, parent, menu, navigation, icon, or ordering field, and none may be added as a hierarchy mechanism.

One module implementation, many hosts

Standalone support is explicitly not a second application. The intended shape is one module implementation consumed by thin host-specific composition:

                 Announcements module
                    /          \
          Ekklesia host     Standalone host

Domain behavior, APIs, schema, migrations, permissions, UI, and feature logic are the module's and are reusable by both. Host-specific composition stays thin. This ADR does not implement a standalone host; it forbids designing the public contract in a way that makes one impossible. Concretely: a module's UI entrypoint must not be fundamentally dependent on a private Ekklesia dashboard application or on Core internals, and the contract must not be named after, or shaped around, one particular host.

entrypoints.ui is the smallest primitive that establishes this

The manifest gains exactly one new optional field: entrypoints.ui, a package-relative path to a compiled artifact, validated identically to every other entrypoint. That is the whole new contract.

The name is deliberate. It is not dashboard, because a module's UI must be hostable by Ekklesia's shell and by a standalone host, so the contract cannot be named after one of them. It is not surface, because surfaces/* is a separate ownership layer (ADR 0001) and reusing that word for a module-owned artifact would conflate two different things. ui is host-neutral, platform-neutral, and already this repository's own vocabulary for the concept.

No widget, route, icon, folder, menu, layout, page-builder, or standalone-metadata field is added. Those contracts, if they are ever needed, evolve from this primitive under a later manifest version.

The UI entrypoint is a compiled artifact, statically validated, and never executed by discovery

entrypoints.ui obeys exactly the rules ADR 0006 established for server and schema: it must be package-relative, must stay inside the module package (checked before and after symlink resolution), must resolve to a real file, and must be compiled ESM JavaScript ending in .js or .mjs. Source TypeScript is rejected at discovery rather than assumed to be compiled later.

Discovery remains completely inert. Core parses the manifest as static JSON, resolves the UI path, and stores it. It does not import, evaluate, render, mount, or otherwise execute a UI entrypoint — and nothing in this repository does yet. This follows the precedent already set by entrypoints.jobs, which has been declared and resolved since ADR 0006 with no runtime consumer.

Manifest version 4 replaces version 3

Version 3's entrypoints object is .strict() and rejects ui outright, so accepting a UI entrypoint is an incompatible shape change. The canonical manifest moves to manifestVersion: 4 (z.literal(MODULE_MANIFEST_VERSION)), and version 3 — like every other version — fails validation. There is no compatibility adapter, no automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2 (ADR 0006) and version 2 by version 3 (ADR 0014). One manifest document can therefore never be interpreted as two different versions.

Backward compatibility is deliberate rather than automatic: because every field version 4 adds is optional, upgrading a backend-only version 3 manifest is a one-character edit and changes nothing else. A backend-only module remains completely valid and completely supported — declaring UI is a choice, never a requirement.

Restart-based discovery remains correct

Installed module inventory is composed at startup and changes take effect on the next restart. This ADR adds no hot installation, no runtime unloading, no filesystem watching, and no dynamic mutation of the running registry. A future module-management UI does not imply hot-loading, and module UI does not change the reasoning ADR 0014 recorded for the static startup snapshot.

Migrations remain explicit

Module installation and API startup never apply migrations. Modules continue to own their Drizzle schema and migration history, applied through Core's existing validated, operator-invoked workflow under an advisory lock and one transaction (ADR 0008/0009/0020). Destructive-migration approval, checksums, planning, locking, and transactional behavior are untouched by this ADR.

The trusted compiled-module security model is unchanged

Installed modules remain trusted compiled code running inside the API process and sharing its PostgreSQL authority. Manifest validation, typed facades, route restrictions, table naming, and frozen contexts remain correctness and coordination boundaries — not a sandbox. Adding a UI entrypoint grants no isolation and implies none. A module that ships UI is exactly as trusted as one that does not, and an operator installing either is deploying trusted code. See Security model and deployment trust boundaries.

Alternatives

  • Surfaces own all UI (the status quo, ADR 0001). Rejected because it forces every module's product experience to be re-implemented by someone who does not own the domain, and guarantees that a module and its own screen drift apart. It is the specific constraint this ADR exists to remove.
  • Parent modules own child modules (media containing video and livestream). Rejected because it encodes one organization's conceptual taxonomy as an architectural dependency. It makes an administrator's presentation preference a code change, forces artificial dependencies between modules that do not actually require one another, prevents a "child" from being installed or versioned alone, and conflates grouping with dependency resolution. Flat peers plus genuine dependencies express everything real, and Dashboard configuration expresses the rest.
  • Separate standalone and Ekklesia implementations. Rejected because two codebases for one domain diverge immediately in validation, permissions, and behavior, and double the maintenance and audit surface for every module forever. One implementation with thin host composition is the entire point of making a module a product.
  • Hot-load modules immediately. Rejected for this phase. It introduces concurrent-mutation and partial-update hazards across the registry, the permission registry, the route table, and now a UI surface, none of which this phase needs to solve. ESM evaluation cannot be rolled back or unloaded (ADR 0006), so hot replacement is a substantially harder problem than it appears. A restart remains sufficient for the current deployment model.
  • Automatic migrations on install or startup. Rejected, and rejected consistently with every prior ADR: a module gaining UI does not make schema change safe to perform implicitly. Automatic application would defeat destructive approval, remove the operator's review step, and make startup a mutating database operation.
  • Extending version 3 in place with a new optional field. Rejected because version 3's entrypoints is strict: silently widening it would change what an existing declared version means and make one document ambiguously readable as two versions. An explicit version bump keeps rejection behavior clear and validation deterministic.
  • A richer first UI contract (mount routes, widgets, icons, navigation entries, grouping hints, standalone metadata). Rejected as premature. None of it can be designed honestly before a host exists to consume it, and every field added now would be a guess frozen into a strict public contract.
  • Naming the entrypoint dashboard. Rejected because it names one prospective host and would bias the contract against standalone reuse — the exact coupling this ADR is trying to avoid.

Consequences

Cross-reference — see ADR 0022. The open question this section records — the module UI authoring model, and which parts of the modules-do-not-own-ui guard change — is now answered. ADR 0022 selects React as the supported authoring model, narrows the guard to allow .tsx plus react/react-dom while keeping every other framework rejected, and defines what entrypoints.ui exports and what a host provides. Everything else in this ADR remains accepted and unchanged: entrypoints.ui is still statically validated and never executed by discovery, and Core still never loads browser code.

A module author can now express the complete product they own, and the platform has one contract for discovering that a module offers presentation at all. Backend-only modules are unaffected beyond a one-character manifest version bump. The dependency graph stays flat and meaningful, and Dashboard organization stays an administrator's decision rather than an architectural one.

The cost is a declared contract with no consumer: entrypoints.ui can be validated but not yet hosted, exactly like entrypoints.jobs. That is deliberate — the static contract is established first so the runtime design in a later pass has a fixed, tested boundary to build against — but until that pass lands, declaring ui accomplishes nothing observable at runtime.

This ADR also creates a real, tracked tension with an enforced repository rule. pnpm boundaries (scripts/check-boundaries.ts, rule modules-do-not-own-ui) rejects UI source extensions (.tsx, .vue, .css, .svg, and others) and UI framework dependencies inside a module package. That rule is intentionally left enforced by this ADR: whether and how to relax it depends entirely on the UI authoring contract a later pass chooses, and weakening a working guard before that decision exists would trade a real protection for nothing. A module may therefore declare entrypoints.ui today only for a framework-neutral compiled artifact, and choosing the module UI authoring model — including exactly which parts of that deny list are relaxed, and what replaces them — is the first question the next pass must answer.

Deferred

None of the following is implemented, designed, or promised by this ADR: the Dashboard application shell; a UI component library or design-token implementation; any React/Vue/Svelte or other framework choice; a module UI runtime, renderer, loader, or mount mechanism; navigation registration; Dashboard folders, groups, or grouping metadata; a standalone application host; a module-management UI; a module installer or marketplace; hot-loading or runtime unloading; automatic migration application; and any real feature module. The entrypoints.ui field is a static declaration only.