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.
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_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