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