Working through the WebTransport implementation path surfaced a scope question distinct from the hedging-as-deferral anti-pattern ADR-038 was written to correct. Three findings drove the re-evaluation: 1. The browser bidirectional call-protocol path doesn't require WebTransport — WebSocket is full-duplex, EventEnvelope fits a WS binary message boundary cleanly, and the Dispatcher is stream- agnostic (ADR-012). What WebTransport gives over WebSocket (native multi-stream multiplexing, the ALPN-as-stream substrate) benefits the proxy use case, not the call protocol. 2. WebTransport is a draft standard (-07, not RFC) on an experimental Rust dependency stack (wtransport/h3 both self-describe as not production-ready). Either choice puts a draft protocol on the security surface of the first release. 3. The ALPN-stream-proxy (ADR-040) is speculative — its WASM parser consumers (browser SSH/SFTP/git clients) don't exist yet, and the downstream crates WebTransport deferral blocks (SSH, git, SFTP) expose their ALPNs natively over QUIC regardless. This is a scope decision (per ADR-009: a decision that 'genuinely doesn't need to be made yet because the use case isn't concrete'), not hedging. The reversal trigger is concrete: a real deployment needing the ALPN-stream-proxy. ADR-038 is superseded (its anti-pattern correction stands; its specific 'h3 in scope now' decision is reversed). ADR-040 and ADR-043 are parked, not superseded — their designs revive unchanged when WebTransport revives, with §2 (bidirectionality) and §3 (no-PeerId overlay) of ADR-043 transferring to WebSocket for v1. ADR-044 §5 also states the 'browser is not a peer' rationale that ADR-034 §4 closed without arguing: peer = addressable node in the call-protocol peer graph (stable PeerId, PeerRef::Specific-reachable, identity stable across reconnects), not 'any endpoint that exchanges calls during a live session.' A browser is the second but not the first (no stable crypto identity of its own, ephemeral, not addressable from other nodes). ADR-034 §4 and Assumption 2 are amended by reference. The wtransport-vs-hyperium dependency question is recorded (not resolved — WebTransport is deferred) in ADR-044 §'Research note' and webtransport.md so the revival doesn't re-derive it: wtransport probably isn't the right choice (axum-bridge friction — it owns its own HTTP serving path); the hyperium stack (h3 + h3-quinn + h3-webtransport) fits the axum integration better but its server-side WebTransport API needs verification before commitment. Reviewed by architecture-review subagent; all critical cross-reference issues (ADR-034 §5 stale 'in scope' assertion, ADR-036 Context listing h3 as implemented, webtransport.md Design Decisions table) resolved.
19 KiB
ADR-043: WebTransport as a Bidirectional ALPN Transport Substrate
Status
Proposed — implementation deferred per ADR-044.
This ADR's decision is correct and is not superseded. It revives unchanged when WebTransport revives, with two transfers to WebSocket that apply during the deferment:
- §2 (call-protocol bidirectionality) transfers to WebSocket unchanged. WebSocket is full-duplex; the call protocol's bidirectionality applies over a WS connection exactly as §2 describes for WebTransport. The browser case where the client registers no ops remains a use-case scoping, not an architectural limitation.
- §3 (the no-
PeerIdconnection-local overlay) transfers to WebSocket unchanged. A browser over WSS has noPeerIdon the hub's side for the same reasons it has none over WebTransport (ADR-044 §5); the connection-local Layer 2 overlay applies. The pattern is transport-agnostic.
What does not transfer to WebSocket is §4 (the non-call-ALPN substrate mechanism / the ALPN-stream-proxy, ADR-040) and §5's WebTransport-specific framing. Those require WebTransport's stream model and revive with it. ADR-044 §3 states the transfer explicitly; ADR-044 §5 states the "browser is not a peer" rationale (addressability vs. bidirectionality) that this ADR's §3 relies on but does not argue.
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_token
→ Identity.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-
PeerIdconnection-local overlay is stated, so an implementer buildingcompose_root_envfor a WebTransport session applies the ADR-034 §2 pattern (mirror direction) and does not hunt for aPeerId. - 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
HandlerRegistryreference, 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 andwebtransport.mdreflects it.
Assumptions
-
The call protocol's bidirectionality applies unchanged over WebTransport. The
Dispatcheris 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 samecall.requested/call.respondedframing works in both directions, correlated by request ID, as it does overalknet/call. -
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 aPeerId?" atcompose_root_envtime. -
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
HandlerRegistrylookup (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). -
PeerIdresolution for peer WebTransport clients follows the same path asalknet/call. A peer connecting over WebTransport presents a bearer token; the hub resolves it viaIdentityProvider::resolve_from_token; the resultingIdentity.idis thePeerId(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
Dispatcheris the same dispatch loop) - ADR-007 —
BiStreamtrait (source-agnostic; the contract a WebTransport stream satisfies) - ADR-027 —
browsers require X.509 (the
h3handler is domain-hosted) - ADR-034 §2 (outbound
no-
PeerIdconnection-local overlay — this ADR's §3 is the inbound mirror), §4 (browsers are not peers — the non-peer WebTransport case) - ADR-038 —
h3is 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-029 —
PeerCompositeEnv/PeerRef(the peer-keyed overlay that non-peer WebTransport clients do not enter) - ADR-030 —
PeerIdsource (Identity.idfrom bearer-token resolution) crates/http/webtransport.md— the spec this ADR refinescrates/http/http-server.md§"One-directional projection" — the HTTP/1.1 + HTTP/2 lossy projection this ADR contrasts WebTransport againstcrates/call/call-protocol.md§"Bidirectional Calls" — the bidirectionality this ADR names as a WebTransport property