Files
Ekklesia/docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md
T
ekklesiaandClaude Opus 5 1e5866504e
CI / verify (pull_request) Successful in 32s
CI / infrastructure-smoke (pull_request) Successful in 26s
CI / migration-smoke (pull_request) Successful in 50s
feat: build module UI into its declared browser artifact
ADR 0022 defined what a module UI is and selected React and TypeScript
as the authoring model, but closed by recording that it was not buildable
end to end: `browser.json` preserves JSX, bundler plugins are denied, and
nothing turned a module's `.tsx` into the artifact its manifest declares.
This closes exactly that gap.

esbuild produces the artifact. It was already the right answer before it
was chosen: this workspace resolves esbuild 0.28.1 through Vite/Vitest
today and `pnpm-workspace.yaml` already carries `allowBuilds: esbuild`,
so the pinned dependency adds no second copy and no new trust decision.
It is also the smallest tool that covers TSX, ESM, browser platform,
bundling, source maps, and a define-time constant with no plugin
ecosystem. Vite library mode would drag in `@vitejs/plugin-react`, which
the module boundary denies on purpose; `tsc` alone cannot bundle, so its
output would carry bare `react/jsx-runtime` imports no browser resolves.

React is bundled, not externalized. That follows from ADR 0022 rather
than from convenience: `mount()` was made the ABI precisely so React
would not be part of the host contract, and externalizing it would
reverse that through the build system — every host would have to supply
a compatible React, and a standalone host might not use React at all.
The cost is real and accepted: duplicate runtimes and a ~570 KB
unminified artifact. Sharing one runtime later is a build optimization
that cannot change the contract, because the contract is a mount
function.

Output is browser ESM at `es2022`, unminified so an operator can review
what they install, with `process.env.NODE_ENV` substituted — React reads
it at module scope and would otherwise touch a Node global that does not
exist in a browser. The output path is read from the module's own
manifest rather than a build convention, so declared and produced
artifacts cannot drift, and it is validated with the same
`packageRelativePathSchema` discovery enforces, so a build can never
write where discovery would later reject.

`@ekklesia/module-ui-build` owns every option; modules own none. It uses
the existing `tooling` role that modules could already depend on, so this
pass required no boundary relaxation at all. Its `bin` points at a
committed launcher rather than compiled output because pnpm links bins
during install, before Turborepo builds anything — a bin targeting
`dist/cli.js` fails to link on a fresh clone and breaks every module's UI
build in CI. A module with no `entrypoints.ui` is not an error; the CLI
reports nothing to build and exits zero, so one script serves both kinds
of module.

Styling is React inline style objects. A stylesheet import fails the
build rather than emitting a sidecar `.css` that no manifest declares and
no host loads — an undeclared asset would appear to work in tests and
silently do nothing in a real host.

`templates/module` is the canonical example and now ships optional UI.
Its tests build the artifact from source, validate it with
`parseModuleUiExport`, mount it into a real DOM, assert the rendered
output, and unmount it; the artifact is rebuilt per run so the proof can
never pass against stale output, and `dist/` stays gitignored. Because
verify runs both `pnpm test` and `pnpm build`, a UI that cannot be
produced fails CI through two independent paths.

One correction worth stating plainly: the UI TypeScript project declares
`"types": []`, and a UI file referencing `process` does fail in
isolation, but TypeScript still pulls Node globals in transitively — a
file importing `@ekklesia/module-ui-sdk` reaches `@ekklesia/contracts`
and through its Zod and Drizzle types `process.env` typechecks again.
The docs and ADR describe this as the convention it is rather than the
guard it is not.

Core is untouched. Building is an authoring step; discovery still only
validates the artifact a module shipped and never executes or produces
one.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 21:24:18 -05:00

267 lines
16 KiB
Markdown

# 0022: Module UI authoring model and host contract
- Status: Accepted
- Date: 2026-08-21
- Extends: [0021](0021-modules-as-hostable-products.md)
## Context
ADR 0021 established that a module is a product which may own its UI, and added `entrypoints.ui` —
a statically validated, never-executed path to a compiled artifact. It deliberately answered none of
the questions that make that field usable, and recorded the gap explicitly: the authoring model was
"the first question the next pass must answer," and `pnpm boundaries` still rejected UI source and UI
framework dependencies inside a module.
Five questions block every later Phase 6 pass:
1. What does a UI artifact export?
2. What does a host give it?
3. How does one artifact run under Ekklesia *and* a standalone host?
4. What is the browser-side trust model?
5. How does module UI avoid being welded to a Dashboard that does not exist?
The repository already leans one way on the first. `packages/typescript-config` ships a `browser.json`
preset (DOM libs, JSX) and a `react-native.json` preset (`"jsx": "react-jsx"`), and ADR 0002 names
"separate Node, browser, and React Native base configurations." No frontend framework is installed
anywhere — the only React or Preact in the tree is inside boundary fixtures that exist to prove the
guard rejects them.
## Decision
### React is the supported module UI authoring model
A module author writes module UI in React and TypeScript (`.tsx`). This is a real choice, not a
neutrality dodge: the repository's own TypeScript presets already anticipate React, Ekklesia's future
first-party UI will be React, and pretending otherwise would push every module author into an
abstraction layer that serves nobody. One framework is supported. Vue, Svelte, Angular, Preact,
Solid, Qwik, Lit, Astro, Marko, and React Native remain unsupported and remain rejected by
`pnpm boundaries`.
**React is the supported authoring model. It is not part of the host contract.** That distinction is
the whole design, and the next section is where it is enforced.
### The host contract is a mount function, not a component
A module's compiled `entrypoints.ui` artifact default-exports a `ModuleUiDefinition`
(`packages/module-ui-sdk`):
```ts
export default defineModuleUi({
id: "announcements",
version: "0.1.0",
mount(container, context) {
const root = createRoot(container);
root.render(<Editor context={context} />);
return () => root.unmount();
},
});
```
`id` and `version` are build-artifact identity markers, mirroring `ModuleRuntimeDefinition` exactly
(ADR 0006): a host proves the bundle it imported was built from the module it thinks it is, and a
stale artifact is caught rather than mounted. They duplicate nothing else from the manifest.
`mount` receives a container `Element` and the host context, and returns an unmount function or
nothing. A module that acquires anything — a React root, a subscription, a timer, a listener —
returns an unmount and releases it there, exactly as a backend module releases resources in `stop`.
React appears nowhere in that contract. A host needs an element and a context; it does not need to
know, share, or match the module's rendering library. That keeps three things independent that would
otherwise be welded together: the module's framework, the host's framework, and the platform
boundary between them. React can be the supported way to *write* module UI while the *contract*
outlives any particular version of it.
### `@ekklesia/module-ui-sdk` is a separate, browser-safe package
The contract lives in a new shared package with role `module-ui-sdk`, not in `@ekklesia/contracts` or
`@ekklesia/module-sdk`. The reason is concrete rather than organizational: `@ekklesia/module-sdk`
re-exports all of `@ekklesia/contracts`, which pulls Zod, Drizzle, and semver behind one barrel
export. Routing module UI through it would drag a backend dependency graph into a browser bundle.
The package therefore has no runtime dependencies. Its one dependency on `@ekklesia/contracts` is
type-only (`import type { EkklesiaPrincipal }`), erased at build time, so a host and a module UI
provably share Core's principal type without shipping any of Core's runtime.
Both hosts consume this same package. It is not a Dashboard framework and must not become one: no
components, no layout, no design system, no router.
### The host context is deliberately four members
```ts
interface ModuleUiHostContext {
readonly moduleId: string;
readonly api: ModuleUiApiClient;
readonly principal: EkklesiaPrincipal;
readonly permissions: ModuleUiPermissions;
}
```
This is the smallest set that proves the architecture, not the set a finished screen will want.
Navigation, host theme and design tokens, module configuration, organization/platform context, and
diagnostics reporting are **not** included. Each needs a design that does not exist, and a
speculative field frozen into a public contract costs more to remove than to add later. A host passes
this object and nothing else; there is no platform singleton, no global, and no escape hatch to Core.
### Module UI addresses its own API relatively, never by URL
`ModuleUiApiClient.request(path, init)` takes a path **relative to the module's own API root** and
returns a `Response`. The module asks for `posts`. The host decides what that means.
This is the single most important portability rule here. Ekklesia mounts module routes under
`/api/v1/modules/<module-id>` (`apps/api/src/module-http-routes.ts`), but that prefix is Ekklesia's
private deployment detail. A standalone host serving one module may expose the same routes at `/api`,
at another origin, or behind a proxy. A module UI that builds
`/api/v1/modules/announcements/posts` itself is silently welded to Ekklesia and can never be hosted
anywhere else — and the failure is invisible until someone tries.
`Request`/`Response` are the same Fetch vocabulary a module's backend routes already speak (ADR
0015), so one module speaks one HTTP shape on both sides of the wire. Credentials, base URL, and any
platform headers are the host's responsibility; a module UI neither supplies nor sees a session
token. No backend routing changed in this pass.
### Module UI is trusted first-party code in the host's realm
Module UI runs in the same browser JavaScript realm as the host shell. There is no iframe, no
sandbox, no cross-origin isolation, and no capability confinement. A module's UI can reach the host
page, the DOM, `window`, and any global the browser exposes, exactly like any other script the
application loads.
**This is a trusted-code model, stated plainly rather than dressed up.** It matches, and does not
extend, the backend model ADR 0021 preserved: installing a module already means running its code in
the API process with the API's PostgreSQL authority. A module whose server code is trusted with the
database is not meaningfully constrained by sandboxing its browser code.
Introducing an iframe or a separate origin now would add real cost — postMessage plumbing, styling
seams, focus and accessibility breakage — to buy theoretical separation from code that already has
server-side authority. That trade only becomes worthwhile for genuinely untrusted third-party
modules, which this platform does not accept (see
[Security model and deployment trust boundaries](../../development/security.md)). Nothing in this
contract prevents a future sandboxed tier: a host that wanted one could implement `mount` against an
isolated realm without changing this interface, because the contract is a mount function and a
plain-data context rather than shared object graphs.
### Client-side permission checks are presentation, never enforcement
`ModuleUiPermissions.has(permissionId)` tells a module UI what the host believes, so it can hide an
action, disable a control, or pick an empty state. It is not a security boundary and must never be
described as one.
Server-side authorization remains the only authority. Every module route is enforced by Core's
`requirePermission` middleware against the composed platform permission registry (ADR 0013/0014),
and that enforcement neither knows nor cares what a browser decided. A module UI that consults
`has()` and then calls its API is still fully authorized server-side; one that skips the check and
calls anyway is still denied. The value is that a user does not see a button that would fail.
`permissionId` is a plain `string`, not Core's branded `PermissionId`, deliberately: branding would
force a module UI to run Zod in the browser to produce a value for a call that decides nothing
security-relevant. The branded type remains the server-side type, where it matters.
### Discovery still never executes UI, and Core still never loads it
`parseModuleUiExport(namespace, expected)` validates an already-imported module namespace. It does
not import anything. Each host performs its own dynamic import — a browser host from a served URL, a
test host from a file URL — and passes the resulting namespace in.
That keeps this package free of any loader, bundler, or URL assumption, and it keeps Core out of the
business of evaluating browser code entirely. Module discovery continues to resolve and validate the
`entrypoints.ui` path and never execute it (ADR 0021), and this pass adds no UI loading to
`core/`, to `apps/api`, or to any startup path. Nothing about restart-based discovery changed, and
nothing about explicit operator-invoked migrations changed.
### Dashboard organization stays outside this contract
The contract carries no navigation entry, route, title, icon, folder, group, or ordering field, and
none may be added as a hierarchy mechanism. ADR 0021 established that Dashboard grouping is
presentation configuration an administrator reorganizes, never module architecture; adding any of it
here would quietly reverse that, because a field in the UI contract is a field a module owns.
### The boundary guard is narrowed precisely, not removed
`pnpm boundaries` keeps enforcing `modules-do-not-own-ui`. Exactly three things changed:
- `.tsx` is no longer a rejected source extension. `.jsx` stays rejected (this repository is
strict-TypeScript throughout) and `.css` stays rejected (module styling and the module UI build
pipeline are not designed yet, and a stylesheet would imply a bundling contract that does not
exist).
- `react` and `react-dom` are no longer rejected dependencies. Every other framework stays rejected,
including `react-router-dom` (routing belongs to the host), `react-native` (this pass supports
React on the DOM only), and `@vitejs/plugin-react` (the build pipeline is undesigned).
- Modules may depend on the new `module-ui-sdk` shared role, which itself may depend only on
`@ekklesia/contracts`.
Layer rules are untouched. Module UI source is subject to the identical import boundaries as module
backend source: it cannot import Core, an app, a surface, a builder, another module, or a shared
package outside the roles a module is allowed. An unsupported framework aliased behind an allowed
name (`"react": "npm:vue@3"`) is resolved and rejected by target, so permitting React did not create
a way to smuggle anything else in.
## Alternatives
- **A React component as the export** (`export default function Ui(props)`). Rejected: it would put
React into the platform contract, force every host — including standalone — onto the same React
major, and make a framework upgrade a breaking change to the module boundary. A mount function
costs a module author three lines and buys independence for all three parties.
- **Web Components as the authoring model.** Rejected as the *authoring* model: it would mean every
module author writes to a lower-level API than the one Ekklesia's own UI will use, and styling,
form handling, and state would all need re-solving per module. Notably, the mount contract does not
forbid a module from using them internally — it only declines to require them.
- **A serialized UI description** (`{"type": "form", "fields": [...]}`) rendered by the host.
Rejected outright. It is a frontend framework in disguise, it can only ever express what its schema
anticipated, and the Announcements editor that motivated ADR 0021 is exactly the kind of screen
such a system cannot express. Ekklesia is not building its own frontend framework.
- **Putting the contract in `@ekklesia/module-sdk`.** Rejected: that package re-exports all of
`@ekklesia/contracts`, so a module UI bundle would pull Zod, Drizzle, and semver into the browser.
- **Passing a single platform object** (`context.platform`, `context.core`, a store). Rejected: it
makes every internal a de facto public API, and nothing that reaches a module can be changed
afterwards without breaking one. Four narrow members can grow; a platform object can only leak.
- **An absolute API base URL** (`context.apiBaseUrl`) instead of a request function. Rejected: a
module that concatenates a base URL still assumes a path shape underneath it, and it puts URL
construction — and therefore credential and header policy — back inside the module.
- **Iframe or cross-origin sandboxing now.** Rejected as premature and misleading: real cost today,
no real containment for code whose backend half already holds the API's database authority, and a
sandbox claim this repository would not be able to substantiate.
- **Waiting for the Dashboard before defining the contract.** Rejected: the Dashboard would then
define the contract by accident, and every Ekklesia-specific assumption it happened to make would
become the standalone host's problem later.
- **Allowing `.css` in modules now.** Rejected: it implies a bundling and style-isolation contract
that this pass does not design. Deferred deliberately rather than half-answered.
## Consequences
> **Cross-reference — see [ADR 0023](0023-module-ui-build-pipeline.md).**
> The gap this section records — that module UI was not buildable end to end, because no pipeline
> turned `.tsx` into the compiled `entrypoints.ui` artifact — is now closed. ADR 0023 selects
> esbuild, bundles React rather than externalizing it (preserving this ADR's mount-function ABI),
> emits browser ESM at the manifest-declared path, and keeps stylesheet imports unsupported by
> failing the build on one. Everything else in this ADR remains accepted and unchanged.
A module author can now write a real screen in React, against a documented four-member host context,
and know exactly what the artifact must export. Two hosts can consume that artifact with no shared
code beyond one small package, and the test suite proves it by mounting one fixture artifact under
two hosts whose URL shapes and origins differ.
The cost is that this is still a contract without a production consumer. No Dashboard mounts a module
UI, no module ships one, and the build pipeline that turns a module's `.tsx` into the compiled
`entrypoints.ui` artifact is not designed — `browser.json` uses `"jsx": "preserve"`, which needs a
bundler that no module has, and `@vitejs/plugin-react` remains denied. Choosing between a `tsc`-only
JSX preset and a real bundler, together with the styling question, is the next pass's work and is the
reason `.css` stays rejected today.
A second cost is deliberate duplication of effort in the browser: if both the host and a module
bundle React, a page can load React twice. Sharing one copy through peer dependencies, bundler
externals, or an import map is a build-pipeline optimization, not a contract change, and the mount
contract is unaffected by whichever is chosen.
## Deferred
Not implemented, designed, or promised here: the Dashboard application shell, homepage, or
navigation UI; module navigation registration; Dashboard folders, groups, or drag-and-drop
organization; a design system, component library, or theme/design-token contract; the module UI build
and styling pipeline; a standalone application host; a module installer, marketplace, or manager;
hot-loading or runtime unloading; automatic migrations; sandboxed or untrusted module tiers; and
every real feature module. `entrypoints.jobs` still accepts source TypeScript where other executable
entrypoints require compiled output — that inconsistency is unchanged and remains open, because this
pass did not centralize executable-entrypoint validation and fixing it here would have been unrelated
scope.