Files
alknet/docs/architecture/decisions/046-assembly-layer-custom-http-routes.md
glm-5.2 e0c6f61e6a docs(http): pre-decomposition sanity check fixes — /subscribe POST, direct-call cleanup, from_mcp output handling
Three issues found in the http crate spec sanity check that would have
caused problems during task decomposition, now fixed:

C1 — /subscribe GET→POST: the gateway's /subscribe is an invoke endpoint
carrying { operation, input } in the body, but was listed as GET (which
has no body). Flipped to POST with Accept: text/event-stream negotiating
the SSE response, consistent with /call's flat-JSON-body invariant.
Browsers using EventSource can't POST but use WebSocket for the
bidirectional path; the HTTP gateway's /subscribe is for non-browser
HTTP clients (fetch + ReadableStream). Touches ADR-042, ADR-047,
ADR-048, http-adapters.md, http-server.md.

C2 — stale direct-call references: three spots contradicted ADR-047
(which removed the POST /{service}/{op} direct-call surface) and
ADR-046 §3 (which states /{service}/{op} is no longer reserved).
Cleaned up in http-server.md (custom-routes intro + collision list) and
ADR-046 §6 (default-surface list).

W2 — from_mcp output handling: the spec's fallback for tools without
outputSchema was Type.Unknown(), but the correct fallback is the MCP
ContentBlock union (text|image|audio|resource|resource_link) — a
well-defined MCP type, not Unknown. Fixed http-mcp.md with the full
structuredContent-preferred-over-content-blocks logic (matching the TS
adapter and rmcp SDK), enriched references with specific rmcp source
files. Also added shared-dispatch-spine notes to http-mcp.md and
http-adapters.md cross-referencing the new research findings.

Research (docs/research/alknet-http-gateway-factoring/findings.md):
to_mcp and to_openapi share a dispatch spine (resolve → invoke → map).
Recommendation: extract a thin shared struct now, not a GatewayDispatch
trait — the server-integration layers (axum routes vs rmcp
StreamableHttpService) and wire-framing stay per-gateway. A third
gateway is not on the horizon; if one appears its server-integration
needs its own shape anyway.

Minor: WS route precedence note (websocket.md), OpenAPISpec
shared-type-not-shape clarification (http-adapters.md), date bumps.
2026-07-01 05:41:07 +00:00

12 KiB

ADR-046: Assembly-Layer Custom HTTP Routes on HttpAdapter

Status

Proposed

Context

The HttpAdapter (crates/http/http-server.md) 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_openapi gateway endpoints (/search, /schema, /call, /batch, /subscribe — ADR-042) — the sole invoke path over HTTP (ADR-047 removed the direct-call POST /{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). 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: Bearerresolve_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 /search discovery — 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 call OperationRegistry::invoke() with a proper OperationContext (caller identity from the resolved bearer token), not a bypass. The invoke() path enforces AccessControl regardless 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

  1. The assembly layer is the composition point. Custom routes are added at HttpAdapter construction, not registered dynamically at runtime. This matches the static-registration constraint (OQ-04 / ADR-010) for the HandlerRegistry; the HttpAdapter's router is likewise immutable after construction. Dynamic route addition would require ArcSwap<Router> and is not part of this ADR.

  2. Custom routes are a deployment concern, not an alknet-crate concern. alknet-http 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 alknet-http), not by alknet-http itself.

  3. 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.

  4. 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 HttpAdapter router 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)
  • crates/http/http-server.md — the HttpAdapter spec that gains the extra_routes constructor parameter