# 0015: Static module HTTP routing and production runtime lifecycle - Status: Accepted - Date: 2026-07-24 - Extends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md) and [0014](0014-module-permission-declarations-and-platform-registry.md) ## 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/` 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/`, 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 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/` 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 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.