feat: add the minimal operational module lifecycle
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:
co-authored by
Claude Opus 5
parent
db67cf7700
commit
7729c99e24
+149
-9
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user