Files
alknet/docs/architecture/decisions/043-webtransport-bidirectional-alpn-substrate.md
glm-5.2 0a78306686 docs(http): add ADR-043 WebTransport bidirectional ALPN substrate; fix spec drift from mid-spec pivot
A consistency review of the alknet-http specs found two classes of
issues: internal contradictions from the mid-spec pivot (the to_openapi
gateway pattern landed in prose but not in cross-references), and a
systematic client→server assumption that only holds for the OpenAPI/MCP
case leaking into the WebTransport architecture.

Class 1 (internal contradictions):
- C1: to_openapi was half-refactored — body described the ADR-042
  gateway pattern but the decisions table and ADR-036 still said
  'paths mirror /{service}/{op}'. ADR-036's to_openapi clause is now
  amended as superseded by ADR-042; the stale decisions row and README
  Principle 2 are fixed.
- C2: the axum Router route list didn't include the 5 gateway endpoints
  (/search, /schema, /call, /batch, /subscribe). Added them; clarified
  /openapi.json as the gateway description doc; added gateway paths to
  the decoy exclusion list.
- C3: ADR-034 §5 still talked about the 'h3/WebTransport deferral
  bucket' that ADR-038 eliminated. Amended §5/Consequences/References
  to drop the deferral framing (the auth-model decision stands; only
  the 'when' wording was stale).

Class 2 (one-way direction assumption):
- C4/C5/C6: the WebTransport specs framed the session as browser→hub
  one-way, when the call protocol is bidirectional and WebTransport is
  a general ALPN transport substrate. New ADR-043 reframes WebTransport
  as a bidirectional ALPN transport substrate (call protocol is the
  first/canonical target; needs no WASM parser), names the call
  protocol's bidirectionality over WebTransport sessions, and states
  the inbound no-PeerId connection-local overlay as the mirror of
  ADR-034 §2. webtransport.md is updated to reflect this framing;
  ADR-040 is repositioned (not superseded) as the substrate's non-call-
  ALPN mechanism.
- C7: the HTTP/1.1+HTTP/2 surface's one-directionality is now named as
  a lossy consequence of HTTP request/response; WebTransport is named
  as the surface that restores the bidirectional call model.
- C8: overview.md acknowledges the from/to direction model is
  OpenAPI/MCP-specific, not a call-protocol property.

A review subagent pass on ADR-043 + webtransport.md found no critical
issues; warnings W1-W3 (residual browser-as-subject framing, ADR-009
rationale in spec, opening abstract tone) and suggestions S2/S4/S5
were addressed.
2026-06-29 10:43:18 +00:00

18 KiB

ADR-043: WebTransport as a Bidirectional ALPN Transport Substrate

Status

Proposed

Context

alknet-http's h3/WebTransport specs (webtransport.md, ADR-040) describe the WebTransport session as a browser-reached path: a browser opens a WebTransport session to a hub, the hub's h3 handler serves it. The two stream destinations described (call-protocol EventEnvelope, and the ALPN-handler proxy) are both framed browser→server: the browser initiates, the hub responds.

That framing is correct for the browser case (ADR-034 §4 — browsers are not alknet peers; they connect to a hub and authenticate by bearer token), but it is not the general case, and writing the spec as if it were leaks an assumption that is only true for the OpenAPI/MCP direction model into the WebTransport architecture. Three concrete problems result:

Problem 1 — the call protocol is bidirectional; the WebTransport spec is not

The call protocol is explicitly bidirectional (call-protocol.md §"Bidirectional Calls"): "Both sides of the connection can initiate calls. The server can call operations on the client just as the client calls operations on the server." The CallConnection/Dispatcher dispatch loop is stream-agnostic (ADR-012) — a WebTransport bidirectional stream is a QUIC bidirectional stream, and the call protocol's bidirectionality applies unchanged over it.

The current webtransport.md describes only the browser-initiates-a- call direction. A reader would reasonably conclude WebTransport is a one-directional session (browser calls hub, hub responds), when in fact a WebTransport call-protocol session inherits the call protocol's bidirectionality: the hub can call operations registered on the browser/WebTransport-client side, exactly as it can over alknet/call. The spec doesn't say this, doesn't scope it down, and doesn't say why it's scoped down. It's just silent.

Problem 2 — the ALPN-stream-proxy is framed as "browser reaches hub ALPNs via WASM," not as "WebTransport carries ALPNs as streams"

ADR-040 frames the ALPN-stream-proxy as the browser's gateway to every ALPN handler: a browser with a WASM parser for SSH (or SFTP, git) can reach any ALPN handler via WebTransport. That framing is correct and important (the anti-censorship property — SSH-over-WebTransport is HTTPS-shaped — is real). But it bakes the browser-initiated direction into the architecture.

WebTransport is more general than that: a WebTransport stream is a QUIC bidirectional stream (ADR-012), and the BiStream trait (AsyncRead + AsyncWrite + Send + Unpin, ADR-007) is source-agnostic. WebTransport can carry any ALPN protocol as streams, in either direction, between any two endpoints that can terminate WebTransport — not only browser→hub. The call protocol is the first/canonical target because it is already JSON-RPC over QUIC streams and needs no WASM parser (the EventEnvelope framing is platform/language/runtime agnostic), but it is one target among possible many. SSH, git, SFTP are additional targets that require a WASM parser on the client side.

The current framing — "browser runs a WASM parser that reaches the hub's ALPN handler" — is a use case of the proxy, not the nature of it. The nature is: WebTransport is a transport substrate that carries ALPN protocols as bidirectional streams; the call protocol is the straightforward first target, and any other ALPN can be proxied the same way.

Problem 3 — "browsers are not peers" reconciles awkwardly with the WebTransport call session, and the reconciliation isn't stated

ADR-034 §4 establishes that a browser over WebTransport authenticates by bearer token, gets no PeerId, and doesn't enter PeerCompositeEnv (the peer-keyed overlay). ADR-034 §2 establishes the analogous outgoing case: a pure-client X.509 dial has no client-side PeerId, and ops discovered via from_call/from_openapi/from_mcp land in "that connection's Layer 2 overlay" — connection-local, not in the peer-keyed overlay.

The inbound WebTransport case is the mirror of ADR-034 §2: a browser (or any non-peer WebTransport client) connects to a hub, the hub's h3 handler hands its streams to the call protocol's Dispatcher, and the connection has no PeerId on the hub's side either. Ops the browser registers (if it registers any — e.g., a browser-based agent exposing local ops) land in a connection-local Layer 2 overlay, exactly like the outgoing pure-client X.509 case. compose_root_env builds the root OperationContext.env from the curated base + that connection's local overlay + (if active) the session overlay — without a peer-keyed entry, because there is no PeerId to key it.

The current webtransport.md doesn't say this. A reader would reasonably ask: if this is the same Dispatcher as alknet/call, where's the PeerId? how does compose_root_env build the root env for a no-PeerId WebTransport call session? The answer exists — it's the ADR-034 §2 connection-local-overlay pattern applied inbound — it's just not written down in the http crate.

Decision

1. WebTransport is a bidirectional ALPN transport substrate; the call protocol is the first target

The h3/WebTransport handler is reframed: WebTransport is a transport substrate that carries ALPN protocols as bidirectional streams, not a browser→hub one-way path. The call protocol is the first/canonical target — it is already JSON-RPC over QUIC streams (ADR-012), needs no WASM parser (the EventEnvelope framing is platform/language/runtime agnostic), and is supported in runtimes that speak WebTransport (Deno, Node, browsers, native Rust via wtransport). Other ALPN protocols (SSH, git, SFTP) are additional targets that require a WASM parser on the browser/client side; the ALPN-stream-proxy (ADR-040) is the mechanism for those targets. The call-protocol-over- WebTransport path needs no proxy — it speaks the EventEnvelope wire format directly.

This is a framing change to ADR-040 and webtransport.md, not a structural change. The three stream destinations (call protocol, ALPN-handler proxy, other sub-protocols) are unchanged; what changes is how they are described. The call-protocol destination is the substrate's canonical use; the ALPN-handler proxy is the substrate carrying other ALPNs. The browser→hub direction is one use case of the substrate, not its definition.

2. The WebTransport call-protocol session inherits the call protocol's bidirectionality

A WebTransport session opened to / or /alknet/call is a call-protocol session. Within it, both sides can initiate calls — the WebTransport client can call operations on the hub, and the hub can call operations registered on the WebTransport client's side. This is the call protocol's native bidirectionality (call-protocol.md § "Bidirectional Calls"), applying unchanged over the WebTransport stream. The Dispatcher is the same dispatch loop the CallAdapter uses for alknet/call connections (ADR-012 — stream-agnostic correlation).

The browser case (ADR-034 §4) is the common case: a browser connects to a hub, calls the hub's operations, and registers no operations of its own — the server→client call direction is unused because the browser has nothing to call. That is a use-case scoping, not an architectural limitation. A non-browser WebTransport client (a Deno process, a Node process, another alknet node that prefers WebTransport over raw alknet/call QUIC) that registers operations on its side receives calls from the hub over the same session. The spec must state this, not leave it implicit.

3. The no-PeerId connection-local overlay (inbound mirror of ADR-034 §2)

A WebTransport call-protocol session from a non-peer client (a browser, or any WebTransport client that is not a PeerEntry-bearing alknet peer) has no PeerId on the hub's side. The connection is served by the h3 handler; the browser/client authenticates by bearer token (ADR-034 §4); the resolved Identity authorizes calls via AccessControl::check, but the connection does not enter PeerCompositeEnv and has no peer-keyed overlay entry.

This is the inbound mirror of ADR-034 §2 (the outgoing pure-client X.509 case). Outbound: a CallClient dials a public X.509 endpoint, ops discovered land in "that connection's Layer 2 overlay" — connection-local, no PeerId. Inbound: a WebTransport client connects to a hub, ops the client registers (if any) land in a connection-local Layer 2 overlay on the hub side — same pattern, opposite direction. The CallAdapter's compose_root_env builds the root OperationContext.env from:

  • the curated base (Layer 0),
  • this connection's local overlay (Layer 2 — connection-scoped, not peer-keyed), and
  • the active session overlay (if any, ADR-024).

There is no PeerCompositeEnv entry because there is no PeerId to key it. This is the explicit closure of the "browser as peer" path (ADR-034 §4) on the inbound side — the same closure ADR-034 §2 makes on the outbound side. webtransport.md must state it so an implementer building compose_root_env for a WebTransport session knows the connection-local-overlay pattern applies and does not hunt for a PeerId that isn't there.

The case where the WebTransport client is a PeerEntry-bearing alknet peer (a hub or spoke node that prefers WebTransport as its transport) is the symmetric case: the connection has a PeerId (resolved from the bearer token via IdentityProvider::resolve_from_tokenIdentity.id = PeerEntry.peer_id, ADR-030), and ops the peer registers land in the peer-keyed overlay, exactly as they would over alknet/call. The no-PeerId pattern above is the non-peer case; the peer case is unchanged from the alknet/call model.

4. ADR-040's ALPN-stream-proxy is the substrate's mechanism for non-call ALPNs

ADR-040 (the ALPN-stream-proxy) is not superseded by this ADR; it is repositioned. The proxy is the substrate's mechanism for carrying ALPN protocols other than the call protocol — SSH, git, SFTP — that require a WASM parser on the client side. The call protocol needs no proxy (it speaks EventEnvelope directly); the ALPN-stream-proxy is for the protocols that do. The browser→hub direction is the primary use case (a browser with a WASM SSH client reaching the hub's SSH handler), but it is not the only one — any WebTransport-capable endpoint can proxy any ALPN via the same mechanism.

This reframing does not change ADR-040's decision (the h3 handler gains Arc<HandlerRegistry>, streams route by CONNECT path); it changes how the decision is described. The "three stream destinations" in webtransport.md remain; what changes is the framing of the ALPN-stream-proxy as the substrate's non-call-ALPN mechanism, not as the browser's gateway.

5. HTTP/1.1 + HTTP/2 is the one-directional projection; WebTransport is the bidirectional one

The HTTP/1.1 + HTTP/2 surface projects the call protocol one-directionally (client→server calls only — HTTP is request/response; the server→client call direction has no HTTP expression). This is named as a lossy consequence of HTTP in http-server.md § "One-directional projection." WebTransport is the HTTP-family transport that restores the call protocol's bidirectionality: a WebTransport session is a long-lived connection over which either side can open streams and send call.requested in either direction. The two surfaces coexist on the h3 ALPN (HTTP/3 requests use the axum Router — the one-directional projection; WebTransport sessions use the call protocol Dispatcher — the bidirectional one). An HTTP/3 request is never a WebTransport stream, and vice versa (the HTTP/3 frame type distinguishes them — see webtransport.md).

Consequences

Positive:

  • The WebTransport spec stops silently inheriting the OpenAPI/MCP direction assumption. The call protocol's bidirectionality is named as a property of WebTransport call sessions, not left implicit.
  • The ALPN-stream-proxy is framed as the substrate's non-call-ALPN mechanism, not as a browser-only gateway. The call protocol is named as the first/canonical target — the easy case that needs no WASM parser and runs in Deno, Node, and browsers.
  • The inbound no-PeerId connection-local overlay is stated, so an implementer building compose_root_env for a WebTransport session applies the ADR-034 §2 pattern (mirror direction) and does not hunt for a PeerId.
  • The HTTP/1.1 + HTTP/2 one-directional projection is named as a lossy consequence, and WebTransport is named as the surface that restores bidirectionality. The two surfaces' relationship is clear.
  • A non-browser WebTransport client (Deno, Node, a peer preferring WebTransport) is a first-class case, not an accident of the spec's browser framing.

Negative:

  • The WebTransport spec gains complexity: the browser-only framing was simpler to describe. The bidirectional framing requires stating both the browser case (no registered ops, server→client call direction unused) and the non-browser case (registered ops, bidirectional calls). This is honest complexity — the substrate is more general than the browser-only framing suggested.
  • The "browser is not a peer" property (ADR-034 §4) now has a counterpart statement for the inbound overlay path. Readers must understand two cases: peer WebTransport clients (in the peer-keyed overlay) and non-peer WebTransport clients (in the connection-local overlay). This mirrors the outbound ADR-034 §2/§3 split and is not new structural complexity, but it is now stated in the http crate, which it wasn't before.
  • The ALPN-stream-proxy's reframing (substrate mechanism for non-call ALPNs, not browser gateway) means ADR-040's prose reads slightly differently from the spec's prose. ADR-040 is not superseded; its decision (the HandlerRegistry reference, path-based routing) stands. Its framing is repositioned by this ADR. A future amendment to ADR-040 could inline the repositioning; for now this ADR records it and webtransport.md reflects it.

Assumptions

  1. The call protocol's bidirectionality applies unchanged over WebTransport. The Dispatcher is stream-agnostic (ADR-012); a WebTransport bidirectional stream is a QUIC bidirectional stream. No protocol change is needed to support server→client calls over WebTransport — the same call.requested/call.responded framing works in both directions, correlated by request ID, as it does over alknet/call.

  2. The browser case is the common non-peer case; non-browser WebTransport clients are the general case. Most WebTransport clients in v1 are browsers (the anti-censorship / universal-client use case). Non-browser WebTransport clients (Deno, Node, native Rust) are supported by the same code path; they may or may not be peers depending on whether they present a PeerEntry-resolvable bearer token. The spec describes both cases; the implementation is one code path with a branch on "does this connection have a PeerId?" at compose_root_env time.

  3. The ALPN-stream-proxy is not the only mechanism for non-call ALPNs over WebTransport. A future WebTransport session type could carry non-call ALPNs without the proxy's HandlerRegistry lookup (e.g., a session that negotiates a single ALPN at CONNECT time and speaks it directly, without per-stream registry routing). The proxy is the mechanism specified by ADR-040; this ADR does not foreclose others, but does not spec them either (scope — not needed for the current use cases).

  4. PeerId resolution for peer WebTransport clients follows the same path as alknet/call. A peer connecting over WebTransport presents a bearer token; the hub resolves it via IdentityProvider::resolve_from_token; the resulting Identity.id is the PeerId (ADR-030). There is no WebTransport-specific peer resolution path — the bearer-token path is the same regardless of transport. This is an assumption, not a new decision: it follows from ADR-004, ADR-030, and ADR-034 §4.

References

  • ADR-012 — stream-agnostic correlation (a WebTransport stream is a QUIC bidirectional stream; the Dispatcher is the same dispatch loop)
  • ADR-007BiStream trait (source-agnostic; the contract a WebTransport stream satisfies)
  • ADR-027 — browsers require X.509 (the h3 handler is domain-hosted)
  • ADR-034 §2 (outbound no-PeerId connection-local overlay — this ADR's §3 is the inbound mirror), §4 (browsers are not peers — the non-peer WebTransport case)
  • ADR-038h3 is first-class (this ADR refines the framing, not the scope)
  • ADR-040 — the ALPN-stream- proxy (this ADR repositions it as the substrate's non-call-ALPN mechanism; the decision stands)
  • ADR-029PeerCompositeEnv / PeerRef (the peer-keyed overlay that non-peer WebTransport clients do not enter)
  • ADR-030PeerId source (Identity.id from bearer-token resolution)
  • crates/http/webtransport.md — the spec this ADR refines
  • crates/http/http-server.md §"One-directional projection" — the HTTP/1.1 + HTTP/2 lossy projection this ADR contrasts WebTransport against
  • crates/call/call-protocol.md §"Bidirectional Calls" — the bidirectionality this ADR names as a WebTransport property