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
+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