feat: establish the module product contract
CI / verify (pull_request) Successful in 2m31s
CI / infrastructure-smoke (pull_request) Successful in 15s
CI / migration-smoke (pull_request) Successful in 49s

Ekklesia's module model has been backend-only since ADR 0001: a module
owns domain behavior and data, and every presentation concern belongs to
a surface. That prevents a module author — the person who knows the
domain — from shipping the screen that makes their module useful, and it
guarantees a module and its own UI drift apart. It also pushes toward two
codebases for one domain the moment a module needs to run standalone.

ADR 0021 replaces that rule. A module is an independently versioned
product that owns its domain behavior, data, permissions, routes,
migrations, and its own UI, hostable inside Ekklesia and — from the same
implementation — by a later standalone host. Core keeps the containing
application shell and every platform policy: authentication,
authorization enforcement, platform context, top-level routing,
navigation, global theme, error boundaries, and trusted-origin/CORS. The
module graph stays flat: modules are peers, genuine module and capability
dependencies remain the only relationships, and Dashboard folders are
presentation configuration an administrator can reorganize, never module
architecture.

This pass implements the static contract only. `manifestVersion` moves to
4 and `entrypoints` gains one optional field, `ui`. Version 3's
`entrypoints` is strict and rejects `ui`, so this is an incompatible
shape change and version 3 is now rejected outright rather than
reinterpreted, exactly as version 1 was by version 2 and version 2 by
version 3. Every field version 4 adds is optional, so upgrading a
backend-only manifest is a one-character edit.

Discovery resolves and validates `ui` under the same rules as `server`
and `schema` — package-relative, inside the package before and after
symlink resolution, a real file, compiled ESM `.js`/`.mjs`, so source
TypeScript is rejected rather than assumed to be compiled later.
Discovery stays inert: nothing imports, evaluates, mounts, or renders a
UI entrypoint, and a test proves a throwing artifact still discovers
successfully. This follows `entrypoints.jobs`, declared and resolved
since ADR 0006 with no runtime consumer.

The name is `ui`, 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; and not `surface`, because `surfaces/*` is a
separate ownership layer. No widget, route, icon, folder, menu, layout,
or standalone-metadata field is added, and tests assert the manifest
still rejects every unknown entrypoint key and offers no hierarchy field.

Deliberately unchanged: `scripts/check-boundaries.ts` still enforces
`modules-do-not-own-ui`. Relaxing that guard depends entirely on the UI
authoring model a later pass chooses, and weakening a working protection
before that decision exists would trade real coverage for nothing. A
module may therefore declare `ui` today only for a framework-neutral
compiled artifact, and the docs say so rather than implying otherwise.
Restart-based discovery, explicit operator-invoked migrations, and the
trusted compiled-module model are untouched — a module that ships UI is
exactly as trusted as one that does not.

No Dashboard shell, UI runtime, renderer, framework choice, navigation
registry, grouping system, standalone host, installer, hot-loading, or
feature module is added by this pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-21 20:20:41 -05:00
co-authored by Claude Opus 5
parent 94d26cffa0
commit 44ba259f58
27 changed files with 769 additions and 63 deletions
+101 -10
View File
@@ -1,7 +1,19 @@
# Module development
An Ekklesia module is a backend capability package. It owns business behavior and data declarations;
it never owns components, styles, layouts, renderers, navigation, or other UI.
An Ekklesia module is an independently versioned, first-class product. It owns its domain behavior,
data declarations, permissions, capabilities, HTTP routes, migrations — and, as of
[ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md), its own UI.
It does **not** own the application that contains it. Authentication, authorization enforcement,
platform context, the application shell, top-level routing, navigation, the global theme and
design-token contract, error boundaries, and trusted-origin/CORS policy are Core's and the host
application's. A module consumes those; it never redefines or bypasses them.
> **What exists today:** a module may *declare* a UI entrypoint in its manifest, and Core validates
> it. **Nothing loads, mounts, or renders it yet** — the host shell, the UI runtime, and the
> standalone host are all later Phase 6 work. Writing a module UI is not yet possible end to end;
> see [Declaring a UI entrypoint](#declaring-a-ui-entrypoint) for exactly what is and is not
> supported.
> **Before you publish or install a module:** modules are trusted compiled backend code. Your
> module runs arbitrary JavaScript inside the API process and shares its PostgreSQL role, and
@@ -54,7 +66,7 @@ without executing module code:
```json
{
"manifestVersion": 3,
"manifestVersion": 4,
"id": "calendar",
"name": "Calendar",
"version": "0.1.0",
@@ -111,9 +123,12 @@ has its own exact semantic version because its public contract can evolve indepe
module release. Capability dependencies use semantic-version ranges. Optional means the provider
may be absent; if it is installed, its capability version must be compatible.
`manifestVersion: 3` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
version 2 manifest, and any other version, fails validation outright rather than being silently
`manifestVersion: 4` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
version 3 manifest, and any other version, fails validation outright rather than being silently
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
Version 4 adds one optional field, `entrypoints.ui`, so upgrading a backend-only version 3 manifest
is a one-character edit and changes nothing else
([ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md)).
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
@@ -138,16 +153,92 @@ module's own HTTP routes, and creating relationship-derived grants automatically
own business logic, are both still later work; this only makes a declared permission *usable*
through Core's existing, generic surface.
Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files. The optional
migration entrypoint is a directory. All entrypoints begin with `./`, remain inside the package,
and exist. Production never imports source TypeScript directly. Modules with no tables omit
`entrypoints.schema`; modules with no migration history omit `entrypoints.migrations`. Unknown
manifest properties are rejected, including presentation fields such as components or renderers.
Server and optional schema and UI entrypoints must be compiled ESM `.js` or `.mjs` files. The
optional migration entrypoint is a directory. All entrypoints begin with `./`, remain inside the
package, and exist. Production never imports source TypeScript directly. Modules with no tables omit
`entrypoints.schema`; modules with no migration history omit `entrypoints.migrations`; modules with
no UI omit `entrypoints.ui`.
Unknown manifest properties are still rejected. `ui` is the only entrypoint version 4 adds — a
`dashboard`, `surface`, `client`, `web`, or `renderers` key fails validation — and the manifest
deliberately has no group, folder, parent, menu, navigation, icon, or ordering field. Dashboard
organization is presentation configuration owned by the Dashboard, never module architecture.
Storage bucket IDs are logical names scoped to the module. Modules may request multiple buckets.
Core will later map them to deployment-specific physical S3 buckets and provide scoped access;
module code must not configure SeaweedFS or another S3 client directly.
## Declaring a UI entrypoint
A module may declare one optional presentation entrypoint alongside its server, schema, and
migration entrypoints:
```json
{
"entrypoints": {
"server": "./dist/server/index.js",
"schema": "./dist/schema/index.js",
"migrations": "./drizzle",
"ui": "./dist/ui/index.js"
}
}
```
`ui` obeys exactly the rules `server` and `schema` already obey. It must be package-relative, stay
inside the module package (checked before *and* after symlink resolution), resolve to a real file,
and be compiled ESM JavaScript ending in `.js` or `.mjs`. A `./src/ui/index.ts` path is rejected at
discovery — the platform never imports source TypeScript, and a UI artifact is a build output like
any other.
### What this does and does not do today
Discovery parses the manifest as inert JSON, resolves the `ui` path, validates it, and records it on
the discovered module. That is all. **Nothing in this repository imports, evaluates, mounts, or
renders a UI entrypoint** — a UI artifact that throws on evaluation still discovers successfully,
because it is never evaluated. The field follows the same established pattern as `entrypoints.jobs`,
which has been declared and validated since ADR 0006 with no runtime consumer.
Declaring `ui` therefore has no observable runtime effect yet. It exists so the host shell, the UI
runtime, and a future standalone host have a fixed, tested contract to build against.
### The authoring constraint you will hit
`pnpm boundaries` enforces `modules-do-not-own-ui`, which rejects UI *source* extensions (`.tsx`,
`.vue`, `.svelte`, `.css`, `.svg`, and others) and UI *framework dependencies* (React, Vue, Svelte,
Tailwind, and others) inside a module package. **That rule is still enforced.** ADR 0021 changed the
architectural direction — a module may own UI — but the authoring model for module UI, including
which parts of that deny list are relaxed and what replaces them, is a later Phase 6 decision that
has not been made.
In practice this means a module can declare `entrypoints.ui` today only for a framework-neutral
compiled artifact authored in plain `.ts`. Do not add a UI framework dependency to a module package
expecting it to pass; it will not, and that is deliberate rather than an oversight.
### What a module UI will never own
Whatever authoring model is chosen, a module UI consumes platform policy and never redefines it.
Authentication and session establishment, authorization enforcement, organization/platform context,
the containing application shell, top-level routing and mount boundaries, the platform navigation
container, the global theme and design-token contract, platform error boundaries, and
trusted-origin/CORS policy belong to Core and the host application. This is the same boundary module
HTTP routes already live inside: a module mounts routes, but the platform still owns the method
vocabulary, CORS preflight, body ceilings, and permission enforcement.
A module UI also grants no isolation. A module that ships UI is exactly as trusted as one that does
not — see [Security model and deployment trust boundaries](../development/security.md).
### Modules stay flat
Shipping UI creates no parent/child relationship between modules. `video`, `livestream`,
`announcements`, `calendar`, `roster`, and `signage` are peers. The only relationships a module may
express are a genuine module dependency and a genuine capability dependency — both version-ranged,
both meaning *requirement*, never *ownership*.
A Dashboard may later group modules into folders (`Media` holding Video and Livestream, or
`Sunday Production` holding Livestream, Signage, and Video). Those groups are Dashboard
configuration an administrator can reorganize freely. They are not dependencies, they are not
declared in your manifest, and your module must never require one to work correctly.
## Runtime server definition
The source entrypoint exports one typed runtime definition and does not repeat the static manifest: