Modules had no way to be told anything. Announcements — the first real product Ekklesia will absorb — has zero environment variables and compiles a LAN IP address into its source, so this is the contract standing between it and the module system. - A module declares what it needs in its manifest, using six bounded types (string, integer, boolean, enum, url, secret) with no nesting and no regular expressions. Static declaration is what lets the platform validate configuration *before* deciding whether a module may start: an enabled module missing a required value is reported `blocked · configuration_missing · <key>` rather than taking startup down from inside its own `start` hook, and Doctor reports the same while still importing no module code. That forces `manifestVersion: 5`, a hard cutover. - Ordinary values persist in one Core table (migration 0005), one row per (module_id, key), stored as text until validated against the declaring module's current manifest. Nothing is coerced; an invalid stored value blocks the module rather than falling back to the default. A key the manifest no longer declares is retained and ignored. - Secrets are never persisted. There is no encryption at rest in this repository, so a `secret` key is deployment-supplied and mapped in by the host under a name the module never sees. A secret declaration has no `default` field, the write API refuses secret material, and the HTTP view has no `value` field for one — absent by construction, not filtered. - Modules receive a frozen `context.config` on the registration, runtime, and HTTP handler contexts: the same object identity, per module, beside `services` rather than inside it (services is deliberately one shared instance). No environment access, no database reference, no way to reach another module's values. - Configuration decides eligibility through ADR 0028's existing resolver: three new blocked reasons, each with a distinct operator fix, propagating to required dependents through the same mechanism as a disabled module. - Changes take effect at the next restart; both resources report restartRequired. New restricted permission `core.modules.configuration.manage`, separate from lifecycle, at the sibling prefix `/api/v1/module-configuration`. - Migration participation stays independent of configuration validity. Endpoints and credentials for shared infrastructure stay platform concerns; a module declares which bucket it needs, not an S3 endpoint. See ADR 0029. Co-Authored-By: Claude Opus 5 <[email protected]>
359 lines
26 KiB
Markdown
359 lines
26 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, authored
|
|
in React against `@ekklesia/module-ui-sdk`
|
|
([ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md)). 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 established 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 that pass implemented 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.
|
|
|
|
**Phase 6B pass 1 defines the module UI authoring model and host contract.**
|
|
[ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md) selects React and TypeScript as
|
|
the supported way to author module UI, and defines what a compiled `entrypoints.ui` artifact exports
|
|
and what a host provides it. React is the authoring model but deliberately not the contract: an
|
|
artifact default-exports `defineModuleUi({ id, version, mount })`, and `mount(container, context)`
|
|
takes a DOM element and returns an unmount function, so a module's framework and a host's stay
|
|
independent.
|
|
|
|
The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime
|
|
dependencies, consumed by module authors and hosts alike. Its host context is four members —
|
|
`moduleId`, `api`, `principal`, `permissions`. Module UI reaches its own backend through
|
|
`api.request(path)` with a path relative to the module's own API root, never by constructing
|
|
Ekklesia's `/api/v1/modules/<module-id>` prefix, which is what keeps a standalone host possible.
|
|
Client-side permission hints are presentation only; server-side enforcement remains the sole
|
|
authority. Module UI is trusted code in the host's browser realm — there is no sandbox, matching the
|
|
backend trust model rather than overstating it.
|
|
|
|
Core is untouched by this: discovery still validates the `entrypoints.ui` path and never evaluates
|
|
it, and hosts perform their own dynamic import.
|
|
|
|
**Phase 6B pass 2 makes that contract buildable.**
|
|
[ADR 0023](decisions/0023-module-ui-build-pipeline.md) selects esbuild, compiles a module's
|
|
`src/ui/index.tsx` into the browser ESM artifact its manifest declares, and puts every bundler
|
|
option in one `tooling` package so module authors write no build configuration. React is bundled
|
|
rather than externalized, keeping `mount()` the ABI and a module artifact hostable by any host;
|
|
styling is inline style objects only, and a stylesheet import fails the build rather than emitting an
|
|
asset nothing declares. Building remains an authoring step — Core never builds, and discovery still
|
|
only validates the artifact a module shipped. The Dashboard shell, a UI runtime, a design system, a
|
|
stylesheet loading contract, the standalone host, and every feature module remain future work.
|
|
|
|
**Phase 6B pass 3 makes the browser boundary real and adds the generic host loader.**
|
|
[ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md) removes the UI SDK's last
|
|
dependency — one type-only import of the contracts barrel that pulled Drizzle's types, and Node's
|
|
globals, into browser programs — so the SDK now depends on nothing and defines its own plain
|
|
`ModuleUiPrincipal`. `@ekklesia/module-ui-host` is the loader both Ekklesia and a future standalone
|
|
host will use: dynamic import from a browser URL, identity validated against the manifest before
|
|
mounting, a host-owned container the loader never mutates, and an idempotent unmount. Modules may not
|
|
depend on it.
|
|
|
|
**Phase 6C pass 1 publishes a validated module UI artifact at a browser URL.**
|
|
[ADR 0025](decisions/0025-trusted-module-ui-asset-publication.md) closes the gap the previous pass
|
|
named: Core resolves `entrypoints.ui` to a **server filesystem path**, and a browser needs a **URL**.
|
|
|
|
Core owns the policy and `apps/api` owns the transport. `createModuleUiPublicationRegistry`
|
|
(`core/src/modules/ui-publication.ts`) answers "may this be published, and which exact file is it?"
|
|
from the discovered, started module inventory — publishable means the module survived discovery
|
|
*and* declares `entrypoints.ui`, no new lifecycle state and no installer. `apps/api` serves
|
|
`GET /api/v1/module-ui/<module-id>/<version>`, where both segments are **lookup keys, not path
|
|
components**: an exact match yields the canonical artifact path discovery already validated, and no
|
|
request value is ever composed into a filesystem path. There is no wildcard segment, so traversal,
|
|
sibling chunks, package-root files, and source maps are unreachable by construction — an installed
|
|
module directory never becomes a static file root. The prefix is a sibling of `/api/v1/modules/`
|
|
rather than a child because `ui` is a legal module route segment and a module could otherwise collide
|
|
with the platform.
|
|
|
|
The route requires no authentication: the bundle confers no authority, every module API stays behind
|
|
`requirePermission`, and a cookie-gated route would break cross-origin `import()`, which sends none.
|
|
The disclosure that buys — a caller who knows a module id and its exact version can download that
|
|
bundle — is stated rather than papered over, and the three failure modes are made indistinguishable
|
|
so the route is not an enumeration oracle. Caching is `no-cache` with a strong `ETag`, not
|
|
`immutable`, because without an installer a version can be rebuilt with different bytes. The server
|
|
reads and writes bytes; it never imports, evaluates, or transforms the artifact.
|
|
|
|
**Phase 6C pass 2 builds the browser host that consumes it.**
|
|
[ADR 0026](decisions/0026-minimal-ekklesia-module-ui-host.md) adds `surfaces/shell`
|
|
(`@ekklesia/shell`), the repository's first surface: a small React application, built to static files
|
|
by its own esbuild script, that discovers installed module UIs and mounts them.
|
|
|
|
The missing piece was discovery. `GET /api/v1/module-ui` — the collection root of the same prefix the
|
|
artifact route already owns — returns, per publishable module, its id and version, the server-produced
|
|
artifact URL, the server-produced root of the module's own API, and the subset of that module's
|
|
declared permissions the caller actually holds. Nothing else: no filesystem paths, and no display or
|
|
navigation metadata, since ADR 0021 keeps Dashboard grouping an administrator's presentation
|
|
configuration. The module set is the publication registry, never a rescan, so the inventory and the
|
|
artifact route cannot disagree. It requires `core.modules.ui.read` (baseline `authenticated`) because
|
|
it is precisely the enumeration ADR 0025 withheld from the anonymous artifact route.
|
|
|
|
Both URLs being server-produced is the load-bearing part. `moduleUiArtifactUrl` and the newly exported
|
|
`moduleApiBaseUrl` are each the single construction of their URL shape, so the browser is a consumer
|
|
of both — it never rebuilds `/api/v1/modules/<id>`, the prefix ADR 0022 forbids a module UI from
|
|
spelling, and neither prefix is pinned by having a second author.
|
|
|
|
The shell builds a `ModuleUiHostContext` entirely from server-derived data, resolves a module's
|
|
relative API calls onto its own API root (refusing anything that escapes it), and hands the artifact
|
|
URL to `@ekklesia/module-ui-host`. It statically imports no module: one frame per selection defeats
|
|
the stale-load race, the host owns the container and every loading and error state, and the bundle is
|
|
checked to contain exactly one dynamic import with no module identity baked in. Permission hints come
|
|
from Core's own evaluator and stay presentation-only — server authorization is proven unchanged by a
|
|
test in which a UI holding no grant calls its API anyway and is refused.
|
|
|
|
**Phase 6D pass 1 adds the Dashboard navigation model, and fixes the rule it could have broken.**
|
|
[ADR 0027](decisions/0027-dashboard-navigation-and-flat-modules.md) establishes that a Dashboard
|
|
group is **presentation configuration and never module structure**. A group is a label on a rendering
|
|
bucket: no manifest, version, artifact, API, permission, or lifecycle, and nothing depends on it.
|
|
Placing two modules in one folder creates no relationship between them; *Livestream depends on Video
|
|
because it calls Video's functionality* orders startup, while *Livestream is shown under Media*
|
|
orders nothing. Modules stay flat, with no parent, child, owner, or group-derived dependency.
|
|
|
|
No manifest change was needed: `manifest.name` has been required since version 1 and already is the
|
|
module's product identity, so the shell renders it instead of the module id. `suggestedGroup` was
|
|
deferred rather than shipped — it would force a hard manifest-version cutover for a hint, and its
|
|
only correct semantics needs initialization logic that risks a suggestion overwriting an
|
|
administrator's decision.
|
|
|
|
Core owns the persistence: two `ekklesia_core_dashboard_*` tables (migration `0003`) holding groups
|
|
and placements, platform-global because no organization model exists yet. `group_id` is
|
|
`on delete set null`, so deleting a group ungroups its modules and a dangling reference is
|
|
impossible; `module_id` carries no foreign key, because modules are packages rather than rows, so a
|
|
placement for an uninstalled module is retained and restores its position on reinstall.
|
|
`resolveDashboardNavigation` is a pure function of configuration plus the mountable set: an unplaced
|
|
module is ungrouped and visible with no initialization write, an absent module renders nothing,
|
|
empty groups are kept, and ordering is total with the identifier as final tie-break.
|
|
|
|
`GET /api/v1/dashboard/navigation` returns the resolved view, layered **on top of** the host-neutral
|
|
module UI inventory rather than merged into it — it embeds each generic descriptor untouched, so a
|
|
standalone host consuming the inventory never inherits Dashboard semantics. Reading requires the same
|
|
permission the inventory does; organizing requires the new platform permission
|
|
`core.dashboard.navigation.manage` through a narrow, structured, idempotent mutation contract. The
|
|
shell renders that arrangement and computes none of it.
|
|
|
|
**Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no
|
|
Dashboard Home or widgets, no design system, and no client routing. A production CSP and a deployment
|
|
for the built shell, sign-in UI, general asset publication, and the standalone host 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.
|
|
|
|
Phase 7A pass 1 adds the platform's operational module lifecycle
|
|
([ADR 0028](decisions/0028-module-operational-lifecycle.md)). Core now has two clearly separated
|
|
axes: **configuration** (`enabled`/`disabled`, persistent, administrator-owned, stored in one Core
|
|
table) and **runtime** (`running`/`disabled`/`blocked`, observed per boot). `resolveModuleEligibility`
|
|
is a pure function of the discovered inventory plus that configuration: a module with no stored
|
|
record is enabled by default, an explicit decision always wins, a disabled module transitively blocks
|
|
every module that *requires* it (module and capability edges alike, with a structured reason naming
|
|
the provider), and an optional dependency never blocks anything. `startApi` starts only the eligible
|
|
set and builds the UI publication registry from that same list, so routes, artifacts, browser
|
|
inventory, and Dashboard navigation all follow one authoritative set rather than a filter downstream.
|
|
|
|
Startup remains all-or-nothing across the eligible set — ADR 0015 and ADR 0020's rollback guarantee
|
|
is unchanged, and there is deliberately no `failed` runtime state, because no live process could
|
|
report one. Enablement is what makes a broken module recoverable, with `pnpm modules:disable` as the
|
|
out-of-band path. Changes take effect at the next start, and the read model reports configuration,
|
|
runtime, and `restartRequired` separately rather than claiming a module stopped when it has not.
|
|
Migration discovery still spans the deployed inventory regardless of enablement, and permission
|
|
composition still spans the whole discovered set. `running` means a module's `start` hook completed
|
|
and nothing more; there is no runtime health-probe contract. Modules remain trusted compiled code —
|
|
disabling one is an operational control, never a sandbox. No installer exists.
|
|
|
|
Phase 7A pass 2 adds the module configuration and secrets contract
|
|
([ADR 0029](decisions/0029-module-configuration-and-secrets.md)). A module declares what it needs
|
|
configured in its manifest — `manifestVersion: 5`, a hard cutover — using six types (`string`,
|
|
`integer`, `boolean`, `enum`, `url`, `secret`) with bounded constraints and no nesting. Static
|
|
declaration is what lets the platform validate configuration **before** deciding whether a module may
|
|
start, so an unconfigured module is reported `blocked · configuration_missing` rather than taking
|
|
startup down from inside its own `start` hook; discovery, `pnpm modules:validate`, and Doctor all read
|
|
it while remaining manifest-only.
|
|
|
|
Ordinary values are persisted in one Core table, one row per `(module_id, key)`, stored as unvalidated
|
|
text until checked against the declaring module's current manifest. **Secret values are never
|
|
persisted**: Ekklesia has no encryption at rest, so a `secret` key is supplied by the deployment and
|
|
mapped into the module's snapshot by the host, under a name the module never sees. A module receives a
|
|
frozen `context.config` on its registration, runtime, and HTTP handler contexts — the same object
|
|
identity across all of them — with no environment access, no database reference, and no way to reach
|
|
another module's values, which is what keeps the same module implementation runnable under a future
|
|
standalone host.
|
|
|
|
Configuration decides eligibility through ADR 0028's existing resolver rather than a parallel concept:
|
|
three new blocked reasons (`configuration_missing`, `configuration_invalid`, `secret_missing`) name
|
|
the offending key, and a configuration-blocked module blocks its required dependents exactly like a
|
|
disabled one. Changes take effect at the next restart and the API reports `restartRequired`; nothing
|
|
is hot-reloaded. Migration discovery remains independent of configuration validity, exactly as it is
|
|
independent of enabled state. Endpoints and credentials for shared infrastructure stay platform
|
|
concerns — a module declares which bucket it needs, not an S3 endpoint — leaving room for the object
|
|
storage service Core still does not have. There is no settings UI.
|