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
+81 -5
View File
@@ -11,7 +11,9 @@ Phase 4C (Core authorization), Phase 5A (module authorization integration), Phas
transport), and Phase 5C (module database access) have landed. **Phase 5D (security hardening
against an independent security audit) is complete: all five passes have landed and the final
independent whole-repository re-audit has closed, under the current trusted-contributor,
not-yet-deployed threat model — and Phase 6 is unblocked and underway.** The operational
not-yet-deployed threat model — and Phase 6 is unblocked and underway.** Phase 6 has since
completed through 6D, and **Phase 7A pass 1 (the minimal operational module lifecycle) has
landed.** The operational
requirements that remain are prerequisites for two specific *future* activities — executing
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the
pass 5 closure below and
@@ -386,9 +388,70 @@ restores its position; deleting a group ungroups its modules through the databas
application cleanup. See
[ADR 0027](docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md).
Phase 7A pass 1 gives the platform an **operational module lifecycle** — the smallest one that is
actually useful, and nothing beyond it. Until now the accepted module set was exactly "whatever was
found on disk at startup", and `ModuleLifecycleStage` named nine states that nothing produced or
consumed. That vocabulary is replaced by two axes that real code produces:
```text
configuration enabled / disabled persistent, administrator-owned
runtime running / disabled / blocked observed per boot
```
**Enablement is host configuration, and a module can never see it.** One Core table (migration
`0004`) holds `module_id` and `enabled` — no install source, download URL, update channel, signature,
or JSON blob, because those are the columns an installer would need and their absence is what stops
one being built by accident. A module never declares or reads its own enabled state, and is never
told it is enabled: its host loads and starts it, or does not. That is what keeps the same module
implementation runnable under a future standalone host.
**A newly discovered module is enabled, and the default is the absence of a row.** Deploying a module
is still sufficient to run it — no initialization write, no database edit — mirroring the rule that a
module with no Dashboard placement is still visible. An explicit record always wins, in both
directions, and a record for a module that is no longer deployed is retained and ignored, so
redeploying restores the decision.
**Required dependencies block; optional ones never do.** Disabling `storage` does not silently
re-enable it because something needs it — it makes every module that *requires* it ineligible,
transitively, with a structured reason (`required_dependency_disabled` →
`required_dependency_blocked`) naming the module an administrator must act on. A required *capability*
dependency blocks identically, because its only provider is a module. An optional dependency being
disabled is the same situation as it never being installed: the dependent starts, and
`capabilities.resolve(...)` returns `undefined`.
**Exposure follows eligibility, structurally.** `startApi` hands the same eligible list to the module
runtime and to the UI publication registry, so a disabled module is never imported at all: no hooks,
no routes (an unmounted path gets the ordinary `404` — there is no per-request enablement check
anywhere), no capabilities, no published artifact, and no entry in the browser inventory or Dashboard
navigation. Its **migrations are still discovered and applied**, because deployed schema ownership is
not runtime state, and its **permissions stay registered**, so grants survive an enable/disable cycle.
**Startup stays all-or-nothing, deliberately, and changes need a restart.** One module that fails to
start still takes API startup down; that guarantee is what ADR 0025/0026/0027 rest on, and inverting
it is a separate design. There is consequently no `failed` runtime state — no live process could
report one. Enablement is what makes a broken module recoverable, with `pnpm modules:disable --
--module <id>` as the out-of-band path, since the HTTP API is served by the process a bad module
takes down. `PUT /api/v1/module-lifecycle/<id>/enabled` writes a decision and reports honestly that
the process has not changed:
```json
{ "configured": { "enabled": false, "explicit": true },
"runtime": { "state": "running" },
"restartRequired": true }
```
Nothing is hot-unloaded to make a button feel immediate. `running` means a module's `start` hook
completed and **nothing more** — a module that starts and then cannot reach its object storage has a
health problem, not a lifecycle problem, and this pass adds no health-probe contract. `pnpm doctor`
gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading
logs. Reading and changing both require the new restricted platform permission
`core.modules.lifecycle.manage`. Disabling a module is an **operational** control, not a sandbox: its
code is still on disk and still trusted. There is still **no installer**. See
[ADR 0028](docs/architecture/decisions/0028-module-operational-lifecycle.md).
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
folders, no icons or colors, no Dashboard Home or widgets, no design system, and no client-side
routing. Sign-in UI, a production CSP and deployment for the built shell, asset publication beyond
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
and no module-management UI — module enablement is an API and a CLI, not a screen. Sign-in UI, a production CSP and deployment for the built shell, asset publication beyond
the UI entrypoint, React runtime sharing, the standalone host, and every real feature module also do
not exist.
@@ -446,16 +509,20 @@ Modules
✓ Dependency graph valid
Dependency order is empty.
✓ Permission registry composed
1 permission registered
4 permissions registered
○ Module lifecycle skipped
DATABASE_URL is not configured.
Data definitions
✓ Schemas validated
0 modules · 0 tables
✓ Migrations validated
3 migrations · checksums valid
5 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0
core:0002_authorization_integrity · 634294731180
core:0003_dashboard_navigation_tables · 2cde27807aa4
core:0004_module_lifecycle_state · c8b5d8b3d48b
PostgreSQL
✓ Database TLS verified
@@ -469,6 +536,8 @@ Identity
○ Identity configuration skipped
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
✓ Direct client-address mode
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
Object storage
○ Check unavailable
@@ -496,6 +565,13 @@ CLI into a temporary location and structurally compares it against the checked-i
and is part of `pnpm verify`. See [Identity](docs/development/identity.md) for the full upgrade
workflow.
`pnpm modules:enable -- --module <id>` and `pnpm modules:disable -- --module <id>` record which
installed modules this deployment runs. 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`. They exist so a module
that breaks startup can be turned off without the API, since the HTTP lifecycle endpoint is served by
the process such a module takes down.
`pnpm api:start` builds the workspace, then starts the identity HTTP transport
(`GET /api/v1/principal`, Better Auth mounted at `/api/auth/*`) against an already-migrated
`DATABASE_URL` — it never applies migrations itself. See [API](docs/development/api.md) for