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