feat: add the minimal operational module lifecycle
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 46s

Ekklesia had no notion of a module being on or off: the accepted module set
was exactly "whatever discovery found on disk", and `ModuleLifecycleStage`
named nine states nothing produced or consumed. This replaces that vocabulary
with two axes that real code produces — persistent, administrator-owned
configuration (`enabled`/`disabled`) and per-boot runtime observation
(`running`/`disabled`/`blocked`) — and wires them through startup.

- One Core table (`ekklesia_core_module_state`, migration 0004) holding only
  `module_id` and `enabled`. A module with no record is enabled by default, so
  deploying a module is still sufficient to run it and discovery never needs an
  initialization write. A record for an undeployed module is retained and
  ignored, so redeploying restores the decision.
- `resolveModuleEligibility` is pure: a disabled module transitively blocks
  every module that *requires* it (module and capability edges alike) with a
  structured reason naming the provider; optional dependencies never block; and
  a missing, incompatible, or cyclic dependency stays discovery's concern.
- `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. A disabled module is
  never imported, so there is no per-request enablement check anywhere.
- Startup stays all-or-nothing, deliberately; there is consequently no `failed`
  runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band
  recovery path for a module that breaks startup.
- 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 participation and permission composition still span the deployed
  and discovered inventories respectively, unchanged.
- New restricted platform permission `core.modules.lifecycle.manage` gates both
  reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`)
  that cannot collide with the module route namespace.
- `pnpm doctor` gains a `modules.lifecycle` check so "why is this module
  unavailable?" is answerable without reading logs.

See ADR 0028.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-22 14:45:34 -05:00
co-authored by Claude Opus 5
parent db67cf7700
commit 7729c99e24
52 changed files with 5311 additions and 116 deletions
@@ -30,6 +30,15 @@ names nine stages, but nothing implements a state machine over them: there is no
enable/disable, no trust store, and no per-module status record. What exists is one accepted set —
the modules `discoverModules` validated at startup and `startDiscoveredModuleRuntime` then started.
> **Cross-reference — see [ADR 0028](0028-module-operational-lifecycle.md).**
> The paragraph above described the platform accurately at the time. There is now a persistent
> enable/disable state and a resolved eligibility model, and `ModuleLifecycleStage` has been replaced
> by the two axes ADR 0028 records. "Publishable" consequently derives from the **eligible** module
> set rather than the discovered one, so a disabled or dependency-blocked module publishes no
> artifact. Everything else in this ADR — lookup by validated identity, the sibling prefix, exact
> version matching, the anonymous artifact route, and the caching contract — is unchanged and still
> accepted. There is still no installer.
## Decision
### Publication is derived from validated module identity, never from a request path
@@ -79,6 +79,14 @@ configure, and no module that declares no UI. Ekklesia has no enable/disable sta
`ModuleLifecycleStage` remains vocabulary with no runtime state machine behind it — and reporting one
would mean inventing it.
> **Cross-reference — see [ADR 0028](0028-module-operational-lifecycle.md).**
> Ekklesia now does have enable/disable state, reported by its own resource
> (`GET /api/v1/module-lifecycle`) rather than by this one. This endpoint is unchanged and remains
> exactly what it was: the host-neutral list of what may be mounted, with no management verbs. What
> changed is upstream of it — the inventory now derives from the **eligible** module set, so a
> disabled or dependency-blocked module is simply absent, and the lifecycle vocabulary this
> paragraph names has been replaced by the two axes ADR 0028 records.
### Both URLs are server-produced, and that is the point
`ui.url` comes from `moduleUiArtifactUrl` (ADR 0025) and `api.baseUrl` from `moduleApiBaseUrl`, a new
@@ -0,0 +1,426 @@
# 0028: Module operational lifecycle — enablement, eligibility, and what "running" means
- Status: Accepted
- Date: 2026-08-22
- Extends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
[0015](0015-static-module-http-routing-and-runtime-lifecycle.md),
[0025](0025-trusted-module-ui-asset-publication.md),
[0026](0026-minimal-ekklesia-module-ui-host.md),
[0027](0027-dashboard-navigation-and-flat-modules.md)
## Context
Everything upstream of this decision exists. Core discovers modules, validates manifests and
dependencies, orders them topologically, composes permissions, starts a runtime, mounts module HTTP
routes, publishes module-owned UI, and resolves a Dashboard arrangement over it.
What did not exist was any notion of a module being **on or off**. The accepted module set was
exactly "the modules found on disk at startup", and three separate places in the repository said so
in almost the same words:
> `ModuleLifecycleStage` (`@ekklesia/contracts`) names nine stages, but nothing implements a state
> machine over them: there is no installer, no enable/disable, no trust store, and no per-module
> status record.
> — ADR 0025
That vocabulary — `discovered`, `validated`, `configured`, `registered`, `migrated`, `initialized`,
`started`, `stopped`, `failed` — was aspiration written down as a contract. Nothing produced any of
those values, and nothing consumed them.
Before migrating a real product into the module system, the platform needs an answer to a small set
of operational questions that a church running this software will genuinely ask: *is this module
on? should it start? what happens to the things that depend on it? why is it not available?* This
ADR records the smallest model that answers them honestly.
Two constraints came from the repository rather than from principle.
**Startup is all-or-nothing, deliberately.** ADR 0015 and ADR 0020 built that guarantee carefully:
a module joins the rollback set the moment its `start` hook is invoked, capabilities and routes are
published only after every module registers and starts, and a failed start unwinds in reverse order
under bounded deadlines. ADR 0025 then *depends* on it — "the published set is also the running
set" — as does ADR 0026's inventory and ADR 0027's navigation.
**There is exactly one persistence precedent, and it is recent.** ADR 0027 added two Core-owned,
platform-global tables for Dashboard configuration, with the explicit rule that a module with no
stored row is still visible. That precedent shaped both the schema conventions and the default
policy below.
## Decision
### Two axes, never one status word
```text
configuration enabled / disabled persistent, administrator-owned
runtime running / disabled / blocked ephemeral, observed per boot
```
`MODULE_LIFECYCLE_STAGES` is **replaced** by these two, because a contract should name states that
something actually produces. Both are in `@ekklesia/contracts`; both are observable through
`GET /api/v1/module-lifecycle`.
They are kept apart because they answer different questions and change at different times.
Collapsing them into one word would force a lie in one direction or the other the moment an
administrator changes a setting on a process that is still running.
### `installed` means "a valid module package on disk that discovery accepted" — and nothing else
There is no installer, no installation registry, no package database, and no install source. This
pass deliberately did not create one. The honest chain today is:
```text
present on disk
↓ discovery validates manifest, package, entrypoints, dependencies, cycles
discovered
↓ platform-owned enablement configuration
enabled
↓ required-dependency resolution
eligible
↓ module runtime import, register, start
running
```
A future installer's job is to put a validated package on disk and record an enablement decision,
then reconcile on restart. Nothing in this contract assumes a source-control checkout, an image, or
a registry, so that installer remains possible without changing anything here.
### Enablement is host configuration, and a module can never see it
One Core table, `ekklesia_core_module_state` (migration `0004_module_lifecycle_state`), with
exactly `module_id`, `enabled`, and two audit timestamps. No install source, download URL, update
channel, signature, marketplace metadata, or JSON blob — those are the columns an installer would
need, and their absence is what stops one being built here by accident.
`module_id` carries **no foreign key**, for the same reason `ekklesia_core_dashboard_placement`
does not: modules are packages, not rows. A record for a module that is no longer deployed is
retained and ignored, so redeploying restores the decision rather than resetting it.
A module never declares, reads, or influences its enablement. Nothing in a manifest, a capability,
a permission, or a lifecycle hook can reach it, and a module is never told it is enabled — its host
either loads and starts it, or does not. **That is what keeps the same module implementation
reusable under a future standalone host**, whose loading decisions are its own. A module asking
"am I enabled in Ekklesia?" would be a module that only runs inside Ekklesia.
### A newly discovered module is enabled, and the default is the absence of a row
`enabled` has no column default. The platform's default is expressed by there being no record at
all, and resolved in `resolveModuleEligibility`.
This preserves the behavior every earlier phase had — deploying a module is sufficient to run it —
requires no initialization write when a module first appears, and mirrors ADR 0027's rule that a
module with no placement is still visible. Default-disabled was rejected: with no management UI, it
would mean every deployment ships a platform where nothing works until someone runs a command, and
it would make "install a module" a two-step operation for no safety gain, since the code is already
trusted the moment it is on disk (see **Trust is unchanged**).
An explicit `enabled: true` record is still meaningful and is retained: it records that somebody
looked and left it on. The read model reports `explicit` alongside `enabled` for exactly that
reason.
### Required dependencies block; optional dependencies never do
Disabling `storage` does not silently re-enable it because something needs it. It makes every
module that **requires** it ineligible, transitively:
```text
storage disabled
↓
media blocked · required_dependency_disabled · storage
↓
announcements blocked · required_dependency_blocked · media
```
Following the chain always terminates at a `..._disabled` entry, so an operator can walk from any
blocked module to the one decision that caused it.
A required **capability** dependency blocks identically, because it is one: a capability's only
provider is a module, and if that module does not run, nothing registers the capability. The reason
names the *providing module* — the thing an administrator can act on — with the capability id
alongside it.
An optional dependency never blocks. A module declaring one has already accepted that it may be
absent (discovery permits an optional dependency to not be installed at all), and "disabled" is the
same situation arriving a different way: `context.capabilities.resolve(...)` returns `undefined`,
exactly as it does when the provider was never installed. Required and optional behavior are tested
separately and deliberately.
A dependency that is **missing, version-incompatible, duplicated, or cyclic** has no eligibility
state at all. Discovery rejects the whole inventory before enablement is ever consulted, so the
dependency graph is validated in exactly one place.
Eligibility is a **filter over discovery's order, never a re-sort**. Blocking propagates along the
same edges discovery used to produce a topological order, so removing an ineligible module can
never leave a module ahead of a required dependency it still has.
### Startup atomicity is preserved, deliberately
Startup remains all-or-nothing **across the eligible set**. A module that fails to load, register,
or start still aborts API startup and unwinds through Core's existing reverse-order rollback.
This was the sharpest question in the pass, and changing it would have been a much larger decision
than this ADR should make by accident. ADR 0015 and ADR 0020 built that guarantee on purpose, and
ADR 0025, ADR 0026, and ADR 0027 all rest on "the published set is the running set". Moving to
per-module failure isolation means the permission registry, the route snapshot, the publication
registry, and the navigation view each have to be derived from a set that is only known *after*
partial failure — a real design, and a separate one.
The cost is stated plainly rather than hidden: **one broken module still takes down the whole
platform.** Enablement is what makes that recoverable, and this pass closes the one path that would
otherwise be a deadlock — the HTTP lifecycle API is served by the process a bad module takes down,
so `pnpm modules:disable -- --module <id>` exists as an out-of-band operator command, in the same
shape as `pnpm authorization:bootstrap-owner`: explicit, never HTTP-exposed, never inferred.
There is consequently **no `failed` runtime state**, because no live process could ever report one.
Naming a state nothing can produce is exactly the mistake `MODULE_LIFECYCLE_STAGES` made. It is the
first thing a future isolated-startup pass adds.
### Runtime failure is never persisted as configuration
```text
enabled configuration → stored
failed to start during this boot runtime → not stored
```
A restart naturally retries every enabled module. There is no failure counter, no quarantine, no
backoff, and no "disabled because it crashed" write — a platform that disabled a module on its
behalf would be making an administrative decision nobody asked for, and the operator would have no
way to tell it apart from one they made.
### `running` means its `start` hook completed, and nothing more
It does **not** mean every external resource the module uses is reachable, now or ever. A module
that starts successfully and then cannot reach its object storage is a module with a health
problem, not a module with a lifecycle problem.
This pass adds no runtime health-probe contract, no readiness hook, and no per-module health
endpoint. A module that wants to report its own health does so through its own API and its own UI,
like any other domain concern. The limitation is recorded here rather than left to be discovered.
### Configuration changes take effect at the next start, and the API says so
There is no hot module installation, removal, or reloading (ADR 0015, unchanged), so `PUT` writes a
decision and changes nothing about the running process. The response reports all three facts:
```json
{ "id": "announcements", "version": "1.0.0",
"configured": { "enabled": false, "explicit": true },
"runtime": { "state": "running" },
"restartRequired": true }
```
Reporting `runtime: disabled` there would be a lie about a process still serving that module's
routes. `restartRequired` is computed by re-resolving eligibility against live configuration and
comparing with what the runtime **reports it actually started**, not with what eligibility
predicted — so it is symmetric (enabling a stopped module marks it too) and transitive (disabling a
dependency marks its dependents, because they really will stop).
Hot-unloading to make a button feel immediate was rejected: it would trade a real guarantee — that
a trusted module's ESM evaluation happens exactly once and can never be rolled back — for a
cosmetic one.
### Exposure follows eligibility, structurally rather than by a downstream check
`apps/api/src/server.ts` passes the **same eligible list** to `startDiscoveredModuleRuntime` and to
`createModuleUiPublicationRegistry`. There is one authoritative eligible set, and everything that
exposes a module consumes it:
- **HTTP routes.** A disabled module is never imported, so its `register` hook never runs and it
publishes no routes. There is no per-request "is this module enabled?" check anywhere, because
there is nothing mounted to check — an unmounted path gets the platform's ordinary `404`.
- **UI publication.** Its artifact URL returns the same `404` an uninstalled module does, even to a
caller who knows the exact id and version. ADR 0025's rule — *the existence of an artifact on disk
must not be what makes it servable* — now covers "discovered but disabled" as well.
- **Inventory and navigation.** Absent from `GET /api/v1/module-ui` and from
`GET /api/v1/dashboard/navigation`, by construction, since both derive from the publication
registry. Its Dashboard placement row is retained and simply renders nothing, exactly like an
uninstalled module's (ADR 0027).
- **Lifecycle hooks.** Never invoked, in either direction: shutdown runs no `stop` hook for a module
that never started.
A module whose enablement changed but whose process has not restarted stays fully exposed. It really
is still mounted and still serving; hiding it now would break a viewer mid-session for a change that
has not taken effect, and `restartRequired` is where that pending change is reported instead.
### Permission composition still spans the whole discovered inventory
Unchanged, deliberately. ADR 0015 fixed the startup order so that discovery and permission
composition complete **before any pool, Better Auth instance, or module entrypoint exists** — a
composition failure therefore leaves nothing to close. Filtering permissions by enablement would
require reading the database first and would invert that order for no safety gain.
A disabled module's permissions therefore stay registered and grantable. Nothing enforces them,
because it mounts no routes, and grants survive an enable/disable cycle intact — which is what an
administrator would expect, and lets a permission be granted ahead of enabling a module.
### Migration participation is deployment, not runtime state
```text
deployed schema/migration ownership ≠ runtime enabled state
```
Migration discovery, planning, and application continue to read the **deployed** inventory,
regardless of enablement. They are offline, Core-owned, explicitly operator-invoked commands and
must not depend on runtime configuration. A disabled module's tables must still be migrated, or
enabling it later would require a schema change in the middle of a restart. Nothing about the
planner, destructive-change approval, locking, checksums, or transactional guarantees changed.
### The URL is a sibling of the module namespace, not a child
```text
GET /api/v1/module-lifecycle
PUT /api/v1/module-lifecycle/<module-id>/enabled { "enabled": boolean }
```
The obvious `/api/v1/modules` and `/api/v1/modules/:id/enabled` are **wrong**, for the exact reason
ADR 0025 gave for `/api/v1/module-ui`. Core's route grammar accepts `enabled` as an ordinary static
segment, so a module named `announcements` may legitimately register `PUT /enabled` — mounted at
exactly `/api/v1/modules/announcements/enabled`. Publishing the platform's own lifecycle mutation
there would put Core and an installed module in one URL namespace with registration order deciding
which one answers, for a route that changes whether modules run. A sibling prefix cannot collide.
`PUT` against a module id not in the boot inventory is a `404`. Unlike an inert Dashboard placement,
an enablement record is operational configuration; accepting one for an arbitrary identifier would
make this an unbounded write surface for a delegable permission. The CLI enforces the identical rule
by running the same manifest-only discovery. *Existing* records for undeployed modules are still
retained and ignored.
The body is closed and structured — any key beyond `enabled` invalidates the whole request rather
than being dropped — matching ADR 0027's mutation contract exactly. There is no configuration blob.
### One restricted permission, gating read as well as write
`core.modules.lifecycle.manage`, baseline `restricted`. The read is not a milder version of the
write: this resource is the only place a *disabled* module, a *blocked* module, and the
required-dependency edges between them are visible at all. `core.modules.ui.read` deliberately
exposes the mountable set to every signed-in user; this exposes the parts of the installation that
are deliberately not running, which is not the same audience. A second, weaker read permission over
the same resource would be ceremony.
It is a **platform** permission, never a module's, and holding it confers no authority inside any
module. Mutations additionally require an exact trusted `Origin` and a bounded body before
authorization work begins, like every other Core mutation.
Neither response carries a filesystem path, package name, manifest path, entrypoint, caught error,
or stack. The only identifiers are module ids, capability ids, and fixed reason codes from closed
sets — all values the platform itself produced and validated.
### Startup fails closed when the configuration cannot be read
`startApi` reads enablement before importing any module entrypoint. If that read fails — an
unmigrated or unreachable database — startup **refuses**, with Core's sanitized
`MODULE_LIFECYCLE_READ_FAILED`. It cannot know what an administrator decided, and starting a module
they disabled is worse than not starting at all. This is a behavior change: previously the API would
bind a listener against an unmigrated database and fail every request instead. Nothing is created or
repaired on that path.
### Diagnostics answer "why is this module unavailable?" without the logs
`pnpm doctor` gains one check, `modules.lifecycle`, in its existing Modules section — not a second
diagnostics framework. It is skipped when discovery failed or `DATABASE_URL` is unset (configuration
lives in PostgreSQL, and reporting a guessed default is worse than skipping). A **disabled** module
is a `pass` with a detail line: it is a decision, not a problem. A **blocked** module is a
`warning`: an enabled module that cannot run is the anomaly worth surfacing. Every rendered value is
re-validated before interpolation, matching the file's existing `safeModuleDetails` discipline.
### Trust is unchanged
Installed modules remain trusted compiled code running in the API process with its PostgreSQL
authority. Enablement is an **operational** control, not a security boundary: a module's code is on
disk and could be started by any operator with filesystem access. Nothing here sandboxes, isolates,
or restricts a module that does run, and nothing here should be described as if it did.
## Validated against a real product
The Announcements repository (`The_River/Announcements`, commit `9aab486`) was walked against this
model as a reference consumer. It was not modified, and nothing about it entered Core.
It is a small Express + Socket.IO server that polls an S3-compatible bucket listing every 30
seconds, advances a slide index on a 7-second timer, broadcasts state to a full-screen React
client, and toggles a blank-screen `mode` from an MQTT topic. It has no database and no
authentication.
Three things it does made the two-axis model earn its keep.
**Its storage is unreachable regularly, and that is not a lifecycle failure.** `loadSlides()`
already catches its own fetch failure, logs, and retries on the next tick. Under this model the
module stays `running`, because its `start` hook completed; "slides could not be loaded right now"
is the module's own health, surfaced through its own UI. Had `running` been defined as "everything
it needs is reachable", a router reboot would have looked like a lifecycle event.
**Its `mode: on/off` toggle is emphatically not enablement.** It blanks the screen, is flipped
frequently by whoever is running a service, and must take effect instantly. Module enablement is an
administrative deployment decision that takes effect at the next restart. Mapping one onto the other
would have pushed a per-service operational toggle into Core's lifecycle and made it restart-based —
which is precisely the kind of conflation the separate configuration and runtime axes exist to
prevent. (Per the platform direction, that control belongs to a future Companion module speaking the
module API; Ekklesia Core will not use MQTT, and this ADR designs nothing around it.)
**Its long-lived timers are what `stop` is for.** Neither `setInterval` is ever cleared today. As a
module it must clear both in a `stop` hook that is safe after a partially completed `start` (ADR
0020) — a migration task, not a Core gap, and comfortably inside the platform's 10-second hook
deadline.
The rest of the walkthrough came out as designed: disabled means no routes, no UI, no navigation
entry, no timers, and no hooks; its migrations would still be discovered and applied; a future
required dependency being disabled would block it with a named reason; and nothing in its feature
code would ever ask whether it is enabled, so the same implementation stays deployable standalone.
## Alternatives
- **Implementing the nine `MODULE_LIFECYCLE_STAGES` names.** Rejected: most name a transition rather
than a state, several are unobservable, and shipping them would have repeated the original mistake
of publishing a vocabulary nothing produces.
- **A `failed` runtime state now.** Rejected while startup is all-or-nothing: no live process could
ever report it.
- **Per-module failure isolation in this pass.** Deferred deliberately. It is a genuine improvement
and a genuinely larger decision; four accepted ADRs depend on the current guarantee.
- **Default-disabled for a newly discovered module.** Rejected: it breaks every earlier phase's
behavior, requires an administrative step with no UI to perform it in, and buys no safety the
trusted-code model does not already concede.
- **A `disabled` column default.** Rejected: it would materialize a decision nobody made and destroy
the distinction between "at the default" and "explicitly set".
- **Persisting startup failures.** Rejected: it turns a transient runtime fact into administrative
configuration an operator did not write and cannot distinguish from their own.
- **Filtering permission composition by enablement.** Rejected: it inverts ADR 0015's deliberate
startup order for no gain, and would make grants vanish across an enable/disable cycle.
- **Filtering migration discovery by enablement.** Rejected: schema ownership is a deployment fact,
and a disabled module's tables must already exist when it is enabled.
- **Enable/disable at `/api/v1/modules/...`.** Rejected: it collides with the module route namespace,
where registration order would decide which handler answers.
- **A separate read permission.** Rejected: the read exposes what the write controls.
- **Hot enable/disable without a restart.** Rejected: ESM evaluation cannot be undone, and the
platform must not claim a module stopped when its code is still resident.
- **A generalized runtime health-probe SDK.** Rejected for this pass: "running" is deliberately a
narrow claim, and a health contract is a design of its own.
- **A module installer, package registry, or install-source columns.** Explicitly out of scope; the
schema deliberately has nowhere to put them.
## Consequences
Ekklesia has an operational lifecycle: an administrator can turn a module off, that decision
survives restarts, its dependents behave deterministically, and every surface that exposes a module
— routes, artifacts, inventory, navigation — follows the one eligible set rather than a filter
somewhere downstream.
`createApiApp` takes a required `moduleLifecycle` option, matching the pattern `dashboard`,
`moduleUi`, and `moduleRoutes` already follow. Core gains a fourth permission, which moved several
existing assertions. `startApi` now reads the database before starting modules, so it refuses to
start against an unmigrated one — stricter than before, and stated as such.
Two costs worth naming. Enablement is platform-global and will need a scope column when an
organization model exists, exactly like the Dashboard tables. And a broken module still takes the
whole platform down; the CLI makes that recoverable without a database edit, but it does not make it
pleasant, and closing it properly is the isolated-startup pass.
## Deferred
Not implemented or promised here: per-module startup failure isolation and a `failed` runtime state;
a module installer, marketplace, package registry, download/update/signature infrastructure, or
install-source metadata; hot loading, hot unloading, and filesystem watchers; a module-management
Dashboard UI or any administrator-facing affordance beyond the API and the CLI; automatic migration
application, and any coupling between enablement and migrations; a runtime health-probe or readiness
contract; module-owned configuration and secrets; per-module or per-organization scoping of
enablement; a "reset to default" operation that deletes an explicit record; and process isolation or
sandboxing of any kind.
The permission-id typing gap carried since ADR 0022 is unchanged: `permissions.has` takes a plain
string, and the wire arrays are `string[]`.
+1
View File
@@ -36,3 +36,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0025: Trusted module UI asset publication](0025-trusted-module-ui-asset-publication.md) — Accepted
- [0026: The minimal Ekklesia module UI host](0026-minimal-ekklesia-module-ui-host.md) — Accepted
- [0027: Dashboard navigation is presentation configuration, and modules stay flat](0027-dashboard-navigation-and-flat-modules.md) — Accepted
- [0028: Module operational lifecycle — enablement, eligibility, and what "running" means](0028-module-operational-lifecycle.md) — Accepted
@@ -308,3 +308,24 @@ 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.
+149 -9
View File
@@ -80,6 +80,8 @@ No real values are shown here — configure per environment and never commit the
| `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). |
| `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). |
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
| `GET /api/v1/module-lifecycle` | Ekklesia | Which installed modules are enabled, what this process actually did with each, and whether a restart is pending. Requires `core.modules.lifecycle.manage` (baseline `restricted`). See [Module lifecycle](#module-lifecycle). |
| `PUT /api/v1/module-lifecycle/<module-id>/enabled` | Ekklesia | Enable or disable one installed module. Takes effect at the next API start. Requires `core.modules.lifecycle.manage` and a trusted `Origin`. See [Module lifecycle](#module-lifecycle). |
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
deterministic `404 {"code":"NOT_FOUND"}`.
@@ -385,12 +387,16 @@ account.
3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same
pool (see [Module database access](#module-database-access)).
4. Construct the Core identity runtime, authorization store, and evaluator.
5. Start the module runtime from that exact same discovered inventory
(`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step — passing it the
services object every module's `start`/`stop` hook receives.
6. Create the Hono application, mounting the started module runtime's published routes and passing
the identical services object to every module HTTP handler.
7. Bind the HTTP server.
5. Read the stored module enablement configuration and resolve, over that exact same discovered
inventory, which modules are **eligible** to run (`resolveModuleEligibility`,
`@ekklesia/core/modules` — see [Module lifecycle](#module-lifecycle)).
6. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — modules are
never rediscovered for this step — passing it the services object every module's `start`/`stop`
hook receives.
7. Create the Hono application, mounting the started module runtime's published routes, publishing
UI for that same eligible set, and passing the identical services object to every module HTTP
handler.
8. Bind the HTTP server.
Discovery and permission composition happen before any pool, Better Auth instance, module
entrypoint import, or HTTP server exists, so a discovery or composition failure (an invalid
@@ -404,9 +410,143 @@ for the permission-composition detail, and [Module development](module-developme
the manifest shape that produces these declarations.
Unlike discovery/composition/Doctor/`modules:validate` (which only ever read manifests), a
successful production startup does import each module's compiled server entrypoint and run its
`register`/`start` hooks — this is the one place in the platform that deliberately executes module
code, and only after every other startup step already listed has succeeded.
successful production startup does import each **eligible** module's compiled server entrypoint and
run its `register`/`start` hooks — this is the one place in the platform that deliberately executes
module code, and only after every other startup step already listed has succeeded. A module that is
disabled, or blocked by a disabled required dependency, is never imported at all.
Step 5 makes startup depend on a reachable, migrated database: if the enablement configuration
cannot be read, `startApi` **refuses to start** with Core's sanitized `MODULE_LIFECYCLE_READ_FAILED`
rather than starting every discovered module. The platform cannot know what an administrator
decided, and starting a module they disabled is worse than not starting. Nothing is created or
repaired on that path.
## Module lifecycle
```text
GET /api/v1/module-lifecycle
PUT /api/v1/module-lifecycle/<module-id>/enabled { "enabled": boolean }
```
The platform's operational view of its installed modules
([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)): which are enabled, what
this process actually did with each of them, and whether the two currently disagree.
It is a **sibling** of `/api/v1/modules/`, never a child, for the same reason `/api/v1/module-ui` is
(ADR 0025): `enabled` is a legal module route segment, so a module named `announcements` could
otherwise register `PUT /enabled` and collide with the platform at exactly
`/api/v1/modules/announcements/enabled`.
```json
{
"modules": [
{
"id": "storage",
"version": "1.0.0",
"configured": { "enabled": false, "explicit": true },
"runtime": { "state": "disabled" },
"restartRequired": false
},
{
"id": "announcements",
"version": "1.0.0",
"configured": { "enabled": true, "explicit": false },
"runtime": {
"state": "blocked",
"reason": "required_dependency_disabled",
"dependency": { "kind": "module", "moduleId": "storage" }
},
"restartRequired": false
}
]
}
```
### Two axes, deliberately not one status word
`configured` is the decision that survives restarts. `explicit: false` means nobody has decided and
the platform default (**enabled**) applies. `runtime` is what *this process* did:
| state | meaning |
| ---------- | ----------------------------------------------------------------------------- |
| `running` | the module was eligible and its `start` hook completed in this process |
| `disabled` | configuration says it must not run, so it was never imported |
| `blocked` | it should run, but a **required** dependency cannot — see `reason`/`dependency` |
`running` says the `start` hook completed. It says **nothing** about whether every external resource
that module uses is reachable right now; Ekklesia has no runtime health-probe contract, and this
state must not be read as one. There is deliberately no `failed` state: module startup is
all-or-nothing, so a module that fails to start aborts API startup and no live process could ever
report it.
`reason` is one of `required_dependency_disabled` (the named dependency was turned off) or
`required_dependency_blocked` (the named dependency is itself blocked). Following the chain always
terminates at a `..._disabled` entry. An **optional** dependency never blocks anything.
### Changing enablement requires a restart, and the response says so
There is no hot module installation or removal, so `PUT` writes a decision and changes nothing about
the running process:
```json
{ "id": "announcements", "version": "1.0.0",
"configured": { "enabled": false, "explicit": true },
"runtime": { "state": "running" },
"restartRequired": true }
```
Reporting `runtime: disabled` there would be a lie about a process still serving that module's
routes. `restartRequired` compares what the current configuration *would* start against what the
runtime reports it *did* start, so it is symmetric and transitive: disabling a module also marks its
dependents, because they really will stop on the next boot.
A module whose enablement changed but whose process has not restarted stays fully exposed — routes,
artifact, inventory, and navigation. It really is still mounted; hiding it now would break a viewer
mid-session for a change that has not taken effect.
### What a disabled module does not have
Never imported ⇒ no `register`, `start`, or `stop` hook runs; no HTTP routes are mounted (an
unmounted path gets the platform's ordinary `404`, with no per-request enablement check anywhere);
no capabilities are registered; `GET /api/v1/module-ui/<id>/<version>` returns the same `404` an
uninstalled module does; and it is absent from both `GET /api/v1/module-ui` and
`GET /api/v1/dashboard/navigation`.
Its **migrations are still discovered and applied**. Deployed schema ownership is not runtime
enabled state: a disabled module's tables must already exist when it is enabled, or enabling it would
require a schema change in the middle of a restart. Its **permissions also stay registered**, so
grants survive an enable/disable cycle and can be issued ahead of enabling a module; nothing enforces
them, because it mounts no routes.
### Authority
Both verbs require `core.modules.lifecycle.manage` (baseline `restricted`). The read is gated too,
because this resource is the only place a disabled module, a blocked module, and the
required-dependency edges between them are visible at all — `core.modules.ui.read` deliberately
covers only what is mountable. Mutations additionally require an exact trusted `Origin` and a bounded
body (`CORE_MUTATION_MAX_BODY_BYTES`) before authorization work begins.
The body is closed: any key beyond `enabled` invalidates the whole request rather than being dropped.
A `PUT` naming a module this process did not discover is `404 {"code":"MODULE_NOT_INSTALLED"}` —
enablement is operational configuration, not an unbounded key-value store. Existing records for
modules that are no longer deployed are retained and ignored, so redeploying restores the decision.
No response carries a filesystem path, package name, manifest path, entrypoint, caught error, or
stack.
### Recovering from a module that breaks startup
Startup is all-or-nothing, so a module whose `start` hook throws takes the API down — including this
API. The out-of-band path is a Core CLI, in the same shape as `pnpm authorization:bootstrap-owner`
(explicit, never HTTP-exposed):
```bash
pnpm modules:disable -- --module announcements
pnpm modules:enable -- --module announcements
```
It reads `DATABASE_URL`, runs the same manifest-only discovery the API does (so it refuses a module
this deployment cannot see), writes the decision, and starts nothing. Restart the API to apply it.
## Module HTTP routes
+23 -6
View File
@@ -100,14 +100,31 @@ grant lookup — there is no wildcard, prefix, or namespace-inheritance matching
required dot-namespacing in a `PermissionId` (`core.authorization.grants.manage`,
`calendar.events.read`) is purely a naming convention for readability and collision-avoidance.
`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission, and
remains what Core-only code and existing tests use directly. `CORE_PERMISSION_DECLARATIONS` is the
same declarations exported separately in deeply immutable form, for the module composition helper
below to build on without reconstructing Core's own list:
`CORE_PERMISSION_REGISTRY` is Core's own registry and remains what Core-only code and existing tests
use directly. `CORE_PERMISSION_DECLARATIONS` is the same declarations exported separately in deeply
immutable form, for the module composition helper below to build on without reconstructing Core's own
list:
| Permission | Baseline | Description |
| --- | --- | --- |
| `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. |
| `core.modules.ui.read` | `authenticated` | List the installed module UIs a browser host may mount. |
| `core.dashboard.navigation.manage` | `restricted` | Organize the Dashboard: create, rename, order, and delete navigation groups. |
| `core.modules.lifecycle.manage` | `restricted` | Read and change which installed modules are enabled. |
Every Core permission is a **platform** permission and can never be declared, gated, or influenced by
a module — `core.*` is a reserved namespace (see below). `core.modules.lifecycle.manage` gates both
reading and changing module enablement
([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)): the read is not a
milder version of the write, because the lifecycle view is the only place a *disabled* module, a
*blocked* module, and the required-dependency edges between them are visible at all, whereas
`core.modules.ui.read` deliberately covers only what is mountable. Holding it confers no authority
inside any module.
Enablement never changes how a permission is enforced. A **disabled** module's permissions stay
registered and grantable — composition spans the whole discovered inventory, not the eligible subset
— so a grant survives an enable/disable cycle and can be issued ahead of enabling a module. Nothing
enforces those permissions while the module is disabled, because it mounts no routes at all.
## Module permission composition
@@ -206,8 +223,8 @@ generic explicit-grant HTTP API, enforcement middleware, and now its own mounted
`pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the
same already-discovered module list `modules.discovery`/`modules.dependencies` already produced —
it never re-discovers modules itself. A successful composition reports a pass with a safe
declaration count (for example `1 permission registered` with zero installed modules, since Core's
own declaration is always present); zero installed modules still composes successfully. If module
declaration count (for example `4 permissions registered` with zero installed modules, since Core's
own declarations are always present); zero installed modules still composes successfully. If module
discovery itself failed, the check is skipped, matching the existing schema/migration checks'
pattern; if discovery succeeded but composition failed, schema and migration checks still run
normally — they are independent diagnostics that do not depend on the permission registry. A
+27
View File
@@ -168,6 +168,33 @@ what a request may do. Reading navigation requires the same `core.modules.ui.rea
inventory requires, so it discloses the same installed-module enumeration and nothing more; it
carries no filesystem path, package root, or manifest internal.
### Disabling a module is an operational control, not a security boundary
A module can be turned off, and that decision survives restarts
([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)): a disabled module is
never imported, so its code never runs, it mounts no routes, registers no capabilities, and publishes
no UI. That is a real and useful operational property — it is how an operator recovers from a module
that misbehaves or breaks startup.
**It is not containment.** The module's compiled code is still on disk, still trusted, and still
carries every authority listed above the moment anything starts it. Enablement is a row in
`ekklesia_core_module_state`; it constrains the platform's own composition, not the module. Anyone
who can put a module package on this filesystem, or who holds the database or operating-system access
to change that row, decides what runs. Nothing about disabling a module sandboxes, isolates, or
reduces the privileges of a module that *is* enabled.
Changing enablement requires `core.modules.lifecycle.manage` (baseline `restricted`) and — for the
mutation — an exact trusted `Origin` and a bounded body. Reading is gated by the same permission
because the lifecycle view is the only place a disabled module, a blocked module, and the
required-dependency edges between them are visible; `core.modules.ui.read` deliberately exposes only
the mountable set. Neither response carries a filesystem path, package name, manifest path,
entrypoint, caught error, or stack — only module ids, capability ids, and fixed reason codes.
Holding the lifecycle permission grants no authority *inside* any module: every module route stays
enforced against that module's own declared permission, and a disabled module's permissions remain
registered and grantable precisely so that an enable/disable cycle changes nothing about who holds
what.
### Client-side permission checks are not a security boundary
A host may tell a module UI what permissions it believes the viewer holds
+22
View File
@@ -152,6 +152,28 @@ key material is ever committed to this repository** — and the disposable conta
`afterAll` even when an assertion fails. See
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
A fifth dedicated suite (`core/tests/module-lifecycle-store.integration.ts`, included in the same
`pnpm test:migrations:integration` run) proves the module enablement store against real PostgreSQL:
that migration `0004` really creates `ekklesia_core_module_state` with exactly the four columns and
the one declared CHECK constraint the schema states — and, load-bearingly, that `enabled` has **no
column default**, so the platform's default for a newly discovered module is genuinely the absence of
a row rather than a materialized decision nobody made; that a decision survives a completely new
connection; that the upsert is idempotent on `module_id` and bumps `updated_at` in place rather than
inserting a second row; that a malformed identifier is rejected before it reaches PostgreSQL and the
empty-string case is refused by the database too; and that a record for a module which is no longer
deployed is retained and simply ignored by resolution, so redeploying restores the decision.
The API suite additionally drives the whole lifecycle end to end against a real database and real
fixture modules: a disabled module's compiled entrypoint is never imported (proven by its
`register`/`start`/`stop` markers staying empty), its routes return the platform's ordinary `404`,
its UI artifact and inventory and navigation entries are all absent, and its neighbours run normally;
a module blocked by a disabled required dependency is explained over HTTP with a structured reason
naming that dependency and no filesystem path anywhere in the payload; a change made through
`PUT /api/v1/module-lifecycle/<id>/enabled` reports `restartRequired: true` while the module
demonstrably keeps serving, and is applied only by the next start; and a disabled module's migrations
are still discovered, because deployed schema ownership is not runtime enabled state. See
[ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md).
### Which kind of timing proof lives where
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
+58 -4
View File
@@ -962,6 +962,13 @@ PostgreSQL, including DDL rollback, advisory-lock contention, history behavior,
sanitized failures. It remains outside the Docker-free `pnpm verify` gate; see the
[testing guide](../development/testing.md).
`pnpm modules:enable -- --module <id>` and `pnpm modules:disable -- --module <id>` record the
platform's enablement decision for one installed module. They read `DATABASE_URL`, run the same
manifest-only discovery every other command does (so a module this deployment cannot see is refused),
write one row, and start nothing. The change takes effect at the next `pnpm api:start`. These exist
so a module that breaks startup can be turned off without the API — the HTTP lifecycle API is served
by the process such a module takes down.
`pnpm doctor` builds first and presents the same production discovery results as a concise,
read-only dashboard. It lists discovered module IDs and versions in dependency order, reports
schema and migration counts, and shows safe migration identities with truncated checksums. Schema
@@ -969,13 +976,60 @@ discovery imports the compiled schema-definition entrypoint, so its no-I/O and n
contract still applies. Doctor does not import or start module server entrypoints, apply migrations,
or exercise runtime hooks; lifecycle behavior remains covered by automated tests. PostgreSQL
planning is skipped when `DATABASE_URL` is absent, and object storage is skipped until Core has a
production storage integration. A skip or pending migration is visible as `READY WITH WARNINGS`,
production storage integration. Its `modules.lifecycle` check reports which installed modules are
enabled and which cannot run — also skipped without `DATABASE_URL`, since enablement is stored in
PostgreSQL. A deliberately disabled module is a pass with a detail line; an *enabled* module that
cannot run (blocked by a disabled required dependency) is a warning, because that is the anomaly. A skip or pending migration is visible as `READY WITH WARNINGS`,
not misreported as a successful check.
### Whether your module runs at all is your host's decision
Between discovery and startup, the platform consults its own **enablement configuration** and
resolves which discovered modules are eligible to run
([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)):
```text
present on disk → discovered → enabled → eligible → running
```
Three things matter to you as a module author, and only three.
**You never see it.** Nothing in your manifest declares enablement, nothing in your `register`,
`start`, or `stop` context exposes it, and your module is never told it is enabled. Your host either
loads and starts you, or it does not. **Do not write feature code that asks whether it is enabled in
Ekklesia** — that is what keeps the same implementation runnable under a future standalone host,
whose loading decisions are its own.
**Disabled means nothing of yours runs.** A disabled module's compiled server entrypoint is never
imported, so no hook fires, no route is mounted, no capability is registered, and its UI artifact is
not published or listed. There is no per-request "is this enabled?" check to code against, because
nothing is mounted.
**A module with no stored decision is enabled.** Deploying your module is sufficient to run it; no
initialization step, database edit, or migration is needed to make it appear.
Your **required** dependencies are real: if a module (or the provider of a capability) you declare
under `dependencies.modules.required` / `dependencies.capabilities.required` is disabled, you are
blocked and will not start, with a structured reason naming it. Your **optional** dependencies are
not: if an optional provider is disabled, you start normally and
`context.capabilities.resolve(...)` returns `undefined` — exactly as it does when that provider was
never installed. Declare optional if you can degrade; declare required if you genuinely cannot run.
Your migrations are discovered and applied regardless of enablement — deployed schema ownership is a
deployment fact, not runtime state — so enabling your module later never needs a schema change in the
middle of a restart.
Finally, "running" means your `start` hook completed. It does **not** mean every external resource
you use is reachable. If your object storage or an upstream service is down after you started, that
is your module's own health to surface through your own API and UI; the platform has no health-probe
contract and will keep reporting you as running.
### Discovery and startup, step by step
Core completes discovery before importing any server entrypoint. It then loads and verifies every
runtime definition before running hooks, registers all modules dependency-first into an unpublished
staged capability registry and an unpublished staged HTTP route list side by side, and starts them in
the same order. Neither the capability registry nor the route list is published — visible on
eligible module's runtime definition before running hooks, registers all of them dependency-first
into an unpublished staged capability registry and an unpublished staged HTTP route list side by
side, and starts them in the same order. Neither the capability registry nor the route list is published — visible on
`StartedModuleRuntime` — unless every module registers and starts successfully. Shutdown is
reverse-order. If a later module fails to start, Core stops already-started modules in reverse order
and reports any cleanup failures without leaking caught exceptions or machine paths. A failing