Files
glm-5.3-flash ac6b4b6c9a fix(gateway): unify INVALID_INPUT to 422 on hand-rolled paths + sink deadline (GW-16, GW-17)
GW-16: empty body / malformed first line / missing header fields /
per-line cap / batch over-cap rejections now route through
call_error_to_http_response_with_identity, mapping INVALID_INPUT to
422 — same status as mid-stream chunk errors. One error class, one
status.

GW-17: invoke_sink wraps the registry sink invoke in the same 30 s
tokio::time::timeout the Once-op invoke uses; a hung sink handler
surfaces as a TIMEOUT (504, retryable) error envelope instead of
holding the HTTP connection forever. The sink wrapper bounds the
whole dispatch (chunk pacing included), matching http-server.md's
deadline contract.

Docs: http-server.md error table documents the 422 triggers and the
sink deadline; http-adapters.md batch cap status corrected.

to_openapi: gateway spec version 1.3.0 -> 1.4.0 (ADR-045 minor):
/publish framing faults and /batch cap reject documented at 422 (the
400 slots moved with the runtime); /publish 400 slot removed; 504
description covers the sink dispatch.

Verification: scripts/verify.sh OK (397 tests); cargo test
--all-features OK (513 tests); clippy --all-features --all-targets -D
warnings OK; cargo fmt --check OK.
2026-08-31 01:03:29 +00:00

34 KiB

status, last_updated
status last_updated
draft 2026-08-27

HTTP Server

The HttpAdapter — the ProtocolHandler for h2 and http/1.1 (and WebSocket upgrade — see websocket.md). The h3/WebTransport path is out of scope in alkhttp (ADR-069); this document covers how axum is run over a bidirectional stream (BiStream), Bearer auth resolution, the HTTP-to-call dispatch, the /healthz raw route, stealth decoy, and the WebSocket upgrade route (which hands off to the channels session specified in websocket.md).

What

The HttpAdapter is constructed by the assembly layer with an Arc<dyn IdentityProvider> (constructor injection, same pattern as SshAdapter — see the alkcall crate's docs/architecture/client-and-adapters.md for the adapter contract and the alkcall crate's docs/architecture/decisions/003-auth-as-shared-core.md (ADR-003) for the shared auth core) and an Arc<OperationRegistry> (for dispatching HTTP requests to call-protocol operations). It implements ProtocolHandler for the standard HTTP ALPNs.

pub struct HttpAdapter {
    identity_provider: Arc<dyn IdentityProvider>,
    registry: Arc<OperationRegistry>,
    /// The default handler for paths that are not registered operations
    /// (stealth decoy). Configurable: a static site, a fake 404, a
    /// redirect. Two-way-door default (ADR-010).
    decoy: DecoyConfig,
    /// Deployment-specific routes added by the assembly layer (ADR-046).
    /// None = the default surface only. Custom routes are raw HTTP, not
    /// call-protocol operations; they coexist with the default surface and
    /// are not described by `to_openapi`.
    extra_routes: Option<Router>,
}

/// The stealth decoy surface for paths that are not registered
/// operations (and not `/healthz`, `/openapi.json`, the `to_openapi`
/// gateway endpoints `/search`/`/schema`/`/call`/`/batch`/`/subscribe`/
/// `/publish`, or the MCP route). Set by the assembly layer at
/// `HttpAdapter` construction. The existence of the decoy path is fixed
/// by ADR-010; the variant is a two-way-door config default.
pub enum DecoyConfig {
    /// Serve a fake `404 Not Found` (the default — matches the reference
    /// implementation's "fake nginx 404").
    NotFound,
    /// Serve a static site from a configured directory (the directory
    /// path is the payload). For deployments that want a real decoy
    /// website.
    StaticSite { root: PathBuf },
    /// Redirect to a configured URL.
    Redirect { to: String },
}

#[async_trait]
impl ProtocolHandler for HttpAdapter {
    fn alpn(&self) -> &'static [u8];   // returns the configured ALPN
    async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError>;
}

The HttpAdapter registers for multiple ALPNs (http/1.1, h2). The endpoint's HandlerRegistry maps each ALPN byte string to the same adapter instance; handle() branches on connection.remote_alpn() to pick the HTTP framing. For http/1.1 and h2, the framing is hyper's HTTP/1.1 or HTTP/2 over the bidirectional stream the connection yields. WebSocket upgrade (see websocket.md) layers on top of the same hyper connection driver — a WS upgrade is an HTTP/1.1 or HTTP/2 request that switches protocols. There is no h3 ALPN: h3/WebTransport is out of scope in alkhttp (ADR-069).

Why

HTTP is the standard external interface. Browsers, curl, axios, API gateways, and load balancers all speak HTTP. Serving HTTP on the standard ALPNs means any HTTP client can connect without knowing about the alk stack — the TLS handshake negotiates h2 or http/1.1 normally. This is the stealth mapping (ADR-010): the HTTP surface is the decoy for clients that don't offer alk ALPNs, and the real external API surface for clients that do know about the alk stack.

Architecture

Running axum over a bidirectional stream

The HttpAdapter::handle() method for h2/http/1.1:

  1. Accepts one bidirectional stream from the connection (connection.accept_bi()BiStream). Over a multi-stream transport this is one of many streams the connection provides; over a single-stream connection (Connection::from_bidi, alkcall ADR-007 "Connection::from_stream — Generic Single-Stream Connections") it is the one stream, yielded once then ConnectionClosed. Either way, accept_bi returns the joined BiStream (AsyncRead + AsyncWrite, per alkcall ADR-005 "BiStream Type Definition") the adapter needs — the handler code is transport-agnostic.
  2. Wraps the BiStream as a hyper TokioIo-compatible stream — the same byte stream hyper expects for an HTTP connection.
  3. Constructs the axum Router (built once at adapter construction, cloned per connection — axum Router is Clone and cheap to clone).
  4. Hands the stream + the axum router to hyper's connection driver (hyper::server::conn::http1::Builder or http2::Builder::serve_connection), which reads HTTP frames, parses them, dispatches to axum routes, and writes HTTP responses.
  5. Returns when the HTTP connection closes (the client disconnects or the stream ends).

The axum Router is built once at adapter construction with the Arc<OperationRegistry> and Arc<dyn IdentityProvider> embedded in its state; cloning the Router per connection clones the Arcs (cheap, shared state), so every request handler has access to the registry and identity provider through the router's state.

The axum Router is the single routing surface for HTTP requests. It contains:

  • The to_openapi gateway endpoints (/search, /schema, /call, /batch, /subscribe, /publish — ADR-042, with /publish per alkhttp ADR-068). These 6 fixed endpoints are the sole invoke path over HTTP: an HTTP client invokes an operation via POST /call with { "operation": "/{service}/{op}", "input": {...} }, discovers available operations via GET /search (AccessControl-filtered), and learns an operation's shape via GET /schema. POST /subscribe is the SSE streaming invoke path (body { operation, input }, same shape as /call, response text/event-stream). POST /publish is the Pub streaming invoke path (producer→consumer streaming via call.published; alkcall ADR-046 "Publish Operation Type and HandlerKind::Sink"). There is no per-operation POST /{service}/{op} direct-call surface — the gateway is the invoke path (ADR-047 supersedes ADR-036's direct-call surface; the simplified contract is a few fixed endpoints, not a per-operation REST tree). /call and /subscribe dispatch through OperationRegistry::invoke(); /publish dispatches through OperationRegistry::invoke_sink(); /search and /schema dispatch the services/list / services/schema discovery ops.
  • GET /healthz (raw route, no auth, no call protocol).
  • GET /openapi.json (serves the to_openapi projection — the OpenAPI document that describes the 6 gateway endpoints. The doc describes the 6 fixed endpoints, and the per-caller operation surface is discovered via /search, not preloaded into paths. The doc carries info.version (semver) tracking the gateway endpoint contract — consumers detect breaking changes via the major version (ADR-045)).
  • The stealth decoy fallback (unknown paths).
  • (Feature-gated) POST /mcp (the to_mcp streamable HTTP service — http-mcp.md).
  • Deployment-specific custom routes (ADR-046). The assembly layer may inject an axum::Router of extra routes at HttpAdapter construction — e.g., an OpenAI-compatible proxy at /v1/chat/completions that dispatches into the registry. These are raw HTTP, not call-protocol operations: not in the OperationRegistry, not discoverable via /search, not described by to_openapi. The default surface's reserved paths take precedence on collision; custom routes namespace away from the reserved set naturally (/v1/...). A deployment that passes no extra routes gets exactly the default surface above. A deployment that wants a REST-like per-operation HTTP surface (the former direct-call shape) builds it as a custom route projection (ADR-047 §4). See ADR-046 and §"Custom routes" below.

A single HTTP/2 or HTTP/1.1 connection multiplexes multiple requests over the one bidirectional stream (HTTP/2 multiplexing is native; HTTP/1.1 is sequential). The axum router handles each request on a tokio task; the hyper driver manages the connection lifetime.

HTTP-to-call dispatch (the gateway's /call; ADR-042, ADR-047)

An HTTP client invokes an operation via the gateway's /call endpoint:

  1. The axum route handler for POST /call reads the JSON body { "operation": "/fs/readFile", "input": {...} }.
  2. It resolves the caller's identity from the Authorization: Bearer header via identity_provider.resolve_from_token(&AuthToken { raw: token_bytes }).
  3. It constructs the root OperationContext (caller identity, the registration bundle's capabilities, the connection's env composition) and dispatches through the OperationRegistry::invoke() — the same dispatch path the CallAdapter uses for alk/call wire requests.
  4. The response (ResponseEnvelope) is serialized as the HTTP response body (JSON). Errors map to HTTP status codes (see Error Mapping below).

Internal operations (ADR-015) return 404 (NOT_FOUND) — the gateway dispatches only External operations, and the caller discovers which External operations it can call via the AccessControl-filtered /search endpoint. This is the per-caller API surface property that the direct-call surface (removed, ADR-047) lacked: an HTTP client cannot stub its toe on a path for an operation it can't call, because there is no per-operation path — /search tells it what it can call, /call invokes it, and the AccessControl check runs on /call regardless.

/batch follows the same dispatch path with an array of { operation, input } pairs (OQ-14); /subscribe follows it with the SSE streaming projection (below); /publish follows it with the invoke_sink() dispatch and HandlerKind::Sink handlers (alkcall ADR-046), projecting the HTTP request body as the initiator's stream into the operation's sink.

Streaming projection (SSE — the gateway's /subscribe)

A Sub operation invoked via the gateway's POST /subscribe endpoint projects its call.responded stream as Server-Sent Events. The request body is { operation, input } (the same flat JSON shape as /call); the response is text/event-stream (negotiated via Accept: text/event-stream on the POST). The axum route handler:

  • Sets Content-Type: text/event-stream.
  • Calls GatewayDispatch::invoke_streaming() (alkcall ADR-021 "Streaming Handler for Subscription Operations") — the streaming analogue of invoke(), returning a BoxStream<ResponseEnvelope>. The security invariants are identical to invoke(): internal: false, forwarded_for: None, same capabilities, same scoped_env, same ACL check before dispatch. The two methods diverge only on the return shape (stream vs single envelope). Streaming invokes set deadline: None — subscriptions are unbounded by contract, unlike the 30 s gateway deadline on Once-op and sink invokes (see Error Mapping below).
  • For each ResponseEnvelope the stream yields, writes an SSE data: frame: Ok(value)data: frame with the output serialized as JSON; Err → SSE error event with the CallError serialized, then close (an Err is terminal — the stream ends after it, matching the wire protocol's call.error semantics; the gateway enforces this with a scan-based framing that emits the error frame and terminates the stream rather than continuing after it).
  • On natural stream end (the streaming handler's stream completes), closes the SSE stream (normal end — corresponds to call.completed on the wire).
  • On call.aborted or HTTP client disconnect (detected as the response writer closing), drops the stream future — Drop guards release the handler's resources, and the abort cascade runs per alkcall ADR-020 "Abort Cascade for Nested Calls".
  • Sends SSE keep-alive comment frames on quiet streams every 15 s and carries a retry: 15000 field on stream events (a client that loses the stream reconnects after 15 s). Quiet-but-alive is the normal state for subscriptions; without keep-alive, LB/proxy idle timeouts (30-60 s) silently terminate the connection.

Status-code asymmetry on /subscribe (GW-12). Because the SSE response is committed as HTTP 200 before the stream runs, pre-dispatch failures on /subscribe (unknown op, Internal op, ACL denial) surface as HTTP 200 + an event:error frame with the mapped CallError — while the same denial on /call surfaces as HTTP 401/403 (ADR-049 makes errors-on-the-stream defensible: the response is the stream). Standard HTTP monitoring sees no auth failures on /subscribe; clients must inspect event:error frames. This asymmetry is deliberate, not an oversight, and applies to all stream runs — the OpenAPI projection documents the same behavior (PRJ-05).

This is the HTTP/1.1 + HTTP/2 streaming projection. Over WebSocket (websocket.md), the subscription projects directly onto the channels session — call.responded events as binary messages on the subscribed channel, no SSE framing. WebTransport (h3) is out of scope in alkhttp (ADR-069).

The streaming dispatch path. Pre-ADR-021, subscribe_handler called GatewayDispatch::invoke() (single response) and wrapped the one ResponseEnvelope in a one-event SSE stream — a placeholder that couldn't stream a real Sub op. alkcall ADR-021 adds GatewayDispatch:: invoke_streaming() and the underlying OperationRegistry:: invoke_streaming(), giving /subscribe a real streaming dispatch path to call. See alkcall ADR-021 and http-adapters.md for the from_openapi SSE forwarding handler that feeds streaming handlers from external text/event-stream responses.

One-directional projection (HTTP request/response)

The HTTP/1.1 + HTTP/2 surface is a lossy, one-directional projection of the call protocol. HTTP is request/response: the consumer initiates, the producer responds. The call protocol is bidirectional — both sides can initiate calls (see the alkcall crate's docs/architecture/call-protocol.md §"Bidirectional Calls": the accept side can call operations on the connect side just as the connect side calls operations on the accept side). The HTTP projection carries only the consumer→producer call direction; the producer→consumer call direction has no HTTP expression (there is no HTTP mechanism for the producer to initiate a request to the consumer). Sub streaming is the one partial exception — the producer streams call.responded frames back over the SSE response — but even there, the call is consumer-initiated; only the results flow producer→consumer.

This is a structural property of HTTP, not a design choice in this crate. WebSocket restores the bidirectional call model for browsers (see websocket.md): a WS connection is a long-lived full-duplex channel over which either side can send call.requested frames in either direction — the call protocol's native bidirectionality applies unchanged (alkcall ADR-015 "Call Protocol Stream Model" — stream-agnostic correlation; a WS message stream is another BiStream-satisfying transport). WebTransport (h3) would restore it via native multi-stream multiplexing, but WebTransport is out of scope in alkhttp (ADR-069) — WebSocket is the v1 browser bidirectional path. The HTTP/1.1 + HTTP/2 surface is the projection for clients that only speak HTTP; WebSocket is the surface for browser clients that speak the call protocol in both directions.

WebSocket browser path (ADR-048, ADR-067)

A browser (or any WS client) upgrades an HTTP/1.1 or HTTP/2 request to WebSocket (RFC 6455) at the upgrade path /alk/channels; the resulting full-duplex WS connection carries the channels protocol (alkhttp ADR-067; framing per alkcall ADR-034 "Channels Wire Format — 8-Byte Chunk Header"): 8-byte chunk multiplexing, with channel 0 pre-negotiated as alk/call (alkcall ADR-036 "Channel 0 Is Pre-Negotiated alk/call"). The shared Dispatcher runs on channel 0 — the WS path is a channels session whose channel 0 is the call-protocol session, and it is the surface that restores the call protocol's native bidirectionality for browsers (unlike the one-directional HTTP projection above). The WS path carries the native session, not the HTTP gateway shape (ADR-048): the gateway endpoints are HTTP-only, discovery is via services/list/services/schema as call-protocol ops, and subscriptions project as native call.responded events (no SSE).

The full WS handler specification — the upgrade route, the channels framing, the dispatch handoff to the shared Dispatcher on channel 0, bidirectionality, the connection-local Layer 2 overlay, the "browsers are not peers" rationale (ADR-034 §4; the old ADR-044 §5 amendment is out of scope with WebTransport per ADR-069), the streaming projection, and the deferred from_wss adapter — is at websocket.md. h3/WebTransport is out of scope in alkhttp (ADR-069); the ALPN-stream-proxy path (alknet ADR-040) is an alknet concern and is not available here.

Auth

Inbound HTTP auth is Authorization: Bearer <token>, resolved via IdentityProvider::resolve_from_token() (the auth handler table: HttpAdapter, Bearer header, resolve_from_token). Bearer-only is the auth mechanism for the default surface; other HTTP auth schemes (Basic, API key in query param) are not implemented and would be added as axum middleware (two-way door). This is recorded in ADR-036 §Auth; the resolution mechanism (resolve_from_token) is from alkcall ADR-003 "Auth as Shared Core (IdentityProvider)", and the connection-level observability (set_identity) is OQ-11 (resolved).

  • Bearer-only is the auth mechanism. Basic auth, API keys in query params, and other HTTP auth schemes are not implemented. A deployment that needs a different auth scheme adds it as axum middleware (two-way door), but the default surface is Bearer-only.
  • The HttpAdapter constructor-injects Arc<dyn IdentityProvider>, same pattern as SshAdapter.
  • An unauthenticated request to an operation with AccessControl restrictions returns 401 (no token) or 403 (token present but insufficient scopes). The call protocol's FORBIDDEN protocol code maps to 403; NOT_FOUND (Internal op) maps to 404.
  • The HTTP handler stores the resolved identity on the Connection for observability (connection.set_identity(identity)), same as the call protocol handler.

Error Mapping

Call-protocol CallError codes (alkcall ADR-016 "Operation Error Schemas") map to HTTP status codes:

Call code HTTP status Notes
NOT_FOUND (operation not registered, or Internal op) 404
FORBIDDEN (insufficient scopes, or unauthenticated) 401 (no token) / 403 (token present)
INVALID_INPUT (input-data fault) 422 one status for every trigger: schema mismatch, /publish NDJSON framing faults (empty body, malformed first line, missing operation/chunk, per-line cap, body-read failure), and the /batch over-cap reject (INVALID_INPUT is an input-data fault wherever it fires — GW-16 unified the formerly hand-rolled 400s onto this row)
INVALID_OPERATION_TYPE (wrong dispatch path for the op's type) 422 (token present) / 401 (no token) consistent across /call, /batch, /publish — a client fault, never a server fault
TIMEOUT 504 retryable: true
INTERNAL 500
Operation-level domain code with http_status (alkcall ADR-016) the declared http_status from_openapi-imported ops carry the original status
Operation-level domain code without http_status 500

The retryable field from CallError maps to an HTTP Retry-After hint for 429/503-class statuses: the value comes from the error's details.retry_after (string or number seconds). All live gateway error paths (/call, /batch entries, /search, /schema, /publish) route through the shared identity-aware mapper (call_error_to_http_response_with_identity), so a retryable HTTP_429/HTTP_503 from a handler carries Retry-After on every endpoint. The mapping is a two-way-door default (the exact status for ambiguous codes can be refined additively); the one-way constraint is that protocol-level and operation-level codes are distinct (alkcall ADR-016) and from_openapi-imported codes are prefixed HTTP_<status> to avoid collision with protocol codes.

Per-endpoint dispatch deadline. Once-op invokes (/call, /batch entries, /search, /schema) and sink invokes (the /publish final envelope) are bounded by the 30 s gateway deadline (GatewayDispatch::invoke and GatewayDispatch::invoke_sink wrap the registry invoke in tokio::time::timeout, GW-17); a hung handler — Once or sink — surfaces as a TIMEOUT error (504, retryable: true), not an indefinitely-held HTTP request. The sink wrapper bounds the whole dispatch (chunk pacing included), so the final envelope always arrives, or the deadline trips, within the window. Streaming invokes (/subscribe) are unbounded — subscriptions are long-lived by contract (alkcall ADR-021 sets deadline: None for the streaming branch). The same time/bytes split governs the outbound half of an imported subscription: from_openapi/from_jsonschema SSE forwards send through the shared HTTP client's stream client, built without the total request timeout (FWD-15) so a healthy subscription is not killed at the 30 s outbound deadline, while a total streamed-bytes cap (HttpClientConfig::stream_total_byte_cap, default 1 GiB) bounds the bytes a single subscription may pull (FWD-14) and the client's read timeout remains the upstream-staleness guard. Unbounded time, bounded bytes — on both the gateway and the forwarding side.

/healthz (raw route)

GET /healthz is a raw HTTP route outside the call protocol — no auth, no operation registration, no OperationContext. It returns 200 OK with a plain-text body (e.g., "ok") if the endpoint is healthy. This is the infrastructure endpoint load balancers and orchestrators call; it must work before identity is resolvable.

Other operational endpoints (metrics, dashboard) are call-protocol operations if built (/metrics/list, /dashboard/view), not raw HTTP routes. healthz is the one exception. See ADR-036.

Stealth decoy

For paths that are not the gateway endpoints (/search, /schema, /call, /batch, /subscribe, /publish), /healthz, /openapi.json, the MCP route, or a custom route per ADR-046), the HTTP handler serves a decoy. The decoy is configurable (DecoyConfig):

  • A fake 404 Not Found (the default — matches the reference implementation's "fake nginx 404").
  • A static site (served from a configured directory).
  • A redirect (to a configured URL).

The decoy is the stealth surface: a port scanner or a client that doesn't offer alk ALPNs connects on h2/http/1.1 and sees the decoy. Real services use alk/ssh, alk/call, etc. The decoy config is a two-way-door default (an operator picks what to serve); the existence of the stealth path is fixed by ADR-010. Custom routes (ADR-046) take precedence over the decoy — a path matched by a custom route is served by it, not the decoy; the decoy is the fallback for paths matched by neither the default surface nor a custom route.

Custom routes (ADR-046)

A deployment that needs HTTP endpoints outside the default surface (gateway + /healthz + /openapi.json + MCP) injects them as an axum::Router at HttpAdapter construction. The classic use case: an OpenAI-compatible proxy at /v1/chat/completions that wraps a call-protocol operation (the deployment parses the OAI request, invokes an openai/chat or agent/chat op via OperationRegistry::invoke(), reformats the response as an OAI response). The hub is a standard alk node plus a deployment-specific HTTP surface.

Custom routes:

  • Are raw HTTP, not call-protocol operations — not registered in the OperationRegistry, not discoverable via /search, not in the to_openapi gateway doc.
  • May dispatch into the registry via OperationRegistry::invoke() with a proper OperationContext (caller identity from the resolved bearer token) — the OAI proxy does this. Or they may be pure HTTP (a webhook receiver, a static asset server) that never touches the registry.
  • Run under the default Bearer-auth middleware; a route that wants different auth applies its own axum middleware (the deployment owns its custom routes' middleware stack).
  • Do not collide with the reserved default-surface paths (/search, /schema, /call, /batch, /subscribe, /publish, /healthz, /openapi.json, the MCP route) — the default surface wins on collision; custom routes namespace away naturally (/v1/...). (ADR-047 removed the direct-call POST /{service}/{op} surface, so /{service}/{op} 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, subject to the same collision rule.)
  • Are not versioned by to_openapi (ADR-045 versions the gateway contract, not custom routes). The deployment versions its own custom routes however it wants.
  • Are immutable after construction (matches OQ-04 / ADR-010's static-registration constraint; the HttpAdapter router is built once at startup).

The extension point is additive: a deployment that passes None gets exactly the default surface. The mechanism (the constructor parameter) is the one-way door — once downstream deployments build against it, it's a contract (ADR-046). The specific routes a deployment adds are a two-way door (add/remove freely). See ADR-046.

Constraints

  • The gateway is the sole invoke path over HTTP (ADR-042, ADR-047). The 6 gateway endpoints (/search, /schema, /call, /batch, /subscribe, /publish) are the only way to invoke operations over HTTP. There is no per-operation POST /{service}/{op} direct-call surface — the simplified contract is a few fixed endpoints, not a per-operation REST tree. A client invokes an operation via POST /call with { "operation": "/{service}/{op}", "input": {...} }; it discovers what it can call via the AccessControl-filtered /search. The per-caller API surface is the default (the Gitea failure mode — every operation gets a path, every caller sees the full surface — is structurally impossible). A deployment that wants a REST-like per-operation HTTP surface builds it as a custom route projection (ADR-046, ADR-047 §4).
  • External operations only. Internal operations return 404 on the gateway's /call, matching the call protocol's NOT_FOUND.
  • Bearer-only auth. Authorization: Bearerresolve_from_token. Other HTTP auth schemes are not implemented.
  • No secret material in HTTP responses. The call protocol carries no secret material (alkcall ADR-010 "Secret Material Flow and Capability Injection"); the HTTP handler inherits this constraint. Capabilities are used for outbound calls (from_openapi), never serialized into HTTP response bodies.
  • /healthz is raw. No auth, no call protocol. The one raw route.
  • WebSocket is the browser bidirectional path (ADR-048, ADR-067). A browser upgrades an HTTP request to WS at /alk/channels and the connection carries the channels protocol (8-byte chunk multiplexing; alkcall ADR-034) — channel 0 is pre-negotiated as alk/call (alkcall ADR-036) and runs the shared Dispatcher; the native session, not the gateway shape (the gateway endpoints are HTTP-only; discovery via services/list/services/schema as call-protocol ops). h3/WebTransport is out of scope in alkhttp (ADR-069); the ALPN-stream-proxy (alknet ADR-040) is not available. The h3 ALPN and its feature gate are not implemented. Full WS handler spec: websocket.md.
  • Custom routes are raw HTTP, not call-protocol operations (ADR-046). The assembly layer injects an axum::Router of extra routes at HttpAdapter construction. They are not in the OperationRegistry, not discoverable via /search, not in the to_openapi doc. They may dispatch into the registry via OperationRegistry::invoke() (the OAI-compatible proxy pattern) or be pure HTTP. The default surface's reserved paths take precedence on collision. A deployment that passes no extra routes gets the default surface unchanged.

Design Decisions

Decision ADR Summary
Direct path mapping (HTTP path = operation path) ADR-036 Superseded by ADR-047 — direct-call surface removed; gateway /call is the sole invoke path
Gateway is the sole invoke path over HTTP ADR-042, ADR-047 6 fixed gateway endpoints (/search//schema//call//batch//subscribe//publish); POST /call with { operation, input } is the invoke path; per-caller AccessControl-filtered /search is the discovery; no per-operation HTTP paths
/publish gateway endpoint for Pub operations alkhttp ADR-068 POST /publish projects an HTTP request body as the initiator's stream into invoke_sink() / HandlerKind::Sink (alkcall ADR-046)
to_openapi published-spec versioning ADR-045 /openapi.json carries info.version (semver) tracking the gateway contract, not the operation set
SSE projection for subscriptions (/subscribe) ADR-036 §Streaming, ADR-042 §2 call.responded stream → SSE frames; the gateway's /subscribe endpoint is the entry point
/healthz is a raw route ADR-036 No auth, no call protocol
Stealth decoy ADR-010 HTTP handler on standard ALPNs serves decoy for non-gateway, non-custom, non-/healthz paths
Bearer auth via resolve_from_token alkcall ADR-003 "Auth as Shared Core (IdentityProvider)" HTTP handler credential source (settled)
WebSocket is the browser bidirectional path ADR-048, alkhttp ADR-067 Browsers upgrade to WS at /alk/channels; the channels protocol over binary messages (8-byte chunk multiplexing, alkcall ADR-034), channel 0 pre-negotiated as alk/call (alkcall ADR-036); h3/WebTransport out of scope (ADR-069). WS carries the native session, not the gateway shape (gateway endpoints are HTTP-only). Full spec: websocket.md
Browsers are not alk peers ADR-034 §4 Bearer token, no PeerId, connection-local overlay (addressability vs. bidirectionality) — full rationale in websocket.md
Error mapping (call codes → HTTP status) alkcall ADR-016 "Operation Error Schemas" Protocol/operation codes distinct; HTTP_<status> prefix for imported
Custom HTTP routes from the assembly layer ADR-046 extra_routes: Option<Router> at construction; raw HTTP, not operations; default surface takes precedence on collision
Streaming handler for subscriptions (invoke_streaming()) alkcall ADR-021 "Streaming Handler for Subscription Operations" GatewayDispatch::invoke_streaming() returns BoxStream<ResponseEnvelope>; /subscribe pipes it to SSE; replaces the one-event placeholder with the real streaming dispatch path

Open Questions

See the alkhttp crate's docs/architecture/open-questions.md (when present) and the alkcall crate's docs/architecture/open-questions.md for full details.

  • OQ-39 (resolved): to_openapi published-spec versioning — resolved by ADR-045: info.version semver tracks the gateway endpoint contract (not the operation set); the per-caller operation surface is discovered via /search and does not bump the version.
  • OQ-40 (resolved): reqwest client config and connection pooling — ClientWithMiddleware + middleware stack; the outbound HTTP client used by from_openapi/from_mcp.

References

  • ADR-036 — the HTTP-to-call mapping this server implements
  • ADR-048 — WS carries the native session (via the channels protocol, channel 0 pre-negotiated as alk/call), not the HTTP gateway shape; the gateway endpoints are HTTP-only.
  • alkhttp ADR-068 — the /publish gateway endpoint (Pub operations, invoke_sink() dispatch)
  • alkhttp ADR-069 — h3/WebTransport out of scope in alkhttp
  • alkcall ADR-003 "Auth as Shared Core (IdentityProvider)" — Bearer → resolve_from_token
  • alkcall ADR-014 "irpc Was Never Integrated — Hand-Rolled EventEnvelope Framing" — the EventEnvelope wire format
  • alkcall ADR-015 "Call Protocol Stream Model" — the Dispatcher (stream-agnostic; runs over the channels session unchanged)
  • alkcall ADR-034 "Channels Wire Format — 8-Byte Chunk Header" — the channels framing the WS path carries
  • alkcall ADR-036 "Channel 0 Is Pre-Negotiated alk/call" — channel 0 as the call-protocol session
  • websocket.md — the full WS browser path spec (upgrade route, channels framing, dispatch on channel 0, bidirectionality, connection-local overlay, streaming projection, the deferred from_wss adapter)
  • overview.md — crate overview, adapter location map
  • http-adapters.mdfrom_openapi/to_openapi
  • the alkcall crate's docs/architecture/call-protocol.mdEventEnvelope wire format, Dispatcher, bidirectional calls
  • the alkcall crate's docs/architecture/client-and-adapters.md — the adapter contract (CallAdapter dispatch path), IdentityProvider constructor injection
  • the alkcall crate's docs/architecture/operation-registry.mdOperationRegistry::invoke() / invoke_streaming() / invoke_sink(), the dispatch path HTTP requests hit