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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user