docs(http): draft alknet-http architecture specs and ADRs 036-039

First speccing pass for alknet-http (HTTP interface crate: h2/http1.1/h3
server + from_openapi/to_openapi/from_mcp/to_mcp adapters).

Specs (crates/http/):
- README.md, overview.md — crate index, two-roles-in-one-crate framing,
  adapter location map, feature gates (h3, mcp), no-env-vars invariant
- http-server.md — HttpAdapter for h2/http1.1, axum over QUIC stream,
  Bearer auth, SSE projection for subscriptions, /healthz, stealth decoy
- http-adapters.md — from_openapi (reqwest) and to_openapi (projection),
  error fidelity (HTTP_<status> per ADR-023), type definitions
- http-mcp.md — from_mcp/to_mcp (feature-gated), streamable-HTTP-only
- webtransport.md — h3/WebTransport handler, browser streaming path,
  HTTP/3 request vs WebTransport session distinguished at framing layer

ADRs:
- ADR-036 HTTP-to-Call Operation Mapping (Proposed) — direct path
  mapping; to_openapi is projection, not router (the load-bearing one-way
  door from Phase 0 DH-3)
- ADR-037 MCP Stdio Transport Exclusion (Proposed) — streamable HTTP
  only; stdio is not built (RCE-vector security position)
- ADR-038 HTTP/3 and WebTransport as First-Class HTTP Transports
  (Proposed) — corrects the Phase 0 DH-2 deferral framing; h3 is in
  scope, not deferred, per ADR-009 §'What this framework is NOT'
- ADR-039 HTTP Server and Client Host Colocated in alknet-http
  (Proposed) — one crate for server + client host (shared HTTP deps,
  shared operation-spec->HTTP mapping)
- ADR-003 Amendment 1 — clarifies alknet-call is a protocol-foundation
  crate (the alknet-http -> alknet-call dependency edge)

Open questions (OQ-38, OQ-39, OQ-40 added under 'Theme: alknet-http'):
- OQ-38 WebTransport relay-as-proxy scope (genuine scope question, not
  a deferral — the decision is made when the use case becomes concrete)
- OQ-39 to_openapi published-spec versioning (one-way after first
  publication)
- OQ-40 reqwest client config and connection pooling (two-way-door)

Architecture README and overview updated with doc table, ADR table
(036-039), current-state note, and crate graph (alknet-http ->
alknet-call edge).

Reviewed by architecture-reviewer subagent: 3 critical, 4 warning, 5
suggestion issues found and fixed (missing ADR-039, WebTransport stream
routing conflation, undefined types, stale OQ-37 deferral language,
README OQ table completeness, Bearer-only attribution, cross-references,
ADR-038 ALPN quote, feature-gate placeholder, MCP temporal language).
This commit is contained in:
2026-06-29 05:53:38 +00:00
parent dd5ccf4983
commit ab47dac4ad
14 changed files with 2343 additions and 12 deletions

View File

@@ -72,4 +72,31 @@ alknet-napi is a thin projection layer — it exposes the Rust call protocol cli
- ADR-001: ALPN-based protocol dispatch
- ADR-002: ProtocolHandler trait
- ADR-004: Auth as shared core (IdentityProvider)
- ADR-005: irpc as call protocol foundation
- ADR-005: irpc as call protocol foundation
## Amendments
### Amendment 1 (2026-06-29): `alknet-call` is a protocol-foundation crate
The Decision table lists `alknet-call` as a handler crate that "depends
on alknet-core, irpc." The dependency-flow diagram and the "No handler
crate depends on another handler crate" rule were written before
`alknet-http` (which implements `from_openapi`/`from_mcp`/`to_openapi`/
`to_mcp` and therefore needs `alknet-call`'s `OperationSpec`, `Handler`,
`HandlerRegistration`, and `OperationAdapter` trait) was specced.
**Clarification:** `alknet-call` is both a handler crate (it implements
`ProtocolHandler` on ALPN `alknet/call`) *and* the protocol-foundation
crate that `alknet-agent`, `alknet-napi`, and `alknet-http` consume for
the operation registry, adapter contract, and call client. The "no
handler crate depends on another handler crate" rule applies to peer
handler crates (e.g., `alknet-http` does not depend on `alknet-ssh`);
`alknet-call` is a protocol-foundation crate in the same spirit that
`alknet-core` is, just at a different layer (operations/RPC vs.
transport/auth/config).
`alknet-http` depending on `alknet-call` is "HTTP uses the call protocol
types," not "HTTP depends on SSH." This is within the spirit of this
ADR's decomposition. The `alknet-call``alknet-http` edge is recorded
in the `alknet-http` spec (`crates/http/overview.md`) and in the adapter
location map (`crates/call/client-and-adapters.md`).

View File

@@ -0,0 +1,197 @@
# ADR-036: HTTP-to-Call Operation Mapping
## Status
Proposed
## Context
`alknet-http` implements `ProtocolHandler` for the standard HTTP ALPNs (`h2`,
`http/1.1`, `h3`). An inbound HTTP request that targets an alknet operation
must become a call-protocol `call.requested` dispatch — the HTTP handler is a
*projection* of the call protocol, not a parallel routing layer. The
question is how an HTTP request maps to an operation invocation.
Three options were considered in the alknet-http Phase 0 research
(`docs/research/alknet-http/phase-0-findings.md`, decision point DH-3):
- **(a) Direct path mapping.** `POST /{service}/{op}``call.requested` for
`/{service}/{op}`. The HTTP handler parses the request body as the
operation input, sends `call.requested`, and returns the response as JSON.
The HTTP surface is a thin projection of the call protocol's
`/{service}/{op}` operation path format (resolved by OQ-13).
- **(b) OpenAPI-defined routes.** The HTTP surface is defined by the
`to_openapi` projection — routes, methods, schemas are generated from the
registry's `External` operations, and the HTTP handler dispatches based on
the generated OpenAPI spec's path mapping.
- **(c) Explicit route registration.** The assembly layer registers HTTP
routes explicitly, mapping URL paths to operations. Most flexible, most
boilerplate.
This is a load-bearing architectural choice. Once the HTTP surface's routing
contract is published and external clients build against it, changing the
mapping (e.g., from "the HTTP path IS the operation path" to "the HTTP path
is a generated alias") is a one-way door: every client breaks. It needs an
ADR before implementation.
The call protocol's operation path format is `/{service}/{op}` (OQ-13,
resolved). The HTTP handler serves these operations over HTTP. The mapping
must be a *projection* of that single operation surface, not a second
routing table that has to be kept in sync with the registry.
## Decision
**Direct path mapping is the default HTTP surface; `to_openapi` is the
discovery/projection layer, not a parallel router.**
The `HttpAdapter` receives an HTTP request whose path is `/{service}/{op}`
(e.g., `POST /fs/readFile`, `POST /agent/chat`), constructs a
`call.requested` dispatch with `operationId: /{service}/{op}` and `input:
<parsed body>`, and returns the operation's response as JSON. The HTTP path
IS the operation path — one routing surface, the call protocol's.
`to_openapi` generates the OpenAPI spec that *describes* this surface for
external consumers (route paths, methods, request/response schemas, error
schemas per ADR-023). It does not define separate routes — the generated
spec's `paths` mirror the `/{service}/{op}` operation paths. An external
client reading the OpenAPI doc learns the same routes the HTTP handler
serves; there is no second mapping.
### HTTP method semantics
The call protocol's `OperationType` (`Query`, `Mutation`, `Subscription`,
per operation-registry.md) maps to HTTP methods on the default surface:
| `OperationType` | Default HTTP method | Notes |
|-----------------|----------------------|-------|
| `Query` | `GET` | Read-only, idempotent. Input from query parameters + optional body. |
| `Mutation` | `POST` (or `PUT`/`PATCH`/`DELETE` if the operation declares it) | Default `POST`; the op may declare a specific mutation method in its spec metadata. |
| `Subscription` | `GET` with `Accept: text/event-stream` | Streaming — the HTTP handler projects the subscription's `call.responded` stream as SSE chunks. |
The default method for an `External` operation with no explicit HTTP method
declared is `POST` for `Mutation`, `GET` for `Query`. This is the
least-surprise default; an operation that wants a specific HTTP verb
declares it. The method-to-`OperationType` mapping is a two-way-door
default (changing it later is additive — a new method is added, existing
methods keep working).
### Streaming projection (SSE)
A `Subscription` operation served over HTTP/1.1 or HTTP/2 projects its
`call.responded` stream as Server-Sent Events. Each `call.responded` event
becomes an SSE `data:` frame; `call.completed` closes the SSE stream;
`call.aborted` closes the stream with an SSE error event. This is the
HTTP/1.1 + HTTP/2 streaming projection. Over WebTransport (`h3`), the
subscription projects directly onto a WebTransport bidirectional stream —
no SSE framing is needed (see ADR-038 for the WebTransport path).
### Auth
Inbound HTTP auth is `Authorization: Bearer <token>`, resolved via
`IdentityProvider::resolve_from_token()` (auth.md's handler table —
`HttpAdapter`, Bearer header, `resolve_from_token`). This is settled by
ADR-004 and OQ-11; this ADR does not change it. Bearer-only is the auth
mechanism; other HTTP auth schemes (Basic, API key in query param) are not
implemented. An unauthenticated request to an operation with
`AccessControl` restrictions returns `401`/`403` (mapped from the call
protocol's `FORBIDDEN` protocol code).
### Stealth mode
The HTTP handler on `h2`/`http/1.1` serves a decoy (configurable: fake
404, a static site, a redirect) for paths that are not registered
operations. This is the ALPN-based stealth mapping from endpoint.md —
clients that don't offer alknet ALPNs get the HTTP handler, and unknown
HTTP paths get the decoy. The decoy is a two-way-door config default (an
operator picks what to serve); the *existence* of the stealth path is fixed
by ADR-010.
### `/healthz` and operational endpoints
`GET /healthz` is a raw HTTP route outside the call protocol — no auth, no
operation registration. It exists for infrastructure (load balancers,
orchestrators). Other operational endpoints (metrics, dashboard) are
call-protocol operations if built (`/metrics/list`, `/dashboard/view`),
not raw HTTP routes. `healthz` is the one exception: it must be callable
without auth before identity is resolvable.
## Consequences
**Positive:**
- One routing surface. The HTTP handler does not maintain a second routing
table; it projects the call protocol's `/{service}/{op}` paths directly.
No sync drift between the operation registry and the HTTP routes.
- `to_openapi` is a pure projection (generate a spec that *describes* the
existing surface), not a routing authority. The generated spec is always
consistent with what the handler actually serves because they're the same
paths.
- External HTTP clients (curl, axios, browser `fetch`) can call alknet
operations without knowing about the call protocol — the HTTP surface is
a standard REST-like API.
- The abort cascade (ADR-016) is preserved: an HTTP client disconnecting
mid-subscription is detected as a stream close, and the HTTP handler
sends `call.aborted` for the in-flight subscription, which cascades to
descendants.
- The HTTP method mapping (`Query``GET`, `Mutation``POST`,
`Subscription``SSE`) is the standard REST projection — no surprise
verbs, no exotic method semantics.
**Negative:**
- The HTTP surface inherits the call protocol's `/{service}/{op}` path
shape. An operation named `fs/readFile` is served at `POST /fs/readFile`,
not at a REST-nested `POST /fs/files/:id/read` or any other
REST-conventional path. Operations that want a REST-nested HTTP path
must declare it in spec metadata (a two-way-door extension); the
default is the operation path verbatim. This is a deliberate
least-surprise-for-alknet choice, not a REST-purist choice.
- HTTP request/response semantics don't map cleanly onto every call
protocol operation. A `Query` with a large input has to put the input in
the body (GET-with-body is non-standard). A `Mutation` that is
idempotent doesn't get `PUT` semantics unless it declares them. The
projection is lossy at the edges; operations that need precise HTTP
semantics declare them.
- `to_openapi` is a published compatibility contract (ADR-017 Consequences:
once external clients build against the generated spec, the mapping is
one-way). The generated spec's versioning (tied to the registry's
`External` operation set version) must be emitted as a spec marker so
consumers can detect mapping changes. This is OQ-17's published-artifact
concern, applied to the HTTP projection.
## Assumptions
1. **The operation path IS the HTTP path.** An operation `fs/readFile` is
served at `/fs/readFile`. There is no separate HTTP path mapping layer.
If a deployment wants different HTTP paths (e.g., a REST-nested
convention), that's a future projection layer, not a change to this
mapping.
2. **`External` operations are the HTTP surface.** `Internal` operations
(composition-only, ADR-015) are not served over HTTP — they return `404`
on the HTTP handler, matching the call protocol's `NOT_FOUND` for wire
calls to Internal ops. The HTTP handler dispatches only `External`
operations.
3. **HTTP auth is Bearer-only.** The HTTP handler resolves identity from
the `Authorization: Bearer` header via `resolve_from_token`. 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
middleware (two-way door), but the default surface is Bearer-only.
## References
- [ADR-004](004-auth-as-shared-core.md) — `IdentityProvider`, Bearer →
`resolve_from_token` (the auth model this ADR uses, unchanged)
- [ADR-010](010-alpn-router-and-endpoint.md) — stealth mode as ALPN
dispatch (the HTTP handler on standard ALPNs serves the decoy)
- [ADR-015](015-privilege-model-and-authority-context.md) — External/Internal
visibility (Internal ops are not served over HTTP)
- [ADR-016](016-abort-cascade-for-nested-calls.md) — abort cascade (HTTP
client disconnect → `call.aborted` → cascade to descendants)
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) —
`to_openapi` as a projection; published-spec compatibility contract
- [ADR-023](023-operation-error-schemas.md) — error schema fidelity in
`from_openapi`/`to_openapi`; HTTP status mapping
- OQ-13 (resolved) — operation path format `/{service}/{op}`
- `docs/research/alknet-http/phase-0-findings.md` DH-3 — the decision this
ADR resolves
- `crates/http/http-server.md` — the spec that implements this mapping

View File

@@ -0,0 +1,173 @@
# ADR-037: MCP Stdio Transport Exclusion
## Status
Proposed
## Context
The Model Context Protocol (MCP) defines multiple transports for
communicating between an MCP client and an MCP server. The MCP Rust SDK
(`rmcp` at `/workspace/rust-sdk/`) implements two:
1. **Streamable HTTP** (`transport-streamable-http-client-reqwest` for
clients, `transport-streamable-http-server` for servers). The client
connects to an HTTP endpoint; the server serves an HTTP endpoint.
Network-isolated, auth-gatable (Bearer token middleware, per the rmcp
`simple_auth_streamhttp.rs` example), and runs under whatever auth/
identity/capabilities machinery the host applies to HTTP.
2. **stdio** (`transport-child-process`). The client spawns the MCP server
as a child process and pipes JSON-RPC over its stdin/stdout. This is
the model the MCP spec promotes for "just download an MCP server and
run it locally."
The alknet-http crate implements `from_mcp` (import remote MCP tools as
call-protocol operations) and `to_mcp` (expose local operations as MCP
tools). Both are feature-gated behind an `mcp` feature (the rmcp
dependency is optional). The question this ADR resolves is which MCP
transports alknet-http supports.
### The stdio security problem
MCP stdio transport is `transport-child-process` — the rmcp client calls
`StdioClientTransport { command, args, env, cwd }`, which spawns an
arbitrary executable and pipes JSON-RPC over its stdin/stdout. An MCP
server is an arbitrary program that the MCP client executes with whatever
privileges the client process has.
The "download untrusted MCP servers and run them via stdio" model is
indistinguishable from `curl | sh` with extra steps:
- **Arbitrary code execution.** The MCP server is an executable. Running
it is RCE. There is no sandbox — the child process has the full
privileges of the client process (filesystem, network, environment
variables, ability to spawn further processes).
- **No auth boundary.** The MCP protocol messages flow over stdin/stdout;
there is no TLS, no auth token, no identity resolution. The server is
trusted by construction (you spawned it).
- **The "download untrusted MCP server" UX.** The MCP ecosystem's
promoted workflow is: find an MCP server on a registry, install it,
point your client at it. This is the npm-without-the-checksums model,
but the "package" is a process with full local privileges, not a
library that runs in-process.
alknet's security posture is the opposite of this. alknet-vault is
local-only by construction (ADR-025); the no-env-vars invariant
(ADR-014, client-and-adapters.md) exists specifically to avoid the
"download untrusted code that reads your secrets" pattern; capabilities
are injected by the assembly layer, not read from the environment a
spawned process can inspect. Building stdio support into alknet-http
would import the exact RCE vector the rest of the architecture is
designed to avoid.
## Decision
**alknet-http supports only streamable HTTP for MCP. Stdio is not built.**
The `mcp` feature gate pulls in rmcp with the streamable HTTP transport
features only:
```toml
[features]
mcp = [
"dep:rmcp",
# rmcp client transport (for from_mcp) — streamable HTTP only
# rmcp server transport (for to_mcp) — streamable HTTP only
]
```
The stdio transport (`transport-child-process`) is explicitly **not** a
dependency and **not** feature-gated. It is not built, not optional, not
"behind a separate feature." alknet-http's `from_mcp` uses rmcp's
`StreamableHttpClientTransport` (reqwest-based); `to_mcp` uses rmcp's
`StreamableHttpService` (axum-based, a tower service that nests into an
axum `Router` — see the rmcp `simple_auth_streamhttp.rs:134-159`
example). No stdio code path exists in the crate.
### If someone wants stdio MCP
They run it themselves, outside alknet. An operator who wants to use a
stdio-only MCP server can spawn it as a subprocess, run a small
streamable-HTTP-to-stdio bridge, and point `from_mcp` at the bridge's
HTTP endpoint. That bridge is the operator's responsibility — alknet
does not ship it, does not endorse it, and the bridge is where the RCE
risk lives, explicitly in the operator's hands, not hidden behind an
alknet feature flag.
This is the same posture as ADR-025 (remote vault access requires a
separate crate with its own ADR and threat model): the dangerous thing
is not built by default; if someone wants it, they build it themselves
and own the security model.
## Consequences
**Positive:**
- alknet-http does not import the MCP stdio RCE vector. There is no code
path in alknet-http that spawns an arbitrary executable.
- The streamable HTTP path is network-isolated, auth-gatable (Bearer
middleware), and runs under alknet's auth/identity/capabilities
machinery — the same machinery that gates every other HTTP request.
- `from_mcp` operations (imported MCP tools) are `Internal` by default
(ADR-015, ADR-022) — composition material, not directly callable from
the wire. The MCP server is reached over HTTP with a Bearer token from
`Capabilities` (the no-env-vars invariant), not by spawning a process
that could read the environment.
- `to_mcp` (expose local ops as MCP tools) serves an axum route with
Bearer auth middleware, matching the rmcp
`simple_auth_streamhttp.rs` pattern. An external MCP client (an
editor, an AI tool) discovers and calls alknet operations through
streamable HTTP, with alknet's auth/identity model applied at the
HTTP boundary.
**Negative:**
- MCP servers that only support stdio (a significant fraction of the
current MCP ecosystem) cannot be consumed by `from_mcp` directly. The
operator runs a bridge (above). This is a deliberate exclusion, not a
feature gap.
- The "just download an MCP server and run it" UX that the MCP
ecosystem promotes is not supported. An alknet user who wants that UX
has to build the bridge and own the RCE risk. This is the correct
tradeoff for alknet's threat model, but it means alknet-http is not a
drop-in client for the stdio MCP ecosystem.
## Assumptions
1. **Streamable HTTP is the supported MCP transport in alknet.** This
is a one-way door: removing stdio support later (if it were ever
added) would break deployments that depend on it; not adding it is
the stable position. The streamable HTTP transport is the MCP
spec's network-isolated path and is what the rmcp examples use for
auth-gated servers.
2. **The MCP ecosystem's stdio UX is not a target.** alknet is not trying
to be a drop-in client for "download untrusted MCP servers." If a
user wants that, the bridge approach puts the RCE risk explicitly
in their hands.
3. **rmcp's streamable HTTP features are the right subset.** The
`mcp` feature gate pulls in `transport-streamable-http-client-reqwest`
(for `from_mcp`) and `transport-streamable-http-server` (for
`to_mcp`). The exact rmcp feature names are a two-way-door
implementation detail (rmcp may rename features across versions); the
one-way constraint is "streamable HTTP only, no stdio."
## References
- [ADR-014](014-secret-material-flow-and-capability-injection.md) — the
no-env-vars invariant; spawned processes reading env vars is the
pattern this ADR's exclusion prevents
- [ADR-015](015-privilege-model-and-authority-context.md) —
adapter-registered ops (`from_mcp`) are `Internal` by default
- [ADR-022](022-handler-registration-provenance-and-composition-authority.md)
`from_mcp` provenance is a leaf
- [ADR-025](025-vault-local-only-dispatch.md) — the analogous "dangerous
thing is not built by default; a separate crate with its own ADR" pattern
- `docs/research/alknet-http/phase-0-findings.md` §4 (MCP stdio exclusion)
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp v1.8.0); streamable HTTP
transport
- `/workspace/rust-sdk/examples/servers/src/simple_auth_streamhttp.rs`
streamable HTTP MCP server with Bearer auth (the `to_mcp` pattern)
- `/workspace/rust-sdk/examples/clients/src/streamable_http.rs`
streamable HTTP MCP client (the `from_mcp` pattern)
- `crates/http/http-mcp.md` — the spec that implements `from_mcp`/`to_mcp`

View File

@@ -0,0 +1,233 @@
# ADR-038: HTTP/3 and WebTransport as First-Class HTTP Transports
## Status
Proposed
## Context
The alknet-http Phase 0 research findings
(`docs/research/alknet-http/phase-0-findings.md`, decision point DH-2)
framed HTTP/3 + WebTransport as "deferred past v1" — a two-way-door
addition to land "when the agent service needs browser streaming." That
framing was a residual of the "two-way door as deferral" anti-pattern
that ADR-009 §"What this framework is NOT" was later written to prevent.
The deferral framing is rejected here; this ADR records the decision as
made.
WebTransport is not a "later" feature. It is the browser-streaming
transport — QUIC streams are cheap (better than WebSocket/SSE by far for
multiplexed bidirectional streaming), and WebTransport is supported in
major browsers. The `alknet-http` crate's purpose is to be the HTTP
interface library for downstream crates that need to expose HTTP, and
browser streaming is a first-class requirement of that purpose, not a
fast-follow.
### The ALPN registry already reserves h3
The overview ALPN Registry maps `h3` to `HttpAdapter (HTTP/3 +
WebTransport)`. The `h3` ALPN is reserved; the implementation lands as
part of `alknet-http`, not as a future crate. This ADR confirms that
reservation and records the architectural decision: HTTP/3 + WebTransport
is in scope, in this crate, as a first-class transport alongside `h2` and
`http/1.1`.
### Why WebTransport matters
- **QUIC streams are cheap.** A browser opens a WebTransport connection
once and multiplexes many bidirectional streams over it. This is
fundamentally better than WebSocket (one stream, one connection) or
SSE (one-directional, one stream per subscription) for the
subscription-heavy, streaming-heavy patterns the call protocol
supports natively.
- **Browser support.** WebTransport is supported in modern Chromium-based
browsers (Chrome, Edge). The browser path for alknet is WebTransport,
not WebSocket.
- **The call protocol maps cleanly onto WebTransport streams.** A
`call.requested` over a WebTransport bidirectional stream is the same
EventEnvelope framing over a different QUIC stream source. The
`CallConnection`/`Dispatcher` dispatch loop is stream-agnostic
(ADR-012) — the `h3` handler hands a bidirectional stream to the call
protocol the same way the `h2`/`http/1.1` handler hands a hyper
connection to axum.
### The TLS constraint (browsers require X.509)
Browsers do not support RFC 7250 raw public keys (ADR-027, OQ-12). A
WebTransport session from a browser requires an X.509 cert — meaning the
`h3` handler is a domain-hosted-service concern, not a P2P concern. A
node serving WebTransport must have an X.509 identity (`TlsIdentity::X509`
or `TlsIdentity::Acme`). This is a property of the browser, not a
decision this ADR makes — it's recorded so the spec doesn't pretend a
raw-key node can serve browsers.
### WebTransport relay-as-proxy
A distinct WebTransport feature — a proxy that terminates the browser's
WebTransport connection and forwards encrypted traffic to a P2P hub's
Ed25519 endpoint (so the hub need not expose its own public X.509 cert)
— was recorded in ADR-034 §5. That feature does not change the auth model
(bearer token + `PeerEntry.auth_token_hash`; the proxy is transport-only)
and was explicitly placed in the same bucket as the rest of h3/
WebTransport. With this ADR, h3/WebTransport is in scope; the
relay-as-proxy is a genuine scope question (does the proxy belong in
alknet-http or in a separate relay crate?), tracked as OQ-38 — not
deferred, just scoped to a separate decision when the proxy use case
becomes concrete.
## Decision
**HTTP/3 + WebTransport is a first-class HTTP transport in `alknet-http`,
implemented alongside `h2` and `http/1.1`. It is not deferred.**
The `HttpAdapter` implements `ProtocolHandler` for three ALPNs:
| ALPN | Transport | Use case | Browser? |
|------|-----------|----------|----------|
| `http/1.1` | HTTP/1.1 over QUIC stream | Legacy clients, curl | No (browsers use h2/h3) |
| `h2` | HTTP/2 over QUIC stream | Modern HTTP clients, curl | No (browsers use h3) |
| `h3` | HTTP/3 / WebTransport | Browser streaming | Yes (X.509 required) |
All three are served by `alknet-http`. The `h3` ALPN handler upgrades to
WebTransport sessions and serves both HTTP/3 requests (the standard
HTTP/3 over QUIC framing) and WebTransport streams (the bidirectional/
unidirectional stream API). The handler dispatches HTTP/3 requests
through the same axum `Router` as `h2`/`http/1.1`; WebTransport streams
that target the call protocol are handed to the call protocol's dispatch
loop directly (a WebTransport stream is a QUIC bidirectional stream, the
same stream type the call protocol already speaks).
### WebTransport as the browser streaming path
The `h3` handler drives two distinct stream types, distinguished at the
HTTP/3 framing layer (not by peeking application bytes):
1. **HTTP/3 request streams** — standard HTTP/3 GET/POST carrying
`:method`/`:path`. Dispatched through the axum `Router`, same as
`h2`/`http/1.1` (ADR-036). These are not WebTransport streams.
2. **WebTransport sessions** — opened by a browser's
`new WebTransport(url)` call (an HTTP/3 extended CONNECT request).
The handler accepts the session (the `wtransport` crate's
`Endpoint::server` + `accept` + `accept_bi` pattern, or the quinn
HTTP/3 endpoint's WebTransport extension). Within an established
session, the browser creates bidirectional streams via
`transport.createBidirectionalStream()`; the handler accepts each
and dispatches by sub-protocol. For a call-protocol session, the
first frame is an `EventEnvelope` and the handler hands the stream
to the call protocol's `Dispatcher` — no SSE translation, no HTTP
framing. The browser's `WebTransport` JS API speaks to this handler
directly.
The stream-type distinction (HTTP/3 request vs. WebTransport session)
is made at the HTTP/3 frame layer (regular request headers vs. extended
CONNECT), not by reading the first application byte. The first-frame
routing applies *within* a WebTransport session (determining the
sub-protocol), not between an HTTP/3 request and a WebTransport stream.
This means the browser's subscription/streaming path uses WebTransport
streams directly, not the SSE projection (ADR-036) that HTTP/1.1 + HTTP/2
clients use. The same `Subscription` operation is served as SSE over
`h2` and as a native WebTransport stream over `h3` — the projection is
transport-dependent, the operation is the same.
### h3 and the stealth mapping
The `h3` handler participates in the same stealth model as `h2`/
`http/1.1` (ADR-010, ADR-036): a client that offers `h3` gets the HTTP
handler. Unknown WebTransport paths and unknown HTTP/3 paths get the
decoy (configurable). Real services use `alknet/ssh`, `alknet/call`, etc.
### Implementation reference: wtransport
The `wtransport` crate (`/workspace/wtransport/`, v0.7.1) is a pure-Rust
WebTransport implementation built on `quinn` + `h3`/`qpack`. It provides
the `Endpoint::server` + `accept` + `accept_bi` API that the `h3`
handler uses. `wtransport` is a candidate dependency for the `h3`
feature gate; the exact WebTransport library choice (wtransport vs a
quinn-native HTTP/3 + WebTransport extension) is a two-way-door
implementation detail. The one-way constraint is: `h3` is served, by this
crate, as a first-class transport.
### Feature gating
The `h3`/WebTransport support is behind an `h3` feature gate (the
WebTransport/HTTP/3 dependencies are heavier than `h2`/`http/1.1`):
```toml
[features]
default = ["h2", "http1"]
h3 = ["dep:wtransport"] # or the quinn h3 extension
mcp = ["dep:rmcp"] # MCP feature gate (ADR-037)
```
A deployment that only needs `h2`/`http/1.1` (a non-browser-facing
node) does not compile the WebTransport dependencies. A browser-facing
node enables `h3`.
## Consequences
**Positive:**
- Browser streaming uses QUIC streams directly, not SSE-over-HTTP/2.
The call protocol's subscription model maps onto WebTransport streams
with no translation loss — a `call.responded` stream over a
WebTransport bidirectional stream is the native representation.
- The `h3` ALPN reservation in the overview is honored — the
implementation lands in this crate, not a future one.
- A browser-facing node (a hub with an X.509 cert) serves the same
operations over `h3` as a non-browser-facing node serves over `h2`
the operation registry is transport-agnostic, the projection is
transport-dependent.
- The WebTransport relay-as-proxy (ADR-034 §5) has a clear home: it's a
feature that lives in or near `alknet-http`'s `h3` handler, scoped by
OQ-38.
**Negative:**
- `alknet-http` gains the `wtransport` (or equivalent HTTP/3 +
WebTransport) dependency behind the `h3` feature. This is a heavier
dependency than `h2`/`http/1.1`. The feature gate keeps it out of
non-browser-facing builds.
- WebTransport is still a draft standard (the `wtransport` README notes
it). The API may change. This is inherent to being an early adopter
of WebTransport; the `h3` feature gate isolates the risk.
- Browsers require X.509 (ADR-027). A raw-key-only node cannot serve
WebTransport. This is a browser limitation, not an alknet decision,
but it means the `h3` feature is useful only on domain-hosted nodes
with X.509 certs.
## Assumptions
1. **WebTransport is the browser streaming transport for alknet.** The
browser path is WebTransport, not WebSocket and not SSE-over-HTTP/2.
SSE remains the streaming projection for non-WebTransport HTTP
clients (curl, axios over h2); WebTransport is the native path for
browsers.
2. **The `wtransport` crate (or an equivalent quinn-native HTTP/3 +
WebTransport implementation) is the dependency for the `h3` feature.**
The exact library is a two-way-door implementation detail; the
one-way constraint is that `h3` is served by this crate.
3. **The WebTransport relay-as-proxy (ADR-034 §5) is a separate scope
decision (OQ-38).** It is not deferred — it's a feature with a clear
home (the `h3` handler or a sibling relay crate) that gets designed
when the browser-to-P2P-peer proxy use case becomes concrete.
## References
- [ADR-009](009-one-way-door-decision-framework.md) §"What this framework
is NOT" — the anti-pattern this ADR corrects (two-way door as deferral)
- [ADR-010](010-alpn-router-and-endpoint.md) — the ALPN router; `h3` is
one of the ALPNs the `HttpAdapter` registers for
- [ADR-012](012-call-protocol-stream-model.md) — stream-agnostic
correlation; a WebTransport stream is a QUIC bidirectional stream
- [ADR-027](027-tls-identity-redesign-acme-rawkey-decoupling.md) — the
browser limitation (no RFC 7250); WebTransport requires X.509
- [ADR-034](034-outgoing-only-x509-and-three-peer-roles.md) §4 (browsers
are not alknet peers) and §5 (WebTransport relay-as-proxy, recorded
for this bucket)
- [ADR-036](036-http-to-call-operation-mapping.md) — the SSE projection
for `h2`/`http/1.1` that WebTransport replaces for the browser path
- `docs/research/alknet-http/phase-0-findings.md` DH-2 — the deferral
framing this ADR rejects
- `/workspace/wtransport/` — pure-Rust WebTransport (the `h3` feature's
reference implementation)
- `crates/http/webtransport.md` — the spec that implements the `h3`
handler

View File

@@ -0,0 +1,154 @@
# ADR-039: HTTP Server and Client Host Colocated in alknet-http
## Status
Proposed
## Context
`alknet-http` has two roles: an HTTP server (the `HttpAdapter`
`ProtocolHandler` for `h2`/`http/1.1`/`h3`, built on `axum`/`hyper`)
and an HTTP client host (the `from_openapi`/`from_mcp` forwarding
handlers, built on `reqwest`). The question is whether these two
directions live in one crate (`alknet-http`) or are split into two
crates (`alknet-http-server` + `alknet-http-client`).
ADR-003 lists `alknet-http` as a single crate with dependency
`alknet-core, axum` and justifies the per-handler-crate decomposition
with "each handler is self-contained — it receives a byte stream and
manages its own protocol." That rationale covers the server side (the
`HttpAdapter` is self-contained), but it does not address the
within-crate dual-role question: should the inbound HTTP server and
the outbound HTTP client (the adapter forwarding handlers) be
colocated, or split?
This is a load-bearing choice. Once published, downstream consumers
build import paths against the crate boundary; the shared `reqwest::Client`
and the no-env-vars invariant boundary (ADR-014) are scoped by it; the
`to_openapi`/`to_mcp` projections are pure-registry-consumers that
*describe* the server surface but live where the adapter types do.
Splitting later would be a rewrite of every consumer's import paths,
not a cheap revert. It needs an ADR.
## Decision
**One crate — `alknet-http` houses both the HTTP server and the HTTP
client host (the adapter forwarding handlers and the `to_*` projections).**
The two directions share the HTTP dependencies and HTTP-specific
concerns that make splitting them counterproductive:
- **Shared HTTP dependencies.** Both `axum` (server) and `reqwest`
(client) pull in `hyper`, `http`, `http-body`, `rustls`/TLS stack
types, and the HTTP header/status code types. A split into two crates
would either duplicate these dependencies across both crates or
force a third shared-types crate, neither of which is an improvement.
- **Shared HTTP-specific concerns.** Both directions care about HTTP
headers, status codes, content types, SSE framing, streaming vs
non-streaming bodies, and TLS trust stores. The `from_openapi`
forwarding handler's error mapping (HTTP status → `HTTP_<status>`
error codes, ADR-023) and the `to_openapi` projection's error mapping
(`ErrorDefinition.http_status` → HTTP response status) are *the same
mapping* read in two directions — splitting them would put the two
halves in different crates.
- **The `to_*` projections describe the server surface.** `to_openapi`
generates an OpenAPI doc whose paths mirror the `/{service}/{op}`
HTTP routes the `HttpAdapter` serves (ADR-036). `to_mcp` exposes the
same operations as MCP tools. These projections consume the
`OperationRegistry` and produce specs; they live with the adapter
types (in `alknet-http`, per the adapter location map — see
[client-and-adapters.md](../crates/call/client-and-adapters.md))
because they share the operation-spec→HTTP mapping logic with the
server's request dispatch.
- **The no-env-vars invariant boundary is crate-scoped.** The
`from_openapi`/`from_mcp` forwarding handlers are the credential
injection point (ADR-014). The invariant — "no handler reads outbound
credentials from any source other than `OperationContext.capabilities`"
— is verified against the handler implementations in this crate. A
split would put the invariant verification boundary across two crates.
### What this does NOT change
- ADR-003's rule "no handler crate depends on another handler crate"
applies to peer handler crates (`alknet-http` does not depend on
`alknet-ssh`). The `alknet-http``alknet-call` edge is the
protocol-foundation exception (ADR-003 Amendment 1). This ADR is
about the *internal* structure of `alknet-http`, not its dependency
edges.
- The adapter location map (the `OperationAdapter` trait in
`alknet-call`; the HTTP-backed adapter implementations in
`alknet-http`) is unchanged. This ADR records *why* the HTTP-backed
adapters live in the same crate as the HTTP server, not whether they
live in `alknet-http` vs `alknet-call`.
## Consequences
**Positive:**
- One crate, one set of HTTP dependencies, one HTTP-specific concern
surface. No duplicated `hyper`/`http` types across two crates, no
shared-types crate needed.
- The `to_*` projections live with the server whose surface they
describe, and with the adapter types they consume. The operation-spec
→ HTTP mapping logic is in one place.
- The no-env-vars invariant verification boundary is one crate. The
`from_openapi`/`from_mcp` handlers and the credential injection
logic they share are co-located.
- A downstream consumer wires one crate (`alknet-http`) into the
`HandlerRegistry` and gets the full HTTP surface — server + adapters +
projections. No two-crate wiring.
**Negative:**
- A deployment that only needs the HTTP server (no `from_openapi`/`from_
mcp` forwarding) still compiles the `reqwest` dependency. Mitigated:
the `mcp` feature is already gated (ADR-037); the `from_openapi`
forwarding is always available but the `reqwest` client is only
constructed if a `from_openapi`/`from_mcp` adapter is registered at
assembly time. The dependency is compiled, the client is lazy.
- A deployment that only needs the HTTP client (e.g., an agent crate
that only uses `from_openapi` forwarding, no inbound HTTP) still
compiles `axum`/`hyper`. This is the rarer case — the agent crate
(`alknet-agent`) consumes `alknet-call` directly for tool dispatch
and uses `from_openapi` via `alknet-http`'s adapter, but doesn't
serve inbound HTTP itself. In practice, the agent deployment wires
`alknet-http` for the adapters and the CLI wires it for the server;
the compile cost is paid once per workspace, not once per deployment.
- The crate is larger than a single-direction crate would be. This is
the cost of colocating shared concerns; the alternative (two crates
+ a shared types crate) is more crates, not less code.
## Assumptions
1. **The shared-HTTP-dependencies argument holds.** `axum` and
`reqwest` both pull in `hyper` and the `http` crate's types; the
shared types (headers, status codes, method, URI) are the same. If
a future version of `axum` or `reqwest` diverges its HTTP types
(e.g., `axum` moves to a different HTTP implementation), this
argument weakens. As of `axum` 0.7+ and `reqwest` 0.12+, both are
built on `hyper` 1.x and share `http` types.
2. **The `to_*` projections share enough mapping logic with the server
to justify colocation.** The operation-spec → HTTP path/method/
error-status mapping is the same in both directions. If the
projections turn out to be pure registry-consumers with no
HTTP-mapping logic (just spec serialization), the colocation
argument is weaker — but the current design (ADR-036, ADR-023)
has them sharing the mapping.
## References
- [ADR-003](003-crate-decomposition.md) — crate decomposition (this
ADR addresses the within-`alknet-http` dual-role question, not the
dependency edge; Amendment 1 covers the `alknet-call` edge)
- [ADR-014](014-secret-material-flow-and-capability-injection.md) —
the no-env-vars invariant whose verification boundary is crate-scoped
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) — the
adapter contract; `to_*` are projections
- [ADR-023](023-operation-error-schemas.md) — the error mapping shared
between `from_openapi` (status → code) and `to_openapi` (code →
status)
- [ADR-036](036-http-to-call-operation-mapping.md) — the HTTP path =
operation path mapping shared between server dispatch and `to_openapi`
- [ADR-037](037-mcp-stdio-transport-exclusion.md) — the `mcp` feature
gate
- `crates/http/overview.md` — the crate overview (the inline rationale
for this decision is replaced by a pointer to this ADR)