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]>
309 lines
23 KiB
Markdown
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.
|