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]>
23 KiB
0015: Static module HTTP routing and production runtime lifecycle
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. 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-arityapp.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 HonoContext, 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, carryingModuleRuntimeServices(currently just a typedModuleDatabasefacade) — 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 HonoContext, 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
handleris 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
StartedModuleRuntimecapabilities 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/requireTrustedOriginmechanism Core's own routes use, so there is exactly one enforcement mechanism to reason about and audit, not one per module. OPTIONSorHEADas module-authorable methods were rejected:OPTIONSis inseparable from the platform's own CORS policy, andHEADis fully and correctly served by Hono's own transparentGET-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/Varybehavior) was rejected for this phase: the conservative default (no-store, vary onOriginandCookie) 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.