Files
Ekklesia/docs/architecture/decisions/0015-static-module-http-routing-and-runtime-lifecycle.md
ekklesiaandClaude Opus 5 2983789779 fix: harden migration command execution
Two problems in the migration commands: no timeouts, and SQL split on literal
bytes.

`migrations:plan` and `migrations:apply` constructed their connection with
neither a connection deadline nor an operation deadline — a real server reported
`statement_timeout = 0` on the migration connection. Both now default to 10
seconds for the connection deadline, the client query timeout, and PostgreSQL's
own statement_timeout, applied at the one shared construction boundary so a
future call site cannot silently omit them. History reads, advisory-lock
queries, transaction control, migration SQL, and history writes are all covered.
The existing lock-wait deadline, the Pass 2 TLS policy, and Doctor's and the
bootstrap CLI's own timeouts are untouched, and no environment variable or flag
was added.

Explicit client fields do not win under the pinned driver. [email protected].0 builds
parameters as `Object.assign({}, config, parse(config.connectionString))`, so
the URL overrides passed options — verified directly, where an explicit
statement_timeout of 10000 alongside `?statement_timeout=0` produced a
server-reported 0, while a clean URL produced 10s. A DATABASE_URL carrying
connect_timeout, query_timeout, statement_timeout, or an `options` value setting
statement_timeout is therefore refused with the existing fixed configuration
error, before any client is constructed and without echoing the URL, parameter,
or value. Unrelated parameters are left alone.

The runner split each migration on the literal `--> statement-breakpoint` bytes.
Those bytes inside a string literal, a dollar-quoted function body, a quoted
identifier, or a comment tore the migration into fragments that were no longer
valid SQL — each case verified to produce two fragments where one was required.
Each migration's exact SQL is now passed to the client once, with no values and
no prepared-statement name, inside the existing transaction, and its history row
inserted only after that batch succeeds. One transaction still spans all pending
migrations; advisory locking, checksum validation, approval preflight, ordering,
rollback, cleanup, and sanitized error identity are unchanged. The port method
is renamed executeStatement to executeMigrationSql so the contract says what it
receives.

PostgreSQL parses the batch because it owns the only correct parser. A homegrown
splitter would have to track single quotes, dollar quoting with arbitrary tags,
quoted identifiers, line comments, nested block comments, and escape syntax to
answer a question the server already answers, and a parser dependency would add
supply-chain surface for the same reason. Drizzle's marker lines are ordinary
`--` comments when the batch is sent whole. No custom SQL parser exists here.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 23:13:52 -05:00

309 lines
23 KiB
Markdown

# 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/<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.
> **Partial correction — see [ADR 0020](0020-module-runtime-and-migration-execution-hardening.md).**
> Relying on registration order to resolve an overlap let the *permission* guarding an ambiguous
> path depend on declaration sequence. Core now rejects any two same-module, same-method routes
> that can both match one request path (`MODULE_ROUTE_OVERLAP`) during staged registration, so no
> such pair ever reaches mounting and no ordering decides which permission applies. Preserving the
> publication order of *disjoint* routes, and everything else in this section, is unchanged and
> still accepted. 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](0016-module-database-access.md).**
> "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](0016-module-database-access.md).**
> 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.