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

598 lines
34 KiB
Markdown

---
status: draft
last_updated: 2026-08-27
---
# HTTP Server
The `HttpAdapter` — the `ProtocolHandler` for `h2` and `http/1.1` (and
WebSocket upgrade — see [websocket.md](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](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.
```rust
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](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 `Arc`s (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](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](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](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](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](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](decisions/036-http-to-call-operation-mapping.md) §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](decisions/046-assembly-layer-custom-http-routes.md).
## 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: Bearer`
`resolve_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](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](decisions/036-http-to-call-operation-mapping.md)~~ | **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](decisions/042-openapi-gateway-pattern.md), [ADR-047](decisions/047-remove-direct-call-http-surface.md) | 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](decisions/045-to-openapi-gateway-spec-versioning.md) | `/openapi.json` carries `info.version` (semver) tracking the gateway contract, not the operation set |
| SSE projection for subscriptions (`/subscribe`) | [ADR-036](decisions/036-http-to-call-operation-mapping.md) §Streaming, [ADR-042](decisions/042-openapi-gateway-pattern.md) §2 | `call.responded` stream → SSE frames; the gateway's `/subscribe` endpoint is the entry point |
| `/healthz` is a raw route | [ADR-036](decisions/036-http-to-call-operation-mapping.md) | No auth, no call protocol |
| Stealth decoy | [ADR-010](decisions/010-alpn-router-and-endpoint.md) | 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](decisions/048-websocket-native-session-not-gateway.md), 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](websocket.md) |
| Browsers are not alk peers | [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md) §4 | Bearer token, no `PeerId`, connection-local overlay (addressability vs. bidirectionality) — full rationale in [websocket.md](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](decisions/046-assembly-layer-custom-http-routes.md) | `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](decisions/045-to-openapi-gateway-spec-versioning.md):
`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](decisions/036-http-to-call-operation-mapping.md) — the
HTTP-to-call mapping this server implements
- [ADR-048](decisions/048-websocket-native-session-not-gateway.md)
— 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](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](overview.md) — crate overview, adapter location map
- [http-adapters.md](http-adapters.md) — `from_openapi`/`to_openapi`
- the alkcall crate's `docs/architecture/call-protocol.md`
`EventEnvelope` 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.md`
`OperationRegistry::invoke()` / `invoke_streaming()` / `invoke_sink()`,
the dispatch path HTTP requests hit