Phase 5B, pass 3: adds ADR 0015 recording the durable module HTTP
routing and production runtime lifecycle architecture pass 1 and pass
2 implemented, extending ADR 0006 and ADR 0014 without superseding
either. Adds narrow cross-reference notes to both, and to the ADR
index, pointing at ADR 0015 for the work they deferred, while
preserving their historical text and accepted status.
Corrects stale forward-looking "pass 2"/future-tense language left
over from pass 1 in core/src/modules/runtime.ts and
packages/contracts/src/module-http.ts (comment-only; production
behavior unchanged), and a shutdown-lifecycle description in
docs/development/testing.md that predated the module-runtime-stop
step pass 2 added between draining the HTTP server and closing the
pool.
Full audit against origin/main...HEAD found the architecture,
contract, startup ordering, routing, authorization/origin
enforcement, and PostgreSQL integration coverage from passes 1-2
sound; the one concrete defect found (Vary-header merge producing an
invalid "*, Origin, Cookie" combination and case-sensitive duplicate
tokens) was already fixed with focused regression tests in a separate
commit (bf76f4e), not amending either checkpoint commit.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
17 KiB
0014: Module permission declarations and platform permission registry
- Status: Accepted
- Date: 2026-07-24
- Extends: 0013
- Supersedes (partial): 0006 — only
its
manifestVersion: 2and 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
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:validatedepend 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_REGISTRYwhen 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. 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.