Merge pull request 'feat: establish the module product contract' (#15) from phase-6a/module-product-contract into main
CI / verify (push) Successful in 29s
CI / infrastructure-smoke (push) Successful in 22s
CI / migration-smoke (push) Successful in 43s

Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/15
This commit was merged in pull request #15.
This commit is contained in:
The_River
2026-08-22 01:32:48 +00:00
27 changed files with 769 additions and 63 deletions
+6 -4
View File
@@ -12,10 +12,12 @@ monorepo:
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration - **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
feature module, a surface, or an app. feature module, a surface, or an app.
- **Modules** (`modules/*`, template at `templates/module`) are backend-only capability packages. - **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
They own their own data and migrations, declare capabilities/permissions through packages. They own their own data and migrations, declare capabilities/permissions through
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never contain UI or import Core, `@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
another module, or a surface directly. surface directly. Per ADR 0021 a module may also own its own UI and declare a compiled
`entrypoints.ui` artifact; no runtime hosts that entrypoint yet, and `pnpm boundaries` still
rejects UI source files and UI framework dependencies inside a module package.
- **Surfaces** (future `surfaces/*`) own all UI and presentation. They consume public API/client - **Surfaces** (future `surfaces/*`) own all UI and presentation. They consume public API/client
contracts only, never Core or module internals directly. contracts only, never Core or module internals directly.
+7 -4
View File
@@ -33,10 +33,13 @@ pnpm infrastructure:config
- Expose supported entry points through package `exports`; do not import another package's - Expose supported entry points through package `exports`; do not import another package's
private source paths. private source paths.
- Use `workspace:` ranges for internal package dependencies. - Use `workspace:` ranges for internal package dependencies.
- Keep modules backend-only. Module packages cannot contain React, mobile, stylesheet, or other - Module packages cannot contain React, mobile, stylesheet, or other UI source files, and
UI source files. `pnpm boundaries` enforces this. ADR 0021 establishes that a module may own its own UI, but the
- Put rendering, layout, navigation, accessibility, and all other presentation concerns in a authoring model — and therefore which parts of that rule change — is a later Phase 6 decision.
surface. Until it lands, a module's optional `entrypoints.ui` artifact must be framework-neutral compiled
output, and nothing hosts it yet.
- The containing application shell, navigation, global theme, and platform policy belong to Core and
the host application, never to a module.
- Add or update tests when changing a contract or architectural rule. - Add or update tests when changing a contract or architectural rule.
- Record major architectural changes in an ADR rather than burying them in an implementation. - Record major architectural changes in an ADR rather than burying them in an implementation.
+32 -7
View File
@@ -1,8 +1,8 @@
# Ekklesia # Ekklesia
Ekklesia is an open, modular church technology platform for composing backend capabilities Ekklesia is an open, modular church technology platform. Each module is an independently versioned
into independently developed dashboards, websites, mobile apps, kiosks, displays, and other product that owns its domain behavior, data, and its own UI, composed into dashboards, websites,
surfaces. mobile apps, kiosks, displays, and other surfaces.
## Current status ## Current status
@@ -11,7 +11,7 @@ Phase 4C (Core authorization), Phase 5A (module authorization integration), Phas
transport), and Phase 5C (module database access) have landed. **Phase 5D (security hardening transport), and Phase 5C (module database access) have landed. **Phase 5D (security hardening
against an independent security audit) is complete: all five passes have landed and the final against an independent security audit) is complete: all five passes have landed and the final
independent whole-repository re-audit has closed, under the current trusted-contributor, independent whole-repository re-audit has closed, under the current trusted-contributor,
not-yet-deployed threat model — and Phase 6 (Surface work) is unblocked.** The operational not-yet-deployed threat model — and Phase 6 is unblocked and underway.** The operational
requirements that remain are prerequisites for two specific *future* activities — executing requirements that remain are prerequisites for two specific *future* activities — executing
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the
pass 5 closure below and pass 5 closure below and
@@ -60,9 +60,9 @@ on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint
accounts/API tokens remain later work. See [Authorization](docs/development/authorization.md) and accounts/API tokens remain later work. See [Authorization](docs/development/authorization.md) and
[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md). [ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md).
Phase 5A wires module-declared permissions into that same authorization system: a version-3 module Phase 5A wires module-declared permissions into that same authorization system: the module manifest
manifest declares permissions as complete `{id, baseline, description}` declarations (never a bare (version 3 at the time, version 4 today) declares permissions as complete
string), and `apps/api` discovers every installed module and composes one immutable platform `{id, baseline, description}` declarations (never a bare string), and `apps/api` discovers every installed module and composes one immutable platform
permission registry — Core's own declarations plus every module's — before constructing its permission registry — Core's own declarations plus every module's — before constructing its
database pool, Better Auth instance, or HTTP server. A module's declared permission is then database pool, Better Auth instance, or HTTP server. A module's declared permission is then
enforceable and grantable through the exact same `requirePermission` middleware and enforceable and grantable through the exact same `requirePermission` middleware and
@@ -218,6 +218,31 @@ development proceeds under the current trusted-contributor, not-yet-deployed mod
and job-token authority remain unverified and are not described as isolated. See and job-token authority remain unverified and are not described as isolated. See
[Security model and deployment trust boundaries](docs/development/security.md). [Security model and deployment trust boundaries](docs/development/security.md).
Phase 6A establishes the **module product contract** and nothing else yet. Ekklesia's modules are no
longer backend-only capability packages: a module is an independently versioned *product* that owns
its domain behavior, data, permissions, routes, migrations, and — new in this pass — **its own UI**.
Core keeps the containing application shell and every platform policy (authentication, authorization
enforcement, platform context, top-level routing, navigation, global theme, error boundaries,
trusted-origin/CORS). Modules stay **flat peers**: `video`, `livestream`, and `announcements` are
independent first-class modules with no parent/child relationship, and a Dashboard folder such as
`Media` is presentation configuration an administrator can reorganize, never module architecture and
never a dependency. The long-term goal is one module implementation hosted by Ekklesia *and*, later,
by a standalone application — never two codebases for one domain. See
[ADR 0021](docs/architecture/decisions/0021-modules-as-hostable-products.md).
**What this pass actually ships is the static contract only.** `manifestVersion: 4` adds one
optional field, `entrypoints.ui`, a package-relative path to a compiled ESM artifact validated
during discovery exactly like `server` and `schema` — inside the package, real file, no source
TypeScript. Discovery stays inert: **nothing imports, mounts, or renders that entrypoint**, exactly
like the long-declared `entrypoints.jobs`. Upgrading a backend-only version 3 manifest is a
one-character edit; version 3 is rejected outright rather than reinterpreted, as version 2 was
before it. **The Dashboard shell, a UI runtime or renderer, any UI framework choice, navigation
registration, Dashboard grouping, a standalone host, a module installer or marketplace, hot-loading,
and every feature module do not exist.** `pnpm boundaries` also still enforces
`modules-do-not-own-ui`, so module UI cannot yet be authored with a UI framework — choosing that
authoring model is the next pass's first question. Restart-based discovery, explicit
operator-invoked migrations, and the trusted compiled-module model are unchanged.
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary **Security model.** Installed modules are trusted compiled backend code that runs arbitrary
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
declarations, typed facades, and frozen contexts are correctness boundaries, not a sandbox. Ekklesia declarations, typed facades, and frozen contexts are correctness boundaries, not a sandbox. Ekklesia
+2 -2
View File
@@ -294,7 +294,7 @@ async function createGatedRouteModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
id, id,
manifestVersion: 3, manifestVersion: 4,
name: `${id} module`, name: `${id} module`,
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }], permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
version: "0.1.0", version: "0.1.0",
@@ -335,7 +335,7 @@ async function createFixtureModule(
): Promise<void> { ): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id); const moduleDirectory = path.join(modulesDirectory, id);
const manifest = { const manifest = {
manifestVersion: 3, manifestVersion: 4,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+2 -2
View File
@@ -67,7 +67,7 @@ async function createFixtureModule(
): Promise<void> { ): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id); const moduleDirectory = path.join(modulesDirectory, id);
const manifest = { const manifest = {
manifestVersion: 3, manifestVersion: 4,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -200,7 +200,7 @@ function fakeDiscoveredModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/does-not-exist/index.js" }, entrypoints: { server: "./dist/does-not-exist/index.js" },
id, id,
manifestVersion: 3, manifestVersion: 4,
name: `${id} module`, name: `${id} module`,
permissions, permissions,
version: "0.1.0", version: "0.1.0",
+9 -1
View File
@@ -45,6 +45,10 @@ export interface ResolvedModuleEntrypoints {
readonly schema?: string; readonly schema?: string;
readonly migrations?: string; readonly migrations?: string;
readonly jobs?: string; readonly jobs?: string;
/** A module's declared presentation artifact (ADR 0021), resolved and validated exactly like the
* other entrypoints. Discovery only proves the file exists, is compiled ESM, and stays inside the
* module package — nothing in this repository imports, evaluates, mounts, or renders it yet. */
readonly ui?: string;
} }
// Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types, // Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types,
@@ -243,8 +247,11 @@ async function resolveEntrypoints(
if (relativeEntrypoint === undefined) { if (relativeEntrypoint === undefined) {
continue; continue;
} }
// `ui` joins `schema`/`server` here: a presentation entrypoint is a build output like any
// other module artifact, so source TypeScript is rejected at discovery rather than relied upon
// to be compiled later (ADR 0006's compiled-artifact rule, extended by ADR 0021).
if ( if (
["schema", "server"].includes(entrypointName) && ["schema", "server", "ui"].includes(entrypointName) &&
![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase()) ![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase())
) { ) {
issues.push({ issues.push({
@@ -323,6 +330,7 @@ async function resolveEntrypoints(
...(resolved.schema ? { schema: resolved.schema } : {}), ...(resolved.schema ? { schema: resolved.schema } : {}),
...(resolved.migrations ? { migrations: resolved.migrations } : {}), ...(resolved.migrations ? { migrations: resolved.migrations } : {}),
...(resolved.jobs ? { jobs: resolved.jobs } : {}), ...(resolved.jobs ? { jobs: resolved.jobs } : {}),
...(resolved.ui ? { ui: resolved.ui } : {}),
}; };
} }
+1 -1
View File
@@ -91,7 +91,7 @@ async function createModule(
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
manifestVersion: 3, manifestVersion: 4,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+1 -1
View File
@@ -41,7 +41,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
}, },
entrypoints: { server: "./dist/server.js" }, entrypoints: { server: "./dist/server.js" },
id, id,
manifestVersion: 3, manifestVersion: 4,
name: `${id} module`, name: `${id} module`,
permissions: [], permissions: [],
storage: { buckets: [] }, storage: { buckets: [] },
@@ -1,5 +1,5 @@
{ {
"manifestVersion": 3, "manifestVersion": 4,
"id": "db-facade", "id": "db-facade",
"name": "Database Facade Integration Test Module", "name": "Database Facade Integration Test Module",
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.", "description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
+121 -1
View File
@@ -13,6 +13,9 @@ interface ModuleFixtureOptions {
manifest?: Record<string, unknown>; manifest?: Record<string, unknown>;
packageJson?: Record<string, unknown>; packageJson?: Record<string, unknown>;
serverSource?: string; serverSource?: string;
/** Extra package-relative files to write before discovery runs, so a fixture can publish a
* compiled UI artifact (or a deliberately uncompiled one) alongside its server entrypoint. */
extraFiles?: Record<string, string>;
} }
const temporaryDirectories: string[] = []; const temporaryDirectories: string[] = [];
@@ -31,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
function manifestFor(id: string): Record<string, unknown> { function manifestFor(id: string): Record<string, unknown> {
return { return {
manifestVersion: 3, manifestVersion: 4,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -73,6 +76,12 @@ async function createModule(
options.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`, options.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
), ),
]); ]);
for (const [relativePath, contents] of Object.entries(options.extraFiles ?? {})) {
const target = path.join(moduleDirectory, relativePath);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, contents);
}
} }
async function discoveryError( async function discoveryError(
@@ -585,4 +594,115 @@ describe("module discovery", () => {
expect(firstError.issues).toEqual(secondError.issues); expect(firstError.issues).toEqual(secondError.issues);
}); });
describe("module-owned UI entrypoint (ADR 0021)", () => {
function manifestWithUi(id: string, ui: string): Record<string, unknown> {
const manifest = manifestFor(id);
manifest.entrypoints = { server: "./dist/server/index.js", ui };
return manifest;
}
it("resolves a declared compiled UI entrypoint to a canonical path inside the package", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "announcements", {
manifest: manifestWithUi("announcements", "./dist/ui/index.js"),
extraFiles: { "dist/ui/index.js": "export default {};" },
});
const [discovered] = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
expect(discovered?.entrypoints.ui).toBe(
path.join(discovered?.directory ?? "", "dist/ui/index.js"),
);
expect(discovered?.entrypoints.server).toBeDefined();
});
it("omits the resolved UI entrypoint entirely for a backend-only module", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "roster");
const [discovered] = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
expect(discovered?.entrypoints.ui).toBeUndefined();
expect(Object.hasOwn(discovered?.entrypoints ?? {}, "ui")).toBe(false);
});
it("rejects a source TypeScript UI entrypoint where compiled output is required", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "signage", {
manifest: manifestWithUi("signage", "./src/ui/index.ts"),
extraFiles: { "src/ui/index.ts": "export default {};" },
});
const error = await discoveryError(modulesDirectory);
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_INVALID");
expect(error.issues.map((issue) => issue.path)).toContain("entrypoints.ui");
});
it("rejects a missing UI artifact rather than silently ignoring the declaration", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "video", {
manifest: manifestWithUi("video", "./dist/ui/index.js"),
});
const error = await discoveryError(modulesDirectory);
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_MISSING");
});
it("rejects a UI entrypoint that resolves to a directory instead of a file", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "livestream", {
manifest: manifestWithUi("livestream", "./dist/ui.js"),
extraFiles: { "dist/ui.js/placeholder.txt": "not a file entrypoint" },
});
const error = await discoveryError(modulesDirectory);
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_INVALID");
});
it("rejects a UI entrypoint linked outside its own module package", async () => {
const modulesDirectory = await createModulesDirectory();
const externalDirectory = await createModulesDirectory();
const externalArtifact = path.join(externalDirectory, "foreign-ui.js");
await writeFile(externalArtifact, "export default {};");
await createModule(modulesDirectory, "calendar", {
manifest: manifestWithUi("calendar", "./dist/ui/index.js"),
});
await mkdir(path.join(modulesDirectory, "calendar/dist/ui"), { recursive: true });
await symlink(externalArtifact, path.join(modulesDirectory, "calendar/dist/ui/index.js"));
const error = await discoveryError(modulesDirectory);
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_INVALID");
});
it("never imports a UI artifact merely to discover it", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "announcements", {
manifest: manifestWithUi("announcements", "./dist/ui/index.js"),
// Discovery is inert: a throwing artifact must not be evaluated. If discovery ever
// imported a UI entrypoint, this would surface as a rejected promise rather than a
// successfully discovered module.
extraFiles: {
"dist/ui/index.js": "throw new Error('a UI entrypoint must never be evaluated');",
},
});
const discovered = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
expect(discovered).toHaveLength(1);
expect(discovered[0]?.entrypoints.ui).toContain("dist/ui/index.js");
});
});
}); });
@@ -65,7 +65,7 @@ function manifestFor(
} = {}, } = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 3, manifestVersion: 4,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+1 -1
View File
@@ -23,7 +23,7 @@ interface PermissionInput {
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */ * is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule { function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 3, manifestVersion: 4,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+1 -1
View File
@@ -52,7 +52,7 @@ function manifestFor(
} = {}, } = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 3, manifestVersion: 4,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+1 -1
View File
@@ -41,7 +41,7 @@ function manifestFor(
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {}, options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 3, manifestVersion: 4,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -11,6 +11,16 @@ would couple business behavior to a particular presentation framework.
## Decision ## Decision
> **Partial correction — see [ADR 0021](0021-modules-as-hostable-products.md).**
> The rule below that a module exposes "data and behavior only" and that "every renderer and UI
> concern belongs to a surface" was superseded by ADR 0021: a module may own its own UI and ship it
> as part of an independently versioned product, hostable by Ekklesia and, later, by a standalone
> host. The four ownership layers themselves, Core's feature-agnostic role, the prohibition on Core
> importing a module, and every other boundary in this ADR remain accepted and unchanged — Core
> still owns the containing application shell and all platform policy. This section is left as
> originally accepted, as the historical record.
Adopt four explicit ownership layers: Core orchestration, backend Modules, presentation Surfaces, Adopt four explicit ownership layers: Core orchestration, backend Modules, presentation Surfaces,
and declarative Surface Builder configuration. Begin as a modular monolith. Modules expose data and declarative Surface Builder configuration. Begin as a modular monolith. Modules expose data
and behavior only; every renderer and UI concern belongs to a surface. and behavior only; every renderer and UI concern belongs to a surface.
@@ -50,6 +50,13 @@ server entrypoint, job, or lifecycle hook to discover its permissions; only the
### Manifest version 3 replaces version 2's bare permission strings ### Manifest version 3 replaces version 2's bare permission strings
> **Partial correction — see [ADR 0021](0021-modules-as-hostable-products.md).**
> `manifestVersion: 3` was superseded by ADR 0021's `manifestVersion: 4`, which adds the optional
> `entrypoints.ui` field. The permission-declaration shape this section establishes, the reserved
> `core.*` namespace, the composed platform registry, and the inert-manifest discovery model are
> carried forward into version 4 unchanged; only the accepted version literal moved. The
> version-rejection rule described below applies identically to version 3 under version 4.
Representing a permission as a complete declaration instead of a bare identifier string is an Representing a permission as a complete declaration instead of a bare identifier string is an
incompatible manifest shape change, so the canonical manifest moves to `manifestVersion: 3` incompatible manifest shape change, so the canonical manifest moves to `manifestVersion: 3`
(`z.literal(3)`). Version 2 — and any other version — fails validation outright; there is no (`z.literal(3)`). Version 2 — and any other version — fails validation outright; there is no
@@ -0,0 +1,235 @@
# 0021: Modules as independently hostable products
- Status: Accepted
- Date: 2026-08-21
- Extends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
[0014](0014-module-permission-declarations-and-platform-registry.md)
- Supersedes (partial): [0001](0001-four-layer-platform-boundary.md) — 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](0014-module-permission-declarations-and-platform-registry.md) — 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:
```text
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](../../development/security.md).
## 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
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.
+1
View File
@@ -29,3 +29,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0018: Identity session resolution, canonical public origins, and PostgreSQL transport policy](0018-identity-session-resolution-and-database-transport.md) — Accepted - [0018: Identity session resolution, canonical public origins, and PostgreSQL transport policy](0018-identity-session-resolution-and-database-transport.md) — Accepted
- [0019: Durable authorization history and grant management](0019-durable-authorization-history-and-grant-management.md) — Accepted - [0019: Durable authorization history and grant management](0019-durable-authorization-history-and-grant-management.md) — Accepted
- [0020: Module runtime and migration execution hardening](0020-module-runtime-and-migration-execution-hardening.md) — Accepted - [0020: Module runtime and migration execution hardening](0020-module-runtime-and-migration-execution-hardening.md) — Accepted
- [0021: Modules as independently hostable products](0021-modules-as-hostable-products.md) — Accepted
+38 -12
View File
@@ -4,10 +4,12 @@ Ekklesia is a long-lived platform from which churches can compose many applicati
experiences. It begins as a modular monolith so contracts can stabilize before distribution is experiences. It begins as a modular monolith so contracts can stabilize before distribution is
introduced. introduced.
> **Trust boundary.** Installed modules are trusted compiled backend code, not sandboxed > **Trust boundary.** Installed modules are trusted compiled code, not sandboxed plugins: a module
> plugins: a module runs arbitrary JavaScript in the API process and shares its PostgreSQL > runs arbitrary JavaScript in the API process and shares its PostgreSQL role. The layering
> role. The layering described below is a correctness and ownership boundary, not hostile-code > described below is a correctness and ownership boundary, not hostile-code isolation. A module that
> isolation. See [Security model and deployment trust boundaries](../development/security.md). > ships its own UI ([ADR 0021](decisions/0021-modules-as-hostable-products.md)) is exactly as
> trusted as one that does not. See
> [Security model and deployment trust boundaries](../development/security.md).
## Dependency model ## Dependency model
@@ -27,16 +29,26 @@ The four ownership layers are:
1. **Core** orchestrates generic platform infrastructure: startup, identity, permissions, 1. **Core** orchestrates generic platform infrastructure: startup, identity, permissions,
configuration, database connectivity, storage, events, jobs, module discovery, and lifecycle. configuration, database connectivity, storage, events, jobs, module discovery, and lifecycle.
2. **Modules** own backend domain behavior, data schemas, resources, actions, routes, events, 2. **Modules** own domain behavior, data schemas, resources, actions, routes, events, permissions,
permissions, and jobs. jobs — and, per [ADR 0021](decisions/0021-modules-as-hostable-products.md), their own UI. A module
3. **Surfaces** own every presentation concern: components, renderers, layout, navigation, is an independently versioned product, not only a backend capability. It does not own the
accessibility, interaction, branding, and client state. 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 4. **Surface Builder** stores validated declarative composition. It selects capabilities and
surface-owned renderers but contains neither executable code nor module business logic. surface-owned renderers but contains neither executable code nor module business logic.
Modules do not own UI. For example, Calendar data, actions, and permissions will live in A module owns its own product experience. Calendar data, actions, permissions, and its own
`modules/calendar`; Calendar renderers for a dashboard or public website will live in those management UI live in `modules/calendar`; the dashboard or public-website shell that hosts it —
surfaces. 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 ## Stable seams
@@ -151,7 +163,21 @@ blocks Phase 6. Read
does and does not protect against, and the repository root does and does not protect against, and the repository root
[README](../../README.md#current-status) for the current status of every phase. [README](../../README.md#current-status) for the current status of every phase.
The module platform's backend foundations are now in place and Phase 6 Surface work is unblocked. **Phase 6A establishes the module product contract.**
[ADR 0021](decisions/0021-modules-as-hostable-products.md) 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 this pass actually implements 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. The Dashboard shell, the UI runtime, the standalone
host, module UI authoring (including the still-enforced `modules-do-not-own-ui` boundary rule), 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 Target architecture not yet implemented includes production approval-provider
composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource 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 and action contracts, a Dashboard, a Builder, and reference feature modules; none of these are
+9 -3
View File
@@ -7,7 +7,7 @@ These rules map platform ownership to filesystem and package boundaries. The exe
| --- | --- | --- | | --- | --- | --- |
| `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app | | `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app |
| `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries | | `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries |
| `modules/*` | Backend domain capability and data | Module SDK, public contracts, and external backend libraries | | `modules/*` | Domain capability, data, and (per ADR 0021) the module's own UI | Module SDK, public contracts, and external backend libraries |
| `surfaces/*` | UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, public contracts, and design packages | | `surfaces/*` | UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, public contracts, and design packages |
| `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts | | `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts |
| `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level | | `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level |
@@ -19,7 +19,12 @@ These rules map platform ownership to filesystem and package boundaries. The exe
- A module never imports Core implementations, another module, a surface, Builder code, or an app. - A module never imports Core implementations, another module, a surface, Builder code, or an app.
- A module cannot contain presentation source, including JSX/TSX, single-file components, - A module cannot contain presentation source, including JSX/TSX, single-file components,
Astro/Marko/MDX/HTML views, stylesheets, or SVG presentation assets, and cannot depend on UI Astro/Marko/MDX/HTML views, stylesheets, or SVG presentation assets, and cannot depend on UI
frameworks or shared UI-role packages. Modules expose data and behavior only. frameworks or shared UI-role packages. **This rule is still enforced, but it is no longer the
architectural direction.** [ADR 0021](decisions/0021-modules-as-hostable-products.md) establishes
that a module may own its own UI and declare a compiled `entrypoints.ui` artifact. The deny list
above stays in force until a later Phase 6 pass chooses the module UI authoring model and decides
what replaces it; until then a module UI must be a framework-neutral compiled artifact. Core still
owns the containing application shell and all platform policy.
- A surface never imports Core or a feature module. It uses public API/client contracts. - A surface never imports Core or a feature module. It uses public API/client contracts.
- Separate surfaces do not import each other's internals. - Separate surfaces do not import each other's internals.
- Builder code composes contracts and surface renderer identifiers; it does not import module or - Builder code composes contracts and surface renderer identifiers; it does not import module or
@@ -39,7 +44,8 @@ The boundary test suite includes intentionally invalid fixture graphs. This prov
rejects every currently machine-checkable repository rule even before production packages fill rejects every currently machine-checkable repository rule even before production packages fill
every directory. The UI guard rejects component, view, template, stylesheet, shared UI-role, and every directory. The UI guard rejects component, view, template, stylesheet, shared UI-role, and
common UI-framework dependencies inside modules. This deny list is defense in depth; package common UI-framework dependencies inside modules. This deny list is defense in depth; package
review remains required when adopting a new frontend technology. review remains required when adopting a new frontend technology — and revisiting the guard itself is
an explicit prerequisite of the module UI authoring pass (ADR 0021).
The Phase 3 schema validator complements source boundaries by checking table ownership, foreign-key The Phase 3 schema validator complements source boundaries by checking table ownership, foreign-key
targets, and physical table namespaces in compiled Drizzle metadata. Offline migration validation targets, and physical table namespaces in compiled Drizzle metadata. Offline migration validation
+2 -2
View File
@@ -111,7 +111,7 @@ below to build on without reconstructing Core's own list:
## Module permission composition ## Module permission composition
A module manifest's `permissions` array (`manifestVersion: 3`, A module manifest's `permissions` array (`manifestVersion: 4`,
[Module development](../module-development/README.md#canonical-manifest)) declares permissions in [Module development](../module-development/README.md#canonical-manifest)) declares permissions in
the exact same `PermissionDeclaration` shape — but declaring one there only proves it is the exact same `PermissionDeclaration` shape — but declaring one there only proves it is
well-formed and correctly namespaced under the module's own id; it does not, by itself, register well-formed and correctly namespaced under the module's own id; it does not, by itself, register
@@ -593,7 +593,7 @@ module database usage), and exactly one `pool.end()` call regardless of how many
- `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation: - `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation:
every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description
rejection, malformed permission id rejection. rejection, malformed permission id rejection.
- `packages/contracts/tests/module-manifest.test.ts` — `manifestVersion: 3` acceptance and version - `packages/contracts/tests/module-manifest.test.ts` — `manifestVersion: 4` acceptance and version
2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above), 2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above),
module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue
path, and default-empty-list isolation across separate parses. path, and default-empty-list isolation across separate parses.
+7
View File
@@ -53,6 +53,13 @@ cooperative. None of them constrains a module that is not:
schema-permission, table-ACL, row-level-security, tenant, or resource sandbox between a module and schema-permission, table-ACL, row-level-security, tenant, or resource sandbox between a module and
Core.** A module is not isolated from Core, from other modules, or from the host. Core.** A module is not isolated from Core, from other modules, or from the host.
[ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md) lets a module declare its
own UI entrypoint. **That changes nothing on this page.** A module that ships UI is exactly as
trusted as one that does not: its server entrypoint still runs in the API process with the authority
described above, and declaring `entrypoints.ui` neither adds nor implies any isolation. No host
loads a UI entrypoint yet; when one does, the trust model for whatever it executes must be decided
explicitly and recorded — it does not inherit a boundary from this section, because none exists.
## What this platform must not claim ## What this platform must not claim
Ekklesia today has **none** of the following, and no document, README, or marketing surface may Ekklesia today has **none** of the following, and no document, README, or marketing surface may
+101 -10
View File
@@ -1,7 +1,19 @@
# Module development # Module development
An Ekklesia module is a backend capability package. It owns business behavior and data declarations; An Ekklesia module is an independently versioned, first-class product. It owns its domain behavior,
it never owns components, styles, layouts, renderers, navigation, or other UI. 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 > **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 > module runs arbitrary JavaScript inside the API process and shares its PostgreSQL role, and
@@ -54,7 +66,7 @@ without executing module code:
```json ```json
{ {
"manifestVersion": 3, "manifestVersion": 4,
"id": "calendar", "id": "calendar",
"name": "Calendar", "name": "Calendar",
"version": "0.1.0", "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 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. 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 `manifestVersion: 4` (`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 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. 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 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 with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what `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* own business logic, are both still later work; this only makes a declared permission *usable*
through Core's existing, generic surface. through Core's existing, generic surface.
Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files. The optional Server and optional schema and UI entrypoints must be compiled ESM `.js` or `.mjs` files. The
migration entrypoint is a directory. All entrypoints begin with `./`, remain inside the package, optional migration entrypoint is a directory. All entrypoints begin with `./`, remain inside the
and exist. Production never imports source TypeScript directly. Modules with no tables omit 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 `entrypoints.schema`; modules with no migration history omit `entrypoints.migrations`; modules with
manifest properties are rejected, including presentation fields such as components or renderers. 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. 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; 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. 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 ## Runtime server definition
The source entrypoint exports one typed runtime definition and does not repeat the static manifest: The source entrypoint exports one typed runtime definition and does not repeat the static manifest:
+28 -1
View File
@@ -59,12 +59,26 @@ const moduleStorageSchema = z
.strict() .strict()
.default(() => ({ buckets: [] })); .default(() => ({ buckets: [] }));
/**
* The compiled artifacts a module publishes for the platform to resolve. Every entry is a
* package-relative path to a build output, never source: Core resolves and validates these
* statically during discovery, and the manifest stays inert JSON (ADR 0006, ADR 0021).
*
* `ui` is a module's optional, host-neutral presentation entrypoint (ADR 0021). It is declared and
* statically validated here, exactly like `jobs` already is, but **no runtime consumer exists yet**
* — nothing in this repository imports, evaluates, mounts, or renders it. Its name is deliberately
* not `dashboard`: a module's UI must be hostable by Ekklesia's own application shell *and* by a
* future standalone host, so the contract cannot be named after one of them. It is deliberately not
* `surface` either — `surfaces/*` is a separate ownership layer (ADR 0001), and reusing that word
* for a module-owned artifact would conflate two different things.
*/
const entrypointsSchema = z const entrypointsSchema = z
.object({ .object({
server: packageRelativePathSchema, server: packageRelativePathSchema,
schema: packageRelativePathSchema.optional(), schema: packageRelativePathSchema.optional(),
migrations: packageRelativePathSchema.optional(), migrations: packageRelativePathSchema.optional(),
jobs: packageRelativePathSchema.optional(), jobs: packageRelativePathSchema.optional(),
ui: packageRelativePathSchema.optional(),
}) })
.strict(); .strict();
@@ -87,9 +101,22 @@ function reportDuplicates(
}); });
} }
/**
* The one manifest shape Core accepts. Adding `entrypoints.ui` is an incompatible shape change —
* version 3's `entrypoints` object is `.strict()` and rejects `ui` outright — so the canonical
* manifest moves to `manifestVersion: 4` rather than reinterpreting version 3 (ADR 0021).
*
* Version 3, and 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
* read as two different versions. Upgrading a backend-only version 3 manifest is a one-character
* edit: no field added by version 4 is required.
*/
export const MODULE_MANIFEST_VERSION = 4;
export const moduleManifestSchema = z export const moduleManifestSchema = z
.object({ .object({
manifestVersion: z.literal(3), manifestVersion: z.literal(MODULE_MANIFEST_VERSION),
id: moduleIdSchema, id: moduleIdSchema,
name: z.string().trim().min(1).max(120), name: z.string().trim().min(1).max(120),
description: z.string().trim().min(1).max(500).optional(), description: z.string().trim().min(1).max(500).optional(),
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
isVersionCompatible, isVersionCompatible,
MODULE_LIFECYCLE_STAGES, MODULE_LIFECYCLE_STAGES,
MODULE_MANIFEST_VERSION,
packageRelativePathSchema, packageRelativePathSchema,
parseModuleManifest, parseModuleManifest,
parsePermissionId, parsePermissionId,
@@ -10,7 +11,7 @@ import {
function validManifest(): Record<string, unknown> { function validManifest(): Record<string, unknown> {
return { return {
manifestVersion: 3, manifestVersion: 4,
id: "calendar", id: "calendar",
name: "Calendar", name: "Calendar",
version: "0.1.0", version: "0.1.0",
@@ -193,10 +194,10 @@ describe("module manifest contract", () => {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 1 }).success).toBe(false); expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 1 }).success).toBe(false);
}); });
it("rejects a version 2 manifest rather than silently interpreting it as version 3", () => { it("rejects a version 2 manifest rather than silently interpreting it as a later version", () => {
// manifestVersion 2's own permissions shape (a bare string array) is also what version 3 // manifestVersion 2's own permissions shape (a bare string array) is also what every later
// must reject — this manifest is invalid twice over, deliberately, since a real version 2 // version must reject — this manifest is invalid twice over, deliberately, since a real
// manifest is exactly this shape. // version 2 manifest is exactly this shape.
const version2Manifest = { const version2Manifest = {
...validManifest(), ...validManifest(),
manifestVersion: 2, manifestVersion: 2,
@@ -206,6 +207,25 @@ describe("module manifest contract", () => {
expect(safeParseModuleManifest(version2Manifest).success).toBe(false); expect(safeParseModuleManifest(version2Manifest).success).toBe(false);
}); });
it("rejects a version 3 manifest rather than silently interpreting it as version 4", () => {
// A backend-only version 3 manifest is otherwise byte-identical to a valid version 4 one:
// only the version literal separates them, and it is not reinterpreted.
const version3Manifest = { ...validManifest(), manifestVersion: 3 };
expect(safeParseModuleManifest(version3Manifest).success).toBe(false);
});
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
expect(MODULE_MANIFEST_VERSION).toBe(4);
for (const manifestVersion of [1, 2, 3, 5, "4", 4.5, null]) {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
}
expect(
safeParseModuleManifest({ ...validManifest(), manifestVersion: MODULE_MANIFEST_VERSION })
.success,
).toBe(true);
});
it("rejects a bare permission-id string array instead of complete declarations", () => { it("rejects a bare permission-id string array instead of complete declarations", () => {
const manifest = { ...validManifest(), permissions: ["calendar.events.read"] }; const manifest = { ...validManifest(), permissions: ["calendar.events.read"] };
@@ -325,4 +345,115 @@ describe("module manifest contract", () => {
expect(MODULE_LIFECYCLE_STAGES).toContain("discovered"); expect(MODULE_LIFECYCLE_STAGES).toContain("discovered");
expect(MODULE_LIFECYCLE_STAGES).toContain("failed"); expect(MODULE_LIFECYCLE_STAGES).toContain("failed");
}); });
describe("module-owned UI entrypoint (ADR 0021)", () => {
it("accepts a backend-only module that declares no UI at all", () => {
const manifest = parseModuleManifest(validManifest());
expect(manifest.entrypoints.ui).toBeUndefined();
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
});
it("accepts a declared compiled UI entrypoint alongside the server entrypoint", () => {
const manifest = parseModuleManifest({
...validManifest(),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
});
expect(manifest.entrypoints.ui).toBe("./dist/ui/index.js");
// Declaring UI never alters the backend contract: a product module is one module, not two.
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
});
it.each([
["../outside/ui.js", "escapes the package boundary"],
["/absolute/ui.js", "is an absolute path"],
["./dist/../../ui.js", "traverses out through a parent segment"],
["file:///dist/ui/index.js", "is a URL rather than a package-relative path"],
["./dist\\ui\\index.js", "uses backslash separators"],
["./dist/ui/index.js?raw", "carries a query string"],
["", "is empty"],
])("rejects a UI entrypoint that %s", (ui) => {
const manifest = {
...validManifest(),
entrypoints: { server: "./dist/server/index.js", ui },
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it.each([
42,
null,
true,
["./dist/ui/index.js"],
{ entry: "./dist/ui/index.js" },
])("rejects a non-string UI entrypoint %s", (ui) => {
const manifest = {
...validManifest(),
entrypoints: { server: "./dist/server/index.js", ui },
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("still rejects unknown entrypoint keys, so UI is the only new one", () => {
for (const unknownEntrypoint of ["dashboard", "surface", "client", "web", "renderers"]) {
const manifest = {
...validManifest(),
entrypoints: {
server: "./dist/server/index.js",
[unknownEntrypoint]: "./dist/ui/index.js",
},
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
}
});
it("declares no grouping, folder, parent, or menu field for a module to own", () => {
// Dashboard organization is presentation configuration, never module architecture
// (ADR 0021). The manifest must offer no place to encode a hierarchy.
for (const presentationField of [
"group",
"folder",
"parent",
"menu",
"navigation",
"icon",
"order",
]) {
const manifest = { ...validManifest(), [presentationField]: "media" };
expect(safeParseModuleManifest(manifest).success).toBe(false);
}
});
it("keeps the module dependency graph flat: UI adds no parent/child relationship", () => {
const manifest = parseModuleManifest({
...validManifest(),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
});
// A module that ships UI still expresses relationships only through genuine module and
// capability dependencies — the same two mechanisms a backend-only module uses.
expect(Object.keys(manifest.dependencies.modules.required)).toEqual([]);
expect(Object.keys(manifest.dependencies.modules.optional)).toEqual([]);
expect(Object.keys(manifest)).not.toContain("children");
});
it("still supports a genuine module dependency for a module that ships UI", () => {
const manifest = parseModuleManifest({
...validManifest(),
dependencies: {
modules: { required: { people: "^0.1.0" } },
capabilities: { required: { "people.records": "^2.0.0" } },
},
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
});
expect(manifest.dependencies.modules.required).toEqual({ people: "^0.1.0" });
expect(manifest.dependencies.capabilities.required).toEqual({ "people.records": "^2.0.0" });
});
});
}); });
+7
View File
@@ -8,6 +8,13 @@ route (`GET /records`, declared in `register`) that reads that table through
module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage
provisioning are intentionally not included. provisioning are intentionally not included.
This template ships backend behavior only, which is a choice rather than a constraint: as of
[ADR 0021](../../docs/architecture/decisions/0021-modules-as-hostable-products.md) a module may also
declare an optional compiled `entrypoints.ui` artifact. The template does not, because no host loads
a UI entrypoint yet — see
[Declaring a UI entrypoint](../../docs/module-development/README.md#declaring-a-ui-entrypoint) for
what is and is not supported today.
To create a module inside this repository: To create a module inside this repository:
1. Copy this directory to `modules/<module-id>`. 1. Copy this directory to `modules/<module-id>`.
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"manifestVersion": 3, "manifestVersion": 4,
"id": "example", "id": "example",
"name": "Example Module", "name": "Example Module",
"description": "A minimal backend-only Ekklesia module template.", "description": "A minimal Ekklesia module template with backend behavior only.",
"version": "0.1.0", "version": "0.1.0",
"compatibility": { "compatibility": {
"platform": "^0.1.0", "platform": "^0.1.0",