- apply bearer_auth_middleware route_layer AFTER the extra_routes merge, so assembly-layer custom routes resolve the bearer token by default (ADR-046 §4); per-route opt-out via the route's own layer remains - enforce RESERVED_PATHS per-method at build time: a probe MethodRouter occupied on all methods is pre-merged against extras, so a custom POST /search panics like a same-method overlap (ADR-046 §3) - tests: auth resolves through an extra route; an extra route with its own layer opts out; reserved-path merge panics; non-reserved different-method merge stays legal; MCP bearer-gate test stays green - ADR-046 §3: one sentence restating the per-method rejection rule Verification: cargo test (215) ok, cargo test --all-features (260 + integration) ok, clippy -D warnings (default + all-features) ok, cargo fmt --check ok.
13 KiB
ADR-046: Assembly-Layer Custom HTTP Routes on HttpAdapter
Ported from alknet ADR-046 (Assembly-Layer Custom HTTP Routes on HttpAdapter); re-targeted to alkhttp.
Status
Proposed
Context
The HttpAdapter (see http-server.md in docs/architecture/) is
constructed by the assembly layer with an Arc<dyn IdentityProvider>, an
Arc<OperationRegistry>, and a DecoyConfig. The axum Router it
builds has a fixed surface:
- The
to_openapigateway endpoints (/search,/schema,/call,/batch,/subscribe— ADR-042) — the sole invoke path over HTTP (ADR-047 removed the direct-callPOST /{service}/{op}surface that ADR-036 originally defined). /healthz,/openapi.json, the MCP route (feature-gated), and the decoy fallback for unknown paths.
There is no documented extension point for a downstream deployment to add its own HTTP routes to this router. A deployment that wants to expose a custom HTTP endpoint — one that is not a gateway endpoint — has no specified way to do so. The architecture currently ties the HTTP surface to the simplified contract with no escape hatch.
The concrete use case
A hub deployment (e.g., api.alk.dev) wants to expose the standard
alknet contract (direct-call + gateway) and an OpenAI-compatible
proxy at /v1/chat/completions. The OAI proxy is a custom HTTP route:
it receives an OpenAI-shaped request, dispatches into the
OperationRegistry (likely to a from_openapi-imported openai/chat
operation or a custom agent operation), and returns an OpenAI-shaped
response. It is not an alknet operation — it is a deployment-specific
HTTP endpoint that uses the registry as a backend.
This pattern is not exotic. It is the standard "wrap an external API shape around our operations" pattern: a deployment adds a compatibility shim (OAI-compatible, Anthropic-compatible, a legacy API shape) as a custom route, backed by call-protocol operations. The alternative — forcing every custom endpoint to be a call-protocol operation whose input/output match the external API's shape — is brittle (the OAI streaming response shape is not a clean call-protocol output) and unnecessary (the deployment owns the HTTP shape; the registry owns the operation shape).
The runner pattern that motivates this (remote GPU instance downloads
a binary, connects back to the hub via from_call, registers its ops;
opencode connects to the hub as a standard OAI provider) is already
supported by the existing architecture (from_call, PeerRef routing,
from_openapi to wrap the OAI API). The only missing piece is the
custom HTTP route on the hub.
Why this needs an ADR
The extension mechanism — how the assembly layer injects custom routes
— is a published API surface of HttpAdapter. Once downstream
deployments build against it (passing their custom routers at
construction), changing the mechanism is a one-way door (every consumer
construction site breaks). It needs an ADR before implementation so the
contract is deliberate, not accidental.
The specific routes a deployment adds are a two-way door (add/remove freely, no protocol contract). The mechanism (the constructor parameter and its semantics) is the one-way door this ADR commits.
Decision
1. HttpAdapter accepts additional axum routes from the assembly layer
The HttpAdapter constructor gains a parameter for deployment-specific
routes. The assembly layer builds an axum::Router with its custom
routes and passes it in; HttpAdapter composes it with the default
surface. A deployment that passes no custom routes gets exactly the
documented default behavior — the extension point is additive, not
mandatory.
pub struct HttpAdapter {
identity_provider: Arc<dyn IdentityProvider>,
registry: Arc<OperationRegistry>,
decoy: DecoyConfig,
/// Deployment-specific routes added by the assembly layer. None =
/// the default surface only. See ADR-046.
extra_routes: Option<Router>,
}
The exact composition mechanism (merge vs nest vs builder, whether
custom routes get a prefix) is a two-way-door implementation detail;
the one-way constraint is that the assembly layer can inject routes
and they coexist with the default surface. axum's Router::merge /
Router::nest are the natural primitives.
2. Custom routes are raw HTTP, not call-protocol operations
A custom route is a raw axum handler — it receives an HTTP request and
returns an HTTP response. It is not registered in the
OperationRegistry, not discoverable via /search, not described in
the to_openapi gateway doc. The deployment owns its shape entirely.
A custom route may dispatch into the OperationRegistry (via
OperationRegistry::invoke(), same as the gateway's /call endpoint
does) if
it wants to back the HTTP endpoint with a call-protocol operation. The
OAI-compatible proxy does this: the /v1/chat/completions handler
parses the OAI request, invokes the openai/chat (or agent/chat)
operation, and reformats the response as an OAI response. But this is
the custom route's choice — it could equally be a pure HTTP handler
that never touches the registry (a webhook receiver, a static asset
server, a legacy API shim with its own backend).
3. The default surface's reserved paths take precedence on collision
The default-surface paths are reserved: /search, /schema, /call,
/batch, /subscribe, /healthz, /openapi.json, and the MCP route.
(ADR-047 removed the direct-call /{service}/{op} surface, so it is no
longer a reserved path; a deployment that builds a per-operation
projection as a custom route is the one case where /{service}/{op}
patterns appear, and those custom routes are subject to the same
collision rule.) If a custom route collides with a reserved path, the
default surface wins — the custom route is silently shadowed (or the
construction panics/warns; the specific collision-handling is a
two-way-door implementation detail). alkhttp panics at construction for
both the same-method overlap (axum's merge) and the different-method
case (a pre-merge probe over the reserved set), so a custom
POST /search next to the default GET /search is rejected, not
silently served. A deployment that wants
/v1/chat/completions namespaces it away from the reserved set, which
is natural (/v1/... doesn't collide).
4. Custom routes carry the same auth middleware by default; per-route opt-out is the deployment's choice
Custom routes run under the same Bearer-auth resolution as the default
surface (the Authorization: Bearer → resolve_from_token path). A
deployment that wants a custom route to be unauthenticated (a public
webhook receiver, a health endpoint with a different shape than
/healthz) applies axum middleware to opt that route out of auth —
the deployment owns its custom routes' middleware stack. The
HttpAdapter provides the identity provider and the default auth
middleware; the custom Router the assembly layer passes in can
layer its own middleware on top. This is standard axum composition; no
alknet-specific mechanism.
5. Custom routes are not part of the published to_openapi doc
The to_openapi gateway doc (ADR-042, ADR-045) describes the 5
gateway endpoints — the default contract. Custom routes are
deployment-specific and not described by to_openapi. A deployment
that wants its custom routes documented for external consumers
generates its own OpenAPI doc for them (a separate projection, not
to_openapi). The default info.version semver (ADR-045) tracks the
gateway contract, not custom routes — custom routes have no
versioning contract with alknet; the deployment versions them however
it wants.
6. This does not change the default surface
A deployment that constructs HttpAdapter with no extra routes gets
exactly the behavior documented in http-server.md — gateway,
/healthz, /openapi.json, MCP (feature-gated), decoy. The
extension point is purely additive. The default surface remains the
published contract (ADR-042, ADR-045; ADR-036's routing decision is
superseded by ADR-047); custom routes are a
deployment-specific addition on top, not a modification of it.
Consequences
Positive:
- Deployments can wrap external API shapes (OAI-compatible, Anthropic-compatible, legacy) around call-protocol operations without forcing the external shape into the operation's input/output schema. The "compatibility shim" pattern is first-class.
- The runner pattern (remote worker →
from_call→ hub → custom OAI route → opencode) works end-to-end with no architectural gap. The hub is a standard alknet node plus a deployment-specific HTTP surface. - The extension point is standard axum composition — no alknet-specific routing abstraction for deployers to learn. A developer who knows axum can add routes.
- The default surface is unchanged for deployments that don't need custom routes. No complexity tax for the common case.
Negative:
- The HTTP surface is no longer fully described by the alknet specs alone — a deployment's custom routes are outside the architecture docs. This is inherent to the extension point (the deployment owns them); the specs describe the default surface and the mechanism, not every possible custom route.
- A custom route that dispatches into the registry bypasses the
gateway's
AccessControl-filtered/searchdiscovery — the custom route is responsible for its own authorization story. The default Bearer-auth middleware covers the common case, but a custom route that wants per-operation ACL checks must callOperationRegistry::invoke()with a properOperationContext(caller identity from the resolved bearer token), not a bypass. Theinvoke()path enforcesAccessControlregardless of the entry point (direct-call, gateway, or custom route), so this is not an ACL bypass — but the custom route author must construct the context correctly. - Two deployments with custom routes have different HTTP surfaces — there is no single "what does an alknet HTTP endpoint look like" answer anymore. The default surface is the contract; custom routes are deployment-specific variance. This is honest (deployments do vary) but means the architecture docs describe the default, not the union.
Assumptions
-
The assembly layer is the composition point. Custom routes are added at
HttpAdapterconstruction, not registered dynamically at runtime. This matches the static-registration constraint (OQ-04 / ADR-010) for theHandlerRegistry; theHttpAdapter's router is likewise immutable after construction. Dynamic route addition would requireArcSwap<Router>and is not part of this ADR. -
Custom routes are a deployment concern, not an alknet-crate concern. alkhttp provides the extension point (accepts the extra
Router); it does not provide custom route implementations. The OAI-compatible proxy, the legacy API shim, the webhook receiver are all written by the deployment (or a downstream crate like alknet-agent that builds on alkhttp), not by alkhttp itself. -
The default surface is the published contract; custom routes are not. ADR-036 (direct-call), ADR-042 (gateway), ADR-045 (versioning) govern the default surface. Custom routes have no alknet-governed compatibility contract — the deployment owns their stability. This keeps the published-contract surface small and stable while allowing arbitrary deployment-specific extension.
-
axum's composition primitives are sufficient.
Router::merge,Router::nest, and axum middleware cover the extension patterns needed (custom routes, per-route auth opt-out, prefix namespacing). No alknet-specific routing abstraction is required. If a future need exceeds axum's composition (e.g., route-level dynamic dispatch), that would be a separate ADR.
References
- ADR-010 — static registration at
startup (the
HttpAdapterrouter is immutable after construction, same constraint) - ADR-042 — the gateway endpoints (the default surface custom routes coexist with; reserved paths)
- ADR-045 — the published doc versions the gateway contract, not custom routes
- ADR-047 — the direct-call surface is removed; the gateway is the sole invoke path (a deployment that wants the former per-operation HTTP surface builds it as a custom route projection; this ADR §4 is the mechanism)
http-server.md— theHttpAdapterspec that gains theextra_routesconstructor parameter (now indocs/architecture/)