Ekklesia's module model has been backend-only since ADR 0001: a module owns domain behavior and data, and every presentation concern belongs to a surface. That prevents a module author — the person who knows the domain — from shipping the screen that makes their module useful, and it guarantees a module and its own UI drift apart. It also pushes toward two codebases for one domain the moment a module needs to run standalone. ADR 0021 replaces that rule. A module is an independently versioned product that owns its domain behavior, data, permissions, routes, migrations, and its own UI, hostable inside Ekklesia and — from the same implementation — by a later standalone host. Core keeps the containing application shell and every platform policy: authentication, authorization enforcement, platform context, top-level routing, navigation, global theme, error boundaries, and trusted-origin/CORS. The module graph stays flat: modules are peers, genuine module and capability dependencies remain the only relationships, and Dashboard folders are presentation configuration an administrator can reorganize, never module architecture. This pass implements the static contract only. `manifestVersion` moves to 4 and `entrypoints` gains one optional field, `ui`. Version 3's `entrypoints` is strict and rejects `ui`, so this is an incompatible shape change and version 3 is now rejected outright rather than reinterpreted, exactly as version 1 was by version 2 and version 2 by version 3. Every field version 4 adds is optional, so upgrading a backend-only manifest is a one-character edit. Discovery resolves and validates `ui` under the same rules as `server` and `schema` — package-relative, inside the package before and after symlink resolution, a real file, compiled ESM `.js`/`.mjs`, so source TypeScript is rejected rather than assumed to be compiled later. Discovery stays inert: nothing imports, evaluates, mounts, or renders a UI entrypoint, and a test proves a throwing artifact still discovers successfully. This follows `entrypoints.jobs`, declared and resolved since ADR 0006 with no runtime consumer. The name is `ui`, not `dashboard`, because a module's UI must be hostable by Ekklesia's shell and by a standalone host, so the contract cannot be named after one of them; and not `surface`, because `surfaces/*` is a separate ownership layer. No widget, route, icon, folder, menu, layout, or standalone-metadata field is added, and tests assert the manifest still rejects every unknown entrypoint key and offers no hierarchy field. Deliberately unchanged: `scripts/check-boundaries.ts` still enforces `modules-do-not-own-ui`. Relaxing that guard depends entirely on the UI authoring model a later pass chooses, and weakening a working protection before that decision exists would trade real coverage for nothing. A module may therefore declare `ui` today only for a framework-neutral compiled artifact, and the docs say so rather than implying otherwise. Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model are untouched — a module that ships UI is exactly as trusted as one that does not. No Dashboard shell, UI runtime, renderer, framework choice, navigation registry, grouping system, standalone host, installer, hot-loading, or feature module is added by this pass. Co-Authored-By: Claude Opus 5 <[email protected]>
245 lines
18 KiB
Markdown
245 lines
18 KiB
Markdown
# 0014: Module permission declarations and platform permission registry
|
|
|
|
- Status: Accepted
|
|
- Date: 2026-07-24
|
|
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md)
|
|
- Supersedes (partial): [0006](0006-versioned-capabilities-and-compiled-module-runtime.md) — only
|
|
its `manifestVersion: 2` and bare-permission-string decision. Capabilities, the compiled-runtime
|
|
production loading boundary, and registration/lifecycle ordering established in ADR 0006 remain
|
|
accepted and unchanged.
|
|
|
|
## Context
|
|
|
|
ADR 0013 established Core's authorization foundation — schema, evaluator, store, permission
|
|
registry, platform-owner bootstrap, and HTTP enforcement — but explicitly deferred module
|
|
permission registration: a module manifest (ADR 0006, `manifestVersion: 2`) could declare a
|
|
`permissions` array, but only as bare namespaced strings. That was enough to validate a namespace
|
|
at manifest-parse time, but not enough to *register* anything: Core's authorization registry needs
|
|
a trusted baseline and an operator-facing description for every permission it enforces, and a bare
|
|
string carries neither. Phase 5A closes that gap in two passes on this branch: pass 1
|
|
(`feat: declare module permissions`) established the shared declaration contract, manifest version
|
|
3, and a pure composition function; pass 2 (`feat: integrate module authorization`) wired the
|
|
composed registry into `apps/api`'s production startup and its existing enforcement/explicit-grant
|
|
HTTP surface. This ADR records the durable architecture both passes established.
|
|
|
|
## Decision
|
|
|
|
### `PermissionDeclaration` is the one shared, contracts-layer definition
|
|
|
|
`packages/contracts/src/authorization.ts`'s `permissionDeclarationSchema` is the single
|
|
authoritative shape of a registered permission declaration — `id` (a branded `PermissionId`),
|
|
`baseline` (`public`/`authenticated`/`restricted`, the evaluator's trusted baseline), and
|
|
`description` (required, trimmed, non-empty, capped at 240 characters). `.strict()` rejects any
|
|
other property. Both Core's own authorization registry
|
|
(`core/src/authorization/permissions.ts`) and a module manifest's `permissions` array
|
|
(`packages/contracts/src/module-manifest.ts`) consume this exact type — Core does not declare a
|
|
second, independently maintained interface, and a module cannot smuggle extra fields into a
|
|
declaration Core treats as trusted. `description` is informational only: audit and operator UI
|
|
text, never read or interpreted by the evaluator, the registry, or enforcement — changing it can
|
|
never change what a permission allows.
|
|
|
|
### Module manifests are inert JSON and the sole permission-declaration source
|
|
|
|
A module's `ekklesia.module.json` remains inert, static data Core parses without executing any
|
|
module code — consistent with ADR 0006's discovery model. Its `permissions` array is the only
|
|
place a module declares permissions; nothing in `defineModuleRuntime` (Module SDK,
|
|
`packages/module-sdk/src/define-module-runtime.ts`) repeats, duplicates, or otherwise re-declares
|
|
permissions, baselines, or descriptions — exactly like it already does not repeat capabilities,
|
|
dependencies, storage requests, or entrypoints (ADR 0006). Core never imports or runs a module's
|
|
server entrypoint, job, or lifecycle hook to discover its permissions; only the manifest is read.
|
|
|
|
### Manifest version 3 replaces version 2's bare permission strings
|
|
|
|
> **Partial correction — see [ADR 0021](0021-modules-as-hostable-products.md).**
|
|
> `manifestVersion: 3` was superseded by ADR 0021's `manifestVersion: 4`, which adds the optional
|
|
> `entrypoints.ui` field. The permission-declaration shape this section establishes, the reserved
|
|
> `core.*` namespace, the composed platform registry, and the inert-manifest discovery model are
|
|
> carried forward into version 4 unchanged; only the accepted version literal moved. The
|
|
> version-rejection rule described below applies identically to version 3 under version 4.
|
|
|
|
Representing a permission as a complete declaration instead of a bare identifier string is an
|
|
incompatible manifest shape change, so the canonical manifest moves to `manifestVersion: 3`
|
|
(`z.literal(3)`). Version 2 — and any other version — fails validation outright; there is no
|
|
compatibility adapter, no automatic conversion from a string array to declarations, and no dual
|
|
acceptance of both shapes. A module author must rewrite `permissions` explicitly. `permissions`
|
|
still defaults to an empty array when the field is omitted entirely, exactly as version 2 did.
|
|
|
|
### Each module owns exactly its own namespace; `core.*` is reserved
|
|
|
|
A permission id declared in a module's manifest must begin with `<module-id>.`, checked the same
|
|
way a capability id already is (ADR 0006). Module ids never contain a dot (`moduleIdSchema` is
|
|
plain kebab-case), so the only module whose manifest could ever declare a `core.`-prefixed
|
|
permission and still pass this per-manifest namespace check is one whose own id is literally
|
|
`"core"` — `core.*` itself remains reserved for Core's own permissions, enforced independently at
|
|
composition time (below). Duplicate permission ids within one manifest fail manifest validation at
|
|
the exact issue path `permissions[index].id`, mirroring the existing capability and storage-bucket
|
|
duplicate checks.
|
|
|
|
### Core and installed-module declarations compose into one immutable registry
|
|
|
|
`composePlatformPermissionRegistry` (`core/src/modules/permissions.ts`) builds the platform's one
|
|
permission registry from Core's own declarations (`CORE_PERMISSION_DECLARATIONS`) plus every
|
|
already-discovered module's declarations. It reuses `createPermissionRegistry`
|
|
(`core/src/authorization/permissions.ts`) rather than reimplementing registry construction or
|
|
duplicate detection — there is no second, module-specific registry type. Order is deterministic:
|
|
Core's declarations first, then each module in the exact order it appears in the caller-supplied
|
|
list, then each module's own declarations in manifest-array order. The resulting registry, and
|
|
every declaration it exposes, is immutable (`Object.freeze`), matching `createPermissionRegistry`'s
|
|
existing guarantee. Composition is pure, synchronous, in-memory work over data the caller already
|
|
discovered: no filesystem, environment, database, network, or module-code operation happens inside
|
|
it.
|
|
|
|
`core/src/authorization` continues to know nothing about modules — it does not import module
|
|
discovery or runtime. `core/src/modules/permissions.ts` is the one-directional module-integration
|
|
layer that depends on `core/src/authorization`, never the reverse.
|
|
|
|
### The structural collision guarantee
|
|
|
|
A discovered module contributing any `core.`-prefixed permission id fails composition with
|
|
`ModulePermissionCompositionError` (`MODULE_PERMISSION_RESERVED_NAMESPACE`) — checked, and thrown,
|
|
before that declaration is ever added to the list `createPermissionRegistry` sees. This is a
|
|
deliberate architectural guarantee, not an incidental test limitation, and it follows from three
|
|
facts holding simultaneously: real module discovery rejects a duplicate module id
|
|
(`MODULE_ID_DUPLICATE`), so module ids are unique; a manifest's own namespace check requires every
|
|
permission id to begin with `<module-id>.`; and a manifest already rejects a duplicate permission
|
|
id within itself. Together, these mean a module can only ever produce a `core.`-prefixed permission
|
|
if its own id is literally `"core"` — and when it does, that declaration is caught by the
|
|
reserved-namespace check specifically, never by `createPermissionRegistry`'s generic duplicate
|
|
detection (`PermissionRegistryError`, `PERMISSION_REGISTRY_DUPLICATE_DECLARATION`). The same three
|
|
facts also mean a genuine *module-vs-module* exact permission-id collision cannot arise from a
|
|
valid, really-discovered installed-module set at all — two different module ids can never produce
|
|
the identical permission string. `createPermissionRegistry`'s own duplicate rejection remains in
|
|
place regardless, as an unconditional, defense-in-depth guarantee of the underlying registry
|
|
primitive; both Core's test suite and this composer's own tests exercise it directly with
|
|
deliberately conflicting internal test data (not reachable through real discovery), specifically to
|
|
prove the guarantee holds even though production traffic cannot currently trigger it. Both error
|
|
families carry only a module id and/or permission id — never a module directory, manifest path,
|
|
caught cause, or stack.
|
|
|
|
### Module discovery and permission composition precede all database and HTTP construction
|
|
|
|
`apps/api`'s `startApi()` (`apps/api/src/server.ts`) composes the platform registry
|
|
(`composeStartupPermissionRegistry`, `apps/api/src/platform-permissions.ts`) as its first step,
|
|
strictly before constructing the shared PostgreSQL pool, Better Auth, the authorization store and
|
|
evaluator, the Hono application, or binding the HTTP server. A discovery or composition failure —
|
|
an invalid manifest, an incompatible dependency graph, a reserved-namespace claim, or a duplicate
|
|
declaration — therefore has nothing to close: it propagates directly out of `startApi()`, and
|
|
startup never falls back to a Core-only registry. Modules are discovered exactly once for this
|
|
composition; nothing re-discovers them a second time for permissions specifically.
|
|
|
|
Repository and package-version information is derived from the installed repository's own layout —
|
|
never `process.cwd()`, which a package-scoped test run does not set to the repository root. The
|
|
repository root is resolved from the composing module's own compiled/source file location (a fixed
|
|
number of directory levels up, identical whether running `apps/api/dist/*.js` or, under a test
|
|
runner, `apps/api/src/*.ts`); the platform and Module SDK versions are read from their real
|
|
`package.json` files, only when startup actually executes — never merely by importing the API's
|
|
server, config, or app modules, which remain side-effect-free at import time.
|
|
|
|
### API enforcement and explicit grants use one registry for Core and module permissions alike
|
|
|
|
`createApiApp` requires the composed platform registry as an injected authorization option;
|
|
`startApi` is the only production caller, and it always supplies the registry
|
|
`composeStartupPermissionRegistry` produced. There is no `CORE_PERMISSION_REGISTRY` fallback
|
|
anywhere in `apps/api` — not in `createApiApp`, not in `createGrantsRoutes`, not in the
|
|
`requirePermission` middleware. `requirePermission` has no notion of "Core" versus "module"
|
|
permissions; it resolves a route's declared `PermissionId` and its trusted baseline from whichever
|
|
registry it is given, denies before principal resolution or evaluator access when the permission is
|
|
unregistered, and never accepts a baseline, actor, or any other authorization-relevant field from
|
|
client input. A module's `public`, `authenticated`, or `restricted` declaration is therefore
|
|
enforced identically to Core's own `core.authorization.grants.manage`, through the identical
|
|
evaluator and grant path, and — because registration and evaluator shape validation remain the two
|
|
separate, independent fail-closed layers ADR 0013 established — a well-formed but unregistered
|
|
permission (module or Core) still receives a sanitized `403` with no store or audit write, whether
|
|
the request is a permission check or an explicit-grant creation attempt on `POST /api/v1/grants`.
|
|
|
|
### The registry is a static startup snapshot
|
|
|
|
There is no hot installation, removal, or dynamic mutation of the composed registry while `apps/api`
|
|
is running. Installing, removing, or editing a module's manifest takes effect only on the next API
|
|
restart. Removing a module from the next startup's discovered inventory makes its permission
|
|
unknown to new authorization checks and new explicit grants — it denies exactly like any other
|
|
unregistered permission — but never deletes, revokes, or otherwise modifies a historical grant or
|
|
audit row already recorded for that permission. A previously granted, still-unrevoked explicit
|
|
grant remains listed through `GET /api/v1/grants`, and revoking it explicitly remains possible,
|
|
since revocation is keyed to the grant's own id and source kind, not to whether its permission is
|
|
currently registered. What happens if the exact same permission id is later reinstalled or
|
|
reactivated in a future startup — whether a preserved, still-active grant becomes effective again
|
|
automatically — is **explicitly not decided by this ADR** and remains deferred; nothing here should
|
|
be read as a guarantee either way.
|
|
|
|
### Doctor and `modules:validate` compose the same inventory
|
|
|
|
`pnpm doctor`'s `modules.permissions` check and `pnpm modules:validate` both call
|
|
`composePlatformPermissionRegistry` over the module list their own already-completed discovery
|
|
produced — neither rediscovers modules solely to check permissions. A structurally valid manifest
|
|
that claims the `core.*` namespace, or that would produce a duplicate declaration, now fails before
|
|
that module is considered installable (`modules:validate`) or reported healthy (`doctor`).
|
|
|
|
### Test-only discovery overrides are not a production configuration surface
|
|
|
|
`composeStartupPermissionRegistry`'s `modulesDirectoryOverride` and `discoveredModulesOverride`
|
|
exist solely so tests can exercise discovery and composition against controlled fixtures or
|
|
deliberately conflicting data — the latter specifically because a genuine module-vs-module
|
|
duplicate is otherwise unreachable through real discovery, as established above. Neither is read
|
|
from an environment variable, an HTTP header, or any other client- or deployment-controlled input;
|
|
`main()`, the executable entry point, never sets either.
|
|
|
|
## Alternatives
|
|
|
|
- **Runtime, code-based permission registration** (a module registering permissions imperatively
|
|
from its server entrypoint or a registration hook) was rejected: it would require executing
|
|
module code to discover what a module can even offer, breaking the inert-manifest discovery
|
|
model ADR 0006 already established and that Doctor and `modules:validate` depend on for
|
|
offline, side-effect-free validation.
|
|
- **A second, module-specific registry implementation** was rejected in favor of reusing
|
|
`createPermissionRegistry` — a second implementation would need to reimplement duplicate
|
|
detection, immutability, and fail-closed lookup independently, with no guarantee the two stayed
|
|
behaviorally identical.
|
|
- **Falling back to `CORE_PERMISSION_REGISTRY` when discovery or composition fails** was rejected:
|
|
it would let the API start in a state where module-declared permissions silently do not exist,
|
|
which is indistinguishable from "no modules were ever installed" and would mask a real
|
|
configuration error as ordinary, healthy startup.
|
|
- **Dynamic, mutable, or hot-reloaded registration** (updating the registry while the process
|
|
runs, without a restart) was rejected for this phase: it introduces concurrent-mutation and
|
|
partial-update hazards this phase does not need to solve, and a static snapshot is sufficient
|
|
for the current deployment model.
|
|
- **Silently accepting version-2 manifests** (auto-converting a bare permission string into a
|
|
declaration with a synthesized baseline/description) was rejected: any synthesized baseline
|
|
would be a guess Core cannot trust, and the whole point of requiring a real declaration is that
|
|
the baseline and description come from the module author, not from Core inventing them.
|
|
- **Deleting or automatically revoking a module's grants when the module disappears** was
|
|
rejected: it would destroy an audit trail that must remain queryable regardless of a module's
|
|
current installation state, and revocation is a deliberate, actor-attributed action elsewhere in
|
|
this system (ADR 0013) — an uninstall is not that action.
|
|
- **Reading a permission's baseline from HTTP request input** (letting a caller assert which
|
|
baseline to check) was already rejected by ADR 0013 and remains rejected here: the baseline is
|
|
never anything but the registry's own trusted value for a request-supplied `PermissionId`.
|
|
|
|
## Consequences
|
|
|
|
A module gains a real, enforceable permission the moment its manifest is written correctly and the
|
|
module is discovered — no separate registration step, no code to write beyond the manifest itself.
|
|
Core's own permissions and every module's permissions share one enforcement mechanism end to end:
|
|
one registry, one middleware, one explicit-grant HTTP API. The reserved `core.*` namespace and the
|
|
structural collision guarantee mean a module can never accidentally or deliberately shadow a
|
|
Core-owned permission without that attempt being caught, sanitized, and reported as exactly what it
|
|
is. The cost is the one explicitly deferred question this ADR leaves open — reinstall/reactivation
|
|
semantics for a permission whose module was previously removed — and the operational fact that
|
|
every module inventory change requires an API restart to take effect, which is acceptable for the
|
|
current deployment model but not the final word on it.
|
|
|
|
## Deferred
|
|
|
|
> **Cross-reference — see [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md).**
|
|
> Module HTTP route-registration contracts and their production mounting, listed immediately below
|
|
> as deferred, are now implemented. Every other item in this list remains deferred and unimplemented.
|
|
|
|
Module HTTP route-registration contracts (a module mounting its own endpoints); a
|
|
`ModuleRuntimeContext` authorization service; relationship-derived grant creation (a roster or
|
|
similar module operation using `sourceKind: "relationship"`, not the generic explicit-grant API);
|
|
reinstall/reactivation policy for a permission whose owning module was previously removed;
|
|
roles, groups, teams, and tenants; an owner-management HTTP endpoint; service accounts and API
|
|
tokens for non-browser automation; module installation/uninstallation state and hot reload; Surfaces
|
|
and UI; and workflow/event automation. None of these are implemented by, or required for, the
|
|
architecture this ADR records.
|