Files
Ekklesia/docs/architecture/platform-architecture.md
T
ekklesiaandClaude Opus 5 44ba259f58
CI / verify (pull_request) Successful in 2m31s
CI / infrastructure-smoke (pull_request) Successful in 15s
CI / migration-smoke (pull_request) Successful in 49s
feat: establish the module product contract
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]>
2026-08-21 20:20:41 -05:00

185 lines
12 KiB
Markdown

# Platform architecture
Ekklesia is a long-lived platform from which churches can compose many applications and user
experiences. It begins as a modular monolith so contracts can stabilize before distribution is
introduced.
> **Trust boundary.** Installed modules are trusted compiled code, not sandboxed plugins: a module
> runs arbitrary JavaScript in the API process and shares its PostgreSQL role. The layering
> described below is a correctness and ownership boundary, not hostile-code isolation. A module that
> ships its own UI ([ADR 0021](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
The arrows below mean "depends on." Presentation and composition depend on stable lower-level
contracts; infrastructure never learns individual feature names.
```text
Surface Builder configuration -> Surfaces -> public capability contracts
^
|
HTTP/API and client contracts
|
Feature Modules -> Module SDK / platform contracts <- Core implementations
```
The four ownership layers are:
1. **Core** orchestrates generic platform infrastructure: startup, identity, permissions,
configuration, database connectivity, storage, events, jobs, module discovery, and lifecycle.
2. **Modules** own domain behavior, data schemas, resources, actions, routes, events, permissions,
jobs — and, per [ADR 0021](decisions/0021-modules-as-hostable-products.md), their own UI. A module
is an independently versioned product, not only a backend capability. It does not own the
containing application shell or any platform policy.
3. **Surfaces** own the presentation concerns a module does not: the containing application shell,
layout, navigation, accessibility, interaction, branding, and client state — including the shell
that hosts a module's own UI.
4. **Surface Builder** stores validated declarative composition. It selects capabilities and
surface-owned renderers but contains neither executable code nor module business logic.
A module owns its own product experience. Calendar data, actions, permissions, and its own
management UI live in `modules/calendar`; the dashboard or public-website shell that hosts it —
navigation, theme, layout, and platform chrome — lives in a surface. The module supplies the screen
for its feature; the surface supplies the application around it.
Modules are flat peers. `video`, `livestream`, `announcements`, and `calendar` are independent
first-class modules with no parent/child relationship. A Dashboard may group them into folders such
as `Media` or `Sunday Production`, but those groups are presentation configuration an administrator
can reorganize, never module ownership and never a dependency. Genuine module and capability
dependencies remain the only way one module may require another.
## Stable seams
- Modules use public SDK ports and injected Core services, never undocumented Core internals.
- Modules do not import another module's schema or private code. Integrations use public service
contracts, capabilities, events, or APIs.
- Surfaces consume API/client and capability contracts, never feature-module packages.
- Core discovers feature modules generically. It contains no feature-specific imports or
conditional branches.
- Deployable apps are thin composition roots and do not become domain-logic owners.
- Generated bootstrap artifacts may connect discovered entry points at the application edge;
they do not become manually maintained Core registries.
## Security invariants
- Authorization is enforced server-side.
- Secrets are isolated from public configuration and logs.
- Authentication tokens, private form content, prayer requests, and pastoral information are not
logged.
- Sensitive actions are designed for auditability without copying sensitive content into audit
records.
- Private objects use signed access and namespaced storage paths.
- Every network service starts from least-privilege and local-only development defaults.
## Current implementation status
Implemented now: the Phase 1 repository and infrastructure foundation plus public module contracts,
static versioned manifests, independent capability versions and dependency ranges, automatic
filesystem discovery, compiled server loading, runtime-definition verification, staged capability
registration, dependency-ordered startup and reverse shutdown, rollback, and a backend-only module
template. Optional compiled schema entrypoints now contribute validated, namespaced module-owned
Drizzle tables to a deterministic aggregate without connecting to a database. Optional module-owned
Drizzle migration directories now produce an atomic dependency-ordered offline inventory with
validated metadata, explicit destructive-change declarations, and exact SHA-256 checksums. Core can
now compare that inventory with its fixed PostgreSQL history table, produce a read-only plan, and
apply the complete pending batch under a session advisory lock and one transaction. Destructive
migrations require an exact externally supplied approval bound to module, identity, and checksum.
Hermetic integration coverage now exercises those contracts through the production adapter against
the pinned PostgreSQL container, including real DDL rollback and advisory-lock concurrency. The
Core-owned Doctor composes those existing services into a deterministic terminal readiness report:
static module, schema, and migration validation always run when their prerequisites succeed, while
PostgreSQL history planning runs only when configured. Schema inspection evaluates compiled schema
definition entrypoints, which the module contract requires to be side-effect-free; Doctor never
imports or starts module server entrypoints. It does not apply migrations or modify PostgreSQL, and
it honestly skips object storage because no production Core storage service exists yet.
Phase 4A added the Core identity foundation. Core owns a Better Auth-backed identity schema
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
`ekklesia_core_verification`) reachable only through Ekklesia-owned contracts and an explicit
Core-owned Better Auth construction boundary; modules never import Better Auth or receive a raw
database client. That schema ships as a first-class Core-owned migration source that reuses the
exact migration format, checksum rule, planner, and transactional runner Phase 3 already
established, ordered before module migrations in the same aggregate. See
[ADR 0010](decisions/0010-core-identity-and-better-auth-adoption.md).
Phase 4B adds identity HTTP transport: a minimal Hono-based `apps/api` composition root mounts
Better Auth at `/api/auth/*` and exposes one Ekklesia-owned route, `GET /api/v1/principal`, behind
Core's own Fetch-compatible identity runtime (`@ekklesia/core/identity`) — Core still does not
depend on Hono. Public email/password registration is the intended eventual product policy — every
account receives identical lowest-privilege baseline status, and signup order never grants
elevation — but is disabled by default as of Phase 5D pass 1, below. See
[ADR 0011](decisions/0011-identity-http-transport-and-public-enrollment.md) and
[the API guide](../development/api.md), and
[the identity development guide](../development/identity.md) for the underlying schema and
contracts. It is now possible to publicly register, sign in, resolve a principal, and sign out.
Phase 4C is complete: Core's authorization foundation — a schema, evaluator, and store for
platform owners, explicit grants, and relationship-derived grants, plus an immutable permission
registry — and an explicit, single-use, auditable platform-owner bootstrap command, plus reusable
Hono enforcement and an `/api/v1/grants` HTTP API in `apps/api`. See
[ADR 0013](decisions/0013-core-authorization-and-owner-bootstrap.md) and
[the authorization guide](../development/authorization.md).
Phase 5A wired module-declared permissions into that same authorization system — a version-3
manifest, one composed platform permission registry spanning Core's own declarations and every
installed module's, enforced through the identical middleware Core's own routes use. Phase 5B let a
module mount its own framework-neutral HTTP routes in production, under
`/api/v1/modules/<module-id>`, enforced and response-sanitized exactly like Core's own routes. Phase
5C added typed module database access (`context.services.database`) for a module's lifecycle hooks
and authorized HTTP handlers, over the platform's one existing PostgreSQL pool.
See [ADR 0014](decisions/0014-module-permission-declarations-and-platform-registry.md),
[ADR 0015](decisions/0015-static-module-http-routing-and-runtime-lifecycle.md),
[ADR 0016](decisions/0016-module-database-access.md), and the repository root
[README](../../README.md#current-status) for the complete, current status of every phase.
**Phase 5D is complete.** Five passes hardened the platform against an independent security audit of
the Phase 5C checkpoint, and an independent whole-repository re-audit closed the phase:
1. the public request perimeter — a server-owned client-address resolution boundary for the
authentication rate limiter, public sign-up disabled by default behind an explicit typed opt-in,
an enforced request-body ceiling on every public route (including a module route's own declared
contract), and finite Node header/request timeouts;
2. identity session resolution, and a mandatory certificate- **and** hostname-verified PostgreSQL
transport with no plaintext or `rejectUnauthorized: false` exception;
3. durable authorization history that survives user deletion, a one-time owner bootstrap that
deleting a user cannot reopen, and bounded, unambiguous explicit-grant management;
4. module route-overlap rejection at registration, finite startup rollback and shutdown deadlines,
and hardened migration command timeouts and SQL execution;
5. deployment and supply-chain closure plus the final re-audit.
See [ADR 0017](decisions/0017-public-request-perimeter-hardening.md),
[ADR 0018](decisions/0018-identity-session-resolution-and-database-transport.md),
[ADR 0019](decisions/0019-durable-authorization-history-and-grant-management.md), and
[ADR 0020](decisions/0020-module-runtime-and-migration-execution-hardening.md).
These guarantees hold under a **trusted-contributor, not-yet-deployed** threat model. The remaining
limitations are operational rather than architectural: CI is a trusted-code-only boundary whose
runner topology and job-token authority are unverified, and Ekklesia is not deployed, so the origin,
proxy, and throttling properties a public launch requires are unproven. Neither set of requirements
blocks Phase 6. Read
[Security model and deployment trust boundaries](../development/security.md) for what each boundary
does and does not protect against, and the repository root
[README](../../README.md#current-status) for the current status of every phase.
**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
composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource
and action contracts, a Dashboard, a Builder, and reference feature modules; none of these are
scheduled by this document.