Files
Ekklesia/docs/architecture/decisions/0015-static-module-http-routing-and-runtime-lifecycle.md
T
ekklesia c38b572778
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 29s
docs: document module database access
Add ADR 0016 recording the accepted module database access design (ModuleDatabase, the frozen
ModuleRuntimeServices boundary, one pool/one facade/one services-object identity, explicit
migrations, and the trust model), with narrow cross-reference corrections to ADR 0006 and ADR 0015
where their original text is now inaccurate. Sweep docs/development/api.md,
docs/development/authorization.md, docs/module-development/README.md, docs/development/testing.md,
and docs/development/identity.md for the new database boundary and remaining Phase 4C staleness;
correct the stale "Phase 4C is next" roadmap paragraph in platform-architecture.md and the matching
gap in README's current-status narrative. Extend the module template with a permissioned
GET /records route reading through context.services.database, using the canonical
createModuleTable helper, with matching template tests.
2026-07-25 21:50:03 -05:00

23 KiB

0015: Static module HTTP routing and production runtime lifecycle

  • Status: Accepted
  • Date: 2026-07-24
  • Extends: 0006 and 0014

Context

ADR 0006 established the compiled module runtime — deterministic discovery, a staged capability registry, dependency-ordered registration and lifecycle, and the injected context's deliberate limit to module ID and capability access. ADR 0014 wired module-declared permissions into Core's authorization system and explicitly deferred two things: a module mounting its own HTTP routes, and apps/api's permission-composition startup retaining the discovered module list for anything beyond composition itself. This ADR records the architecture that closes both gaps: a framework-neutral route contract a module declares during its existing register hook, Core's atomic collection of those routes into the same immutable startup snapshot as capabilities, and apps/api's production mounting of that snapshot — enforced, origin-checked, and response-sanitized — under one platform-owned path prefix.

Decision

One contracts-layer, Fetch-based route definition

packages/contracts/src/module-http.ts is the sole route contract, consumed identically by Core's module runtime and apps/api's mounting adapter — there is no second, parallel definition in either layer. It depends only on standard Fetch primitives (Request, Response), EkklesiaPrincipal, ModuleId, and PermissionId; it never imports Hono, Node HTTP types, Better Auth, a database client, or any other framework or Core implementation type. A route definition contains exactly four fields — method, path, permission, handler — validated as an exact-shape object with no unknown properties; a module cannot smuggle a baseline, a mount prefix, an absolute path, CORS or origin policy, authentication behavior, grant/platform-owner state, middleware, audit metadata, or another module's namespace through this contract, because the type has no field for any of them. permission is accepted as a plain string at the authoring boundary (so a module writes a literal identifier with no parse call) and validated and branded to PermissionId by the contract's own parser, mirroring how PermissionDeclarationInput/PermissionDeclaration already split authoring input from validated output (ADR 0014).

Exactly GET, POST, PUT, PATCH, and DELETE are authorable, always uppercase; there is no OPTIONS (the platform owns CORS preflight) and no wildcard or all-methods registration. A path is always local to the module and canonical: it begins with /, a non-root path never ends with /, and every segment is either a lowercase kebab-case static segment or a single :parameterName segment — no empty or repeated slashes, no query string or fragment, no backslash or percent-encoding, no dot segment, and no wildcard/catch-all segment, because none of those characters can ever satisfy either segment grammar. Malformed input is rejected outright, never normalized, so a module can never register a path Core silently reinterpreted.

Permission ownership is structural, not a runtime policy choice

Every route — including one meant to be publicly reachable — names exactly one permission the owning module's own manifest declares (ADR 0014's manifestVersion: 3 declarations); there is no way to construct an unprotected, no-permission route through this contract. A route can never supply a baseline: the trusted public/authenticated/restricted baseline comes exclusively from the composed platform permission registry at enforcement time, exactly like every other permission check in the platform. Public access is expressed by referencing a permission whose own manifest declaration has baseline: "public", never by omitting a permission. Core's module runtime checks route ownership against only the registering module's own manifest-declared permissions — never the composed platform registry, never another module's manifest — so a module can never gate a route with Core's or another module's permission; this is a structural consequence of what the ownership check is allowed to consult, not a policy a caller could misconfigure.

Atomic validation and publication alongside capabilities

Core's staged registration (ADR 0006) now stages a module's HTTP routes exactly as it already stages capability provision: a route is validated and, on failure, rejected immediately at the http.route(...) call site during register, using structured ModuleRuntimeIssue codes distinguishing invalid shape/handler, invalid method, invalid path, invalid permission identifier, an undeclared (unowned) permission, and a duplicate route — never a caught exception's message, handler source, or a machine path. A module's routes are merged into the shared, eventually published list only after that module's register hook returns without throwing and every capability it declared was actually provided — the same all-or-nothing gate capabilities already enforce. StartedModuleRuntime.routes is populated, and becomes non-empty, only after every discovered module has registered and started successfully; a failed registration or a failed startup (with Core's existing reverse-order rollback) publishes no route snapshot at all, exactly as no capability registry is published in that case either.

Duplicate detection uses route shape — method plus path with every parameter segment collapsed to one placeholder — not the parameter's chosen name, so /events/:eventId and /events/:id collide under the same method exactly like the identical route they are. Detection is scoped to one module's own routes; two different modules may register the identical local method and path without conflict, because the production mount prefix (below) namespaces them apart regardless.

An immutable, ordered startup snapshot

StartedModuleRuntime.routes is a single frozen array of frozen route records, published once at startup and never mutated afterward — there is no dynamic route addition or removal while the process runs. Its order is deterministic: discovered-module order, then registration order within each module. Because a caller-owned route-definition object could otherwise be mutated after http.route(...) returns, Core never retains the caller's own object — it copies each validated field into a freshly frozen record, so mutating the original definition afterward cannot alter the published snapshot. Core never invokes a handler during registration or startup; a handler is only ever a stored function reference until a route is actually dispatched (a concern entirely owned by apps/api, below).

One discovered inventory drives both composition and runtime startup

prepareStartupModuleInventory (apps/api/src/platform-permissions.ts) discovers the installed module inventory exactly once and returns both the discovered module list and the platform permission registry composed from it, together. apps/api's production startup (startApi, apps/api/src/server.ts) passes that same discovered list directly to startDiscoveredModuleRuntime — a Core entry point that performs no discovery of its own and preserves the given list's order exactly — so permission composition and executable module startup are provably looking at the same installed-module set, never two independently discovered ones. startModuleRuntime (discover-then-start) remains available as a convenience wrapper for callers that do not already have a discovered list; production never calls it. The test-only discoveredModulesOverride drives both composition and runtime startup identically, for the same reason: it stands in for the one real discovered list, never a second, competing one.

Production startup composes in one fixed order: discover the module inventory and compose the platform permission registry from it; construct the shared PostgreSQL pool; construct Identity, the authorization store, and the evaluator; start the module runtime from that same discovered inventory; create the Hono application and mount the started runtime's published routes; bind the HTTP server. A discovery or composition failure therefore occurs before any pool, Better Auth instance, or executable module code exists; a database/identity construction failure occurs before any module runtime starts; and a module runtime or app-construction failure binds no server. Every cleanup path preserves the original failure if cleanup itself also fails, matching the sanitized, non-masking failure discipline the rest of the platform already follows.

The platform-owned /api/v1/modules/<module-id> namespace, enforced identically to Core's own routes

apps/api/src/module-http-routes.ts — the one place in the repository below apps/api importing Hono for this purpose — mounts a started runtime's published route snapshot under /api/v1/modules/<module-id>, in publication order, never sorted or otherwise rewritten. A local "/" maps to the bare module prefix; every other local path appends directly; the platform alone decides this mapping, and a module has no way to choose or influence its own mount prefix. This ordering is load-bearing, not cosmetic: Hono's router resolves two routes whose patterns overlap at the same depth (a static segment and a same-position :parameter) by registration order, not by static-path specificity, so preserving the runtime's exact publication order is what lets a module author's more-specific-first declaration continue to take precedence after mounting. Mounting uses one fixed-arity app.on(method, path, ...middleware, handler) call per route rather than spreading a variable-length handler array — Hono's overload resolution is arity-sensitive, and a spread call silently resolves to an unrelated, incompatible overload.

Every mounted route is protected by the identical requirePermission middleware Core's own routes use (ADR 0013), consulting route.permission against the exact same composed platform registry — there is no module-specific enforcement path, no wildcard, prefix, namespace, or implication matching, and a route's baseline always comes from the registry, never the route. A public permission still resolves a principal and traverses the same authorization check as any other baseline; only the evaluator's own decision differs. Because the mounted routes and the injected registry both derive from the same discovered inventory, every mounted route's permission should already be registered; apps/api still checks this invariant explicitly at app-construction time and fails construction with a fixed, sanitized configuration error if it ever does not hold, rather than silently mounting an unenforceable route — requirePermission's own per-request registry lookup remains the live, defense-in-depth enforcement regardless of that check.

Trusted origin, and platform-owned OPTIONS/HEAD

A state-changing method (POST, PUT, PATCH, DELETE) additionally requires an exact trusted Origin, checked before authorization or the handler ever run — the identical requireTrustedOrigin mechanism the explicit-grant mutation routes already use (ADR 0013), for the identical reason: CORS response headers alone do not stop a browser from sending a state-changing cross-origin request, only from reading its response. GET does not require an Origin. OPTIONS is never authorable by a module and remains entirely platform-owned: the shared CORS middleware intercepts every preflight request before any route-specific middleware or handler runs, so a preflight never reaches authorization or module code. HEAD is not separately authorable either; Hono's own router transparently re-dispatches a HEAD request through the identical registered GET chain and strips the response body afterward, so a HEAD request is authorized exactly like the GET it mirrors and cannot bypass anything a direct GET would not also bypass. The platform's CORS policy advertises GET, POST, PUT, PATCH, DELETE, and OPTIONS, and reflects only an exact configured trusted origin — never a wildcard, and never anything module-influenced.

A frozen, minimal handler context and sanitized failure boundary

Cross-reference — see ADR 0016. "A frozen context of exactly moduleId... and an immutable, Hono-decoded parameter record" and "A handler never receives a Hono Context, a database connection... or any other middleware-internal state," below, described the handler context's complete shape accurately at the time. ADR 0016 adds a fourth field, services, carrying ModuleRuntimeServices (currently just a typed ModuleDatabase facade) — never a raw database connection, pg.Pool, PoolClient, connection string, or other infrastructure object. Every other statement in this section remains accepted and unchanged: a handler still never receives a Hono Context, the authorization store or evaluator, raw PostgreSQL access, configuration, or secrets.

A handler receives the unmodified Fetch Request (c.req.raw, so Request.url, headers, method, body, and a streaming request all behave normally, and the full query string remains available without a competing query abstraction) and a frozen context of exactly moduleId, the already-authorized EkklesiaPrincipal, and an immutable, Hono-decoded parameter record — an empty frozen object for a root or fully static route. A handler never receives a Hono Context, a database connection, the authorization store or evaluator, PostgreSQL, configuration, secrets, or any other middleware-internal state. Module code is executable, trusted backend plugin code, not a sandboxed boundary — the same trust model ADR 0006 already established for capability registration and lifecycle hooks — but its HTTP-facing boundary is still sanitized: a handler that throws, rejects, or returns anything other than a Response becomes a fixed 500 {"code":"MODULE_HANDLER_FAILED"} with Cache-Control: no-store, and nothing about the caught value, a stack, the module ID, the route's path, or its permission ID is ever included. A Response a handler deliberately returns — including a 4xx or 5xx one — is never remapped, and a streaming body is passed through unread rather than consumed or buffered while headers are sanitized.

Platform-controlled caching and CORS on every module response

The platform remains the sole owner of caching and CORS for a module response: any Access-Control-* header a handler set is stripped (case-insensitively, since header names are themselves case-insensitive) before the response leaves the process, and every module response is forced to Cache-Control: no-store with a Vary that always includes Origin and Cookie — merged case-insensitively so a differently-cased duplicate never appears, and collapsed to a bare * rather than an invalid combination whenever an existing Vary already contains one (RFC 9110 §12.5.5 forbids combining * with any other value) — while any of a handler's own legitimate Vary values are preserved alongside the forced ones. This is deliberately conservative: even a public-baseline handler receives an authorized principal and may personalize its response, so until a dedicated module-declared cache-policy contract exists, no module response is ever treated as cacheable or origin-independent by default.

Discovery, Doctor, and modules:validate remain manifest-only; production runtime startup does not

Module discovery, Doctor's checks, modules:validate, and permission composition continue to read only manifests — none of them imports or executes a module's server entrypoint, register hook, or any other lifecycle code, exactly as ADR 0006 and ADR 0014 established. A successful production runtime startup is the one deliberate exception: it imports each installed module's compiled server entrypoint and runs its register and start hooks, because that is the only way a module's declared routes and capabilities become real, dispatchable behavior. Entrypoint modules remain trusted backend plugins, not a sandbox boundary (ADR 0006); their top-level ESM evaluation must remain side-effect-free, because ESM evaluation cannot be rolled back or unloaded once it has run — this was already a hard requirement for capability registration and remains exactly as hard a requirement now that routes are declared the same way, during the same hook.

Restart-based static installation, and ordered runtime shutdown

Installing, removing, or editing a module's routes takes effect only on the next API restart, exactly like permission composition (ADR 0014) — there is no hot installation, removal, or dynamic route mutation while the process runs. Removing a module makes its former routes return the platform's ordinary 404 {"code":"NOT_FOUND"}, the same fallback every unmatched path already receives. RunningApi's shutdown stops accepting new HTTP work and drains in-flight requests first, then stops the module runtime, then closes the shared PostgreSQL pool — in that order, so no module resource stops while a route handler might still be executing. One memoized shutdown promise is shared by every concurrent or later caller; each of the three steps runs at most once regardless of how many times shutdown() is called. Unlike a startup-failure cleanup path (which always swallows a secondary cleanup failure so it never masks the original error), a module-stop failure during normal shutdown is not swallowed — it is a genuine result a caller should observe — but database cleanup still always completes first, so a stop failure never leaves the pool open.

Alternatives

  • Hono or another framework's own request/response/routing types in the contracts layer was rejected: it would leak an implementation-layer framework choice into the one public contract every module and Core depend on, and would make swapping the HTTP transport a breaking contract change instead of an internal one.
  • Declaring routes in the static module manifest (alongside capabilities and permissions) was rejected: a route's handler is executable behavior, not inert data a manifest can express without executing module code, and duplicating the same route between a manifest and the runtime definition would create two sources of truth that could silently drift.
  • Permissionless routes, or a route/handler that selects its own baseline were rejected outright by the contract's shape: there is no field for either, so no caller can construct a route that omits a permission or asserts its own trust level.
  • An arbitrary or module-chosen mount prefix was rejected in favor of a single platform-owned /api/v1/modules/<module-id> namespace: a module choosing its own path risks colliding with Core's own routes or another module's, and removes the platform's ability to reason about the complete route surface from one place.
  • A second module discovery pass, or a second route/runtime registry, for HTTP routes specifically was rejected in favor of reusing the exact discovery Phase 5A already performs and staging routes inside the same StartedModuleRuntime capabilities already publish through — avoiding two independently discovered inventories that could disagree, and two registry implementations that could drift.
  • Module-owned authorization, CORS, or origin-checking middleware was rejected: every module route is enforced through the identical, already-audited requirePermission/requireTrustedOrigin mechanism Core's own routes use, so there is exactly one enforcement mechanism to reason about and audit, not one per module.
  • OPTIONS or HEAD as module-authorable methods were rejected: OPTIONS is inseparable from the platform's own CORS policy, and HEAD is fully and correctly served by Hono's own transparent GET-chain re-dispatch; making either authorable would only create a second, competing code path for behavior the platform already provides correctly.
  • Dynamic or hot route mutation (adding, removing, or replacing a route while the process runs) was rejected for this phase: it would reopen the same concurrent-mutation and partial-update hazards ADR 0014 already rejected for permission composition, for no benefit the current deployment model needs.
  • A module-declared cache policy (letting a route specify its own Cache-Control/Vary behavior) was rejected for this phase: the conservative default (no-store, vary on Origin and Cookie) is always correct, while a wrong module-declared policy could leak one user's personalized response to another; a real cache-policy contract is deferred, deliberately, rather than guessed at now.

Consequences

A module gains a real, dispatchable HTTP surface the moment its register hook declares routes against permissions its own manifest already declares — no second registration step, no module-specific transport code to write. The integration boundary is safe by construction: the contracts-layer shape structurally prevents a permissionless route or a module-asserted baseline, Core's ownership check structurally prevents borrowing another owner's permission, and apps/api's mounting reuses exactly the enforcement, origin-checking, and response-sanitization machinery already audited for Core's own routes. The operational cost mirrors ADR 0014's: every module inventory or route change requires an API restart to take effect, and — because production startup now deliberately imports and executes trusted module entrypoints, not merely their manifests — a module's compiled server code must be as trustworthy as any other backend plugin code Core already runs, since nothing about this design sandboxes it.

Deferred

Cross-reference — see ADR 0016. Module database or Drizzle access, listed immediately below as deferred, is now implemented. Every other item in this list remains deferred and unimplemented.

Module database or Drizzle access; module storage access; jobs and workflow/event automation; module-owned configuration and secrets services; a ModuleRuntimeContext authorization service; relationship-derived grant operations; reinstall/reactivation semantics for a permission or route 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; a native-client trusted-origin exemption policy; a module-declared cache-policy contract; hot module installation, removal, or reloading; Surfaces and UI; and real product-module endpoints. None of these are implemented by, or required for, the architecture this ADR records.