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.
This commit is contained in:
2026-06-29 10:43:18 +00:00
parent 69ebe58bab
commit 0a78306686
10 changed files with 660 additions and 85 deletions

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-28
last_updated: 2026-06-29
---
# Alknet Architecture
@@ -18,7 +18,7 @@ The storage and auth strategy research (`docs/research/alknet-storage-strategy/f
The alknet-call crate is **implemented and reviewed** — both the server-side core and the client/adapter surface (207 lib + 2 integration tests passing). The alknet-core and alknet-call crate specs are in draft; the alknet-vault crate specs are stable.
**alknet-http specs drafted.** The alknet-http crate (HTTP interface — `h2`/`http/1.1`/`h3` server + `from_openapi`/`to_openapi`/`from_mcp`/`to_mcp` adapters) now has architecture specs: [crates/http/](crates/http/) (overview, http-server, http-adapters, http-mcp, webtransport) and seven new ADRs — [ADR-036](decisions/036-http-to-call-operation-mapping.md) (HTTP-to-call mapping), [ADR-037](decisions/037-mcp-stdio-transport-exclusion.md) (MCP stdio exclusion), [ADR-038](decisions/038-http3-and-webtransport-as-first-class.md) (HTTP/3 + WebTransport as first-class, correcting the Phase 0 deferral framing), [ADR-039](decisions/039-http-server-and-client-host-colocated.md) (HTTP server + client host colocated in one crate), [ADR-040](decisions/040-webtransport-alpn-stream-proxy.md) (WebTransport ALPN-stream-proxy — browser → WebTransport stream → any ALPN handler via WASM parser; the "VPN-like without being a VPN" use case), [ADR-041](decisions/041-mcp-tool-gateway-pattern.md) (`to_mcp` tool-gateway pattern — 4 fixed gateway tools instead of one tool per operation, addressing LLM context tool-bloat), [ADR-042](decisions/042-openapi-gateway-pattern.md) (`to_openapi` gateway pattern — 5 fixed gateway endpoints instead of one path per operation; per-caller AccessControl-filtered API surface). ADR-003 Amendment 1 clarifies that `alknet-call` is a protocol-foundation crate (the `alknet-http``alknet-call` dependency edge). The specs are in draft; implementation has not started. Three open questions carried: OQ-38 (WebTransport standalone relay service scope — distinct from the in-process ALPN-stream-proxy resolved by ADR-040), OQ-39 (`to_openapi` published-spec versioning), OQ-40 (reqwest client config).
**alknet-http specs drafted and consistency-reviewed.** The alknet-http crate (HTTP interface — `h2`/`http/1.1`/`h3` server + `from_openapi`/`to_openapi`/`from_mcp`/`to_mcp` adapters) now has architecture specs: [crates/http/](crates/http/) (overview, http-server, http-adapters, http-mcp, webtransport) and eight ADRs — [ADR-036](decisions/036-http-to-call-operation-mapping.md) (HTTP-to-call mapping; direct-call surface), [ADR-037](decisions/037-mcp-stdio-transport-exclusion.md) (MCP stdio exclusion), [ADR-038](decisions/038-http3-and-webtransport-as-first-class.md) (HTTP/3 + WebTransport as first-class, correcting the Phase 0 deferral framing), [ADR-039](decisions/039-http-server-and-client-host-colocated.md) (HTTP server + client host colocated in one crate), [ADR-040](decisions/040-webtransport-alpn-stream-proxy.md) (WebTransport ALPN-stream-proxy — the substrate's mechanism for non-call ALPNs like SSH/git/SFTP via WASM parser; the "VPN-like without being a VPN" use case), [ADR-041](decisions/041-mcp-tool-gateway-pattern.md) (`to_mcp` tool-gateway pattern — 4 fixed gateway tools instead of one tool per operation, addressing LLM context tool-bloat), [ADR-042](decisions/042-openapi-gateway-pattern.md) (`to_openapi` gateway pattern — 5 fixed gateway endpoints instead of one path per operation; per-caller AccessControl-filtered API surface; supersedes ADR-036's original `to_openapi` clause), [ADR-043](decisions/043-webtransport-bidirectional-alpn-substrate.md) (WebTransport as a bidirectional ALPN transport substrate — reframes WebTransport as carrying ALPNs as bidirectional streams with the call protocol as the first/canonical target; names the call protocol's bidirectionality over WebTransport; states the inbound no-`PeerId` connection-local overlay as the mirror of ADR-034 §2). ADR-003 Amendment 1 clarifies that `alknet-call` is a protocol-foundation crate (the `alknet-http``alknet-call` dependency edge). A consistency review pass corrected drift from the mid-spec pivot (the `to_openapi` gateway pattern landed in the prose but not in cross-references; the WebTransport specs inherited the OpenAPI/MCP direction assumption that doesn't hold for the call protocol) — ADR-036's `to_openapi` clause is now amended as superseded by ADR-042, ADR-034 §5's "deferral bucket" wording is corrected (the decision stands), and the http specs now name the one-directional HTTP projection vs. the bidirectional WebTransport substrate. The specs are in draft; implementation has not started. Three open questions carried: OQ-38 (WebTransport standalone relay service scope — distinct from the in-process ALPN-stream-proxy resolved by ADR-040), OQ-39 (`to_openapi` published-spec versioning), OQ-40 (reqwest client config).
**Next step**: The storage/repo-pattern ADRs (030033) are accepted and amend the core and call specs. The next implementation phase is the ADR-029 migration (peer-keyed overlays, `PeerRef` routing, retire `remote_safe`/`trusted_peer`) with the ADR-030 `PeerEntry` change and the ADR-032 `forwarded_for` field folded in — the `OperationContext`, `from_call` handler, and `AuthPolicy` are all under edit, making this the cheapest window. After that: alknet-http implementation (specs drafted, ADRs 036038 proposed), which consumes the `CredentialStore` trait and the `OperationAdapter` contract. The alknet-ssh crate (the other post-core crate, specced in parallel) proceeds independently — it depends on `alknet-core`, not `alknet-call`.
@@ -95,6 +95,7 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
| [040](decisions/040-webtransport-alpn-stream-proxy.md) | WebTransport ALPN-Stream-Proxy | Proposed |
| [041](decisions/041-mcp-tool-gateway-pattern.md) | MCP Tool-Gateway Pattern for to_mcp | Proposed |
| [042](decisions/042-openapi-gateway-pattern.md) | OpenAPI Gateway Pattern for to_openapi | Proposed |
| [043](decisions/043-webtransport-bidirectional-alpn-substrate.md) | WebTransport as a Bidirectional ALPN Transport Substrate | Proposed |
## Open Questions

View File

@@ -40,9 +40,10 @@ on standard ALPNs, and hosts the HTTP-backed call-protocol adapters
| [037](../../decisions/037-mcp-stdio-transport-exclusion.md) | MCP Stdio Transport Exclusion | Streamable HTTP only; stdio not built |
| [038](../../decisions/038-http3-and-webtransport-as-first-class.md) | HTTP/3 and WebTransport as First-Class HTTP Transports | `h3` in scope, not deferred |
| [039](../../decisions/039-http-server-and-client-host-colocated.md) | HTTP Server and Client Host Colocated in alknet-http | One crate for server + client host (shared HTTP deps, shared mapping) |
| [040](../../decisions/040-webtransport-alpn-stream-proxy.md) | WebTransport ALPN-Stream-Proxy | Browser → WebTransport stream → any ALPN handler (SSH, git, SFTP) via WASM parser |
| [040](../../decisions/040-webtransport-alpn-stream-proxy.md) | WebTransport ALPN-Stream-Proxy | The substrate's mechanism for non-call ALPNs (SSH, git, SFTP) — browser → WebTransport stream → target ALPN handler via WASM parser |
| [041](../../decisions/041-mcp-tool-gateway-pattern.md) | MCP Tool-Gateway Pattern for to_mcp | 4 fixed gateway tools (search/schema/call/batch), not one tool per operation; Subscription excluded |
| [042](../../decisions/042-openapi-gateway-pattern.md) | OpenAPI Gateway Pattern for to_openapi | 5 fixed gateway endpoints (search/schema/call/batch/subscribe), not one path per operation; per-caller AccessControl-filtered |
| [043](../../decisions/043-webtransport-bidirectional-alpn-substrate.md) | WebTransport as a Bidirectional ALPN Transport Substrate | WebTransport carries ALPNs as bidirectional streams; call protocol is the first/canonical target (needs no WASM parser); both sides can initiate calls; no-`PeerId` non-peer clients use a connection-local overlay |
## Relevant Open Questions
@@ -69,9 +70,14 @@ on standard ALPNs, and hosts the HTTP-backed call-protocol adapters
[overview.md](overview.md).
2. **The HTTP surface is a projection of the call protocol.** An HTTP
request at `POST /fs/readFile` becomes a `call.requested` for
`/fs/readFile`. The HTTP path IS the operation path; `to_openapi`
describes this surface, it does not define a second one. See
[ADR-036](../../decisions/036-http-to-call-operation-mapping.md).
`/fs/readFile`. The HTTP path IS the operation path on the
**direct-call surface**. `to_openapi` *describes* a different surface
— the 5-endpoint gateway (`/search`, `/schema`, `/call`, `/batch`,
`/subscribe`) that gates discovery and invocation behind a fixed
entry set. See [ADR-036](../../decisions/036-http-to-call-operation-mapping.md)
(direct-call surface) and [ADR-042](../../decisions/042-openapi-gateway-pattern.md)
(`to_openapi` gateway, superseding ADR-036's original `to_openapi`
clause).
3. **Standard ALPNs, not alknet ALPNs.** `h2`, `http/1.1`, `h3` are
IANA-registered ALPN strings. Any HTTP client (browser, curl, axios)
connects without knowing about alknet — the TLS handshake negotiates
@@ -89,13 +95,27 @@ on standard ALPNs, and hosts the HTTP-backed call-protocol adapters
The browser streaming path uses QUIC streams directly. See
[ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md).
7. **The `h3` handler is an ALPN-stream-proxy for browsers.** A browser
with a WASM parser can reach any ALPN handler (SSH, git, SFTP) via
WebTransport — no install, no native client, no VPN. SSH-over-
WebTransport is HTTPS-shaped at the network layer (anti-censorship).
See [ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md).
with a WASM parser can reach any non-call ALPN handler (SSH, git,
SFTP) via WebTransport — no install, no native client, no VPN. The
call protocol needs no proxy (it speaks EventEnvelope directly);
the ALPN-stream-proxy is the substrate's mechanism for the protocols
that need a client-side parser. SSH-over-WebTransport is
HTTPS-shaped at the network layer (anti-censorship). See
[ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md)
and [ADR-043](../../decisions/043-webtransport-bidirectional-alpn-substrate.md).
8. **`h3` requires X.509.** Browsers don't support RFC 7250 raw keys
(ADR-027). A node serving WebTransport must have an X.509 identity.
This is a browser limitation, not an alknet decision.
9. **WebTransport is a bidirectional ALPN transport substrate.**
WebTransport carries ALPN protocols as bidirectional streams; the
call protocol is the first/canonical target (JSON-RPC over QUIC
streams, needs no WASM parser, runs in Deno/Node/browsers/native
Rust). Both sides of a WebTransport call-protocol session can
initiate calls — the call protocol's bidirectionality applies
unchanged. The HTTP/1.1 + HTTP/2 surface is the one-directional
projection (HTTP is request/response); WebTransport restores the
bidirectional call model. See
[ADR-043](../../decisions/043-webtransport-bidirectional-alpn-substrate.md).
## References

View File

@@ -352,8 +352,8 @@ once published, the 5-endpoint gateway shape is one-way.
| `from_openapi` provenance is a leaf | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | `composition_authority: None`, `scoped_env: None` |
| Error fidelity (`HTTP_<status>` codes) | [ADR-023](../../decisions/023-operation-error-schemas.md) | No collision with protocol codes; `to_openapi` projects back |
| No-env-vars credential injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Handler reads `context.capabilities`, not env vars |
| HTTP path = operation path | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `to_openapi` paths mirror `/{service}/{op}` |
| `to_openapi` gateway pattern | [ADR-042](../../decisions/042-openapi-gateway-pattern.md) | 5 fixed gateway endpoints (search/schema/call/batch/subscribe), not one path per operation; per-caller AccessControl-filtered |
| HTTP path = operation path (direct-call surface) | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `POST /{service}/{op}` → `call.requested` (the direct-call surface; not what `to_openapi` describes) |
| `to_openapi` gateway pattern | [ADR-042](../../decisions/042-openapi-gateway-pattern.md) | 5 fixed gateway endpoints (search/schema/call/batch/subscribe), not one path per operation; per-caller AccessControl-filtered. Supersedes ADR-036's original `to_openapi` "paths mirror `/{service}/{op}`" clause |
## Open Questions

View File

@@ -30,10 +30,11 @@ pub struct HttpAdapter {
}
/// The stealth decoy surface for paths that are not registered
/// operations (and not `/healthz`, `/openapi.json`, or the MCP route).
/// Set by the assembly layer at `HttpAdapter` construction. The
/// existence of the decoy path is fixed by ADR-010; the variant is a
/// two-way-door config default.
/// operations (and not `/healthz`, `/openapi.json`, the `to_openapi`
/// gateway endpoints `/search`/`/schema`/`/call`/`/batch`/`/subscribe`,
/// or the MCP route). Set by the assembly layer at `HttpAdapter`
/// construction. The existence of the decoy path is fixed by ADR-010;
/// the variant is a two-way-door config default.
pub enum DecoyConfig {
/// Serve a fake `404 Not Found` (the default — matches the reference
/// implementation's "fake nginx 404").
@@ -99,10 +100,25 @@ identity provider through the router's state.
The axum `Router` is the single routing surface for HTTP requests. It
contains:
- The call-protocol projection routes (`POST /{service}/{op}`
`call.requested` dispatch — ADR-036).
- **The direct-call surface** (`POST /{service}/{op}` `call.requested`
dispatch — ADR-036). This is the HTTP projection of the call protocol's
`/{service}/{op}` operation path; an HTTP client that knows the
operation name calls it directly.
- **The `to_openapi` gateway endpoints** (`/search`, `/schema`, `/call`,
`/batch`, `/subscribe` — ADR-042). These are the fixed 5-endpoint
gateway that an OpenAPI consumer uses to discover and invoke
operations without knowing operation names up front. `/call` and
`/subscribe` dispatch through the same `OperationRegistry::invoke()`
as the direct-call surface; `/search` and `/schema` dispatch the
`services/list` / `services/schema` discovery ops. The gateway and
the direct-call surface coexist on the same router — they are two
projections of the same operation registry, not two registries.
- `GET /healthz` (raw route, no auth, no call protocol).
- `GET /openapi.json` (serves the `to_openapi` projection).
- `GET /openapi.json` (serves the `to_openapi` projection — the OpenAPI
document that *describes* the 5 gateway endpoints. Post-ADR-042 this
is the gateway's description doc, not a per-operation REST spec; the
doc describes the 5 fixed endpoints, and the per-caller operation
surface is discovered via `/search`, not preloaded into `paths`).
- The stealth decoy fallback (unknown paths).
- (Feature-gated) `POST /mcp` (the `to_mcp` streamable HTTP service —
[http-mcp.md](http-mcp.md)).
@@ -155,6 +171,32 @@ 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 (see [webtransport.md](webtransport.md)).
### One-directional projection (HTTP request/response)
The HTTP/1.1 + HTTP/2 surface is a **lossy, one-directional projection**
of the call protocol. HTTP is request/response: the client initiates,
the server responds. The call protocol is bidirectional — both sides can
initiate calls (see
[../call/call-protocol.md](../call/call-protocol.md) §"Bidirectional
Calls": the server can call operations on the client just as the client
calls operations on the server). The HTTP projection carries only the
client→server call direction; the server→client call direction has no
HTTP expression (there is no HTTP mechanism for the server to initiate a
request to the client). `Subscription` streaming is the one partial
exception — the server streams `call.responded` frames back over the
SSE response — but even there, the *call* is client-initiated; only the
*results* flow server→client.
This is a structural property of HTTP, not a design choice in this
crate. WebTransport (`h3`) restores the bidirectional call model: a
WebTransport session is a long-lived connection over which either side
can open bidirectional streams and send `call.requested` events in
either direction — the call protocol's native bidirectionality applies
unchanged. See [webtransport.md](webtransport.md) and ADR-043. The
HTTP/1.1 + HTTP/2 surface is the projection for clients that only speak
HTTP; WebTransport is the surface for clients that can speak the call
protocol in both directions.
### Auth
Inbound HTTP auth is `Authorization: Bearer <token>`, resolved via
@@ -219,8 +261,9 @@ routes. `healthz` is the one exception. See ADR-036.
### Stealth decoy
For paths that are not registered operations (and not `/healthz`,
`/openapi.json`, or the MCP route), the HTTP handler serves a decoy. The
decoy is configurable (`DecoyConfig`):
`/openapi.json`, the `to_openapi` gateway endpoints `/search`/`/schema`/
`/call`/`/batch`/`/subscribe`, or the MCP route), the HTTP handler serves
a decoy. The decoy is configurable (`DecoyConfig`):
- A fake `404 Not Found` (the default — matches the reference
implementation's "fake nginx 404").
@@ -235,9 +278,13 @@ config is a two-way-door default (an operator picks what to serve); the
## Constraints
- **The HTTP path IS the operation path.** `POST /fs/readFile`
`call.requested` for `fs/readFile`. No second routing table. See
ADR-036.
- **The HTTP path IS the operation path on the direct-call surface.**
`POST /fs/readFile``call.requested` for `fs/readFile`. No second
routing table for the direct-call surface. See ADR-036. The
`to_openapi` gateway (`/search`, `/schema`, `/call`, `/batch`,
`/subscribe`) is a separate fixed-endpoint surface (ADR-042) that
coexists with the direct-call surface on the same axum `Router`; it
does not replace it.
- **`External` operations only.** `Internal` operations return `404`
on the HTTP handler.
- **Bearer-only auth.** `Authorization: Bearer`
@@ -256,7 +303,8 @@ config is a two-way-door default (an operator picks what to serve); the
| Decision | ADR | Summary |
|----------|-----|---------|
| Direct path mapping (HTTP path = operation path) | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `POST /{service}/{op}``call.requested` |
| Direct path mapping (HTTP path = operation path) | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `POST /{service}/{op}``call.requested` (direct-call surface) |
| `to_openapi` gateway endpoints on the router | [ADR-042](../../decisions/042-openapi-gateway-pattern.md) | `/search`/`/schema`/`/call`/`/batch`/`/subscribe` coexist with the direct-call surface |
| SSE projection for subscriptions over h2/http1.1 | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `call.responded` stream → SSE frames |
| `/healthz` is a raw route | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | No auth, no call protocol |
| Stealth decoy | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | HTTP handler on standard ALPNs serves decoy |

View File

@@ -54,6 +54,24 @@ headers, streaming, SSE), so they belong in one crate. See
[ADR-039](../../decisions/039-http-server-and-client-host-colocated.md)
for the colocation decision.
A note on the "from/to" direction model: the `from_openapi`/`to_openapi`
and `from_mcp`/`to_mcp` adapters are *inherently directional* because
OpenAPI and MCP are client/server protocols — one side serves, the
other calls. That directionality is a property of those protocols, not
of the call protocol itself. The call protocol is bidirectional (see
[../call/call-protocol.md](../call/call-protocol.md) §"Bidirectional
Calls": both sides can initiate calls). The HTTP/1.1 + HTTP/2 surface
inherits HTTP's request/response constraint and projects the call
protocol one-directionally (client→server calls only — see
[http-server.md](http-server.md) §"One-directional projection").
WebTransport (`h3`) is the HTTP-family transport that restores the
call protocol's native bidirectionality — it is a transport substrate
for the call protocol (and, via the ALPN-stream-proxy, for any ALPN),
not a browser→hub one-way path. See [webtransport.md](webtransport.md)
and ADR-043. The "from/to" naming of the OpenAPI/MCP adapters should not
be read as a statement about the call protocol's directionality; it is
a statement about OpenAPI's and MCP's directionality.
## Dependencies
```
@@ -205,9 +223,10 @@ verified against this invariant. See ADR-014 and
| MCP stdio transport exclusion | [ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md) | Streamable HTTP only; stdio is not built (RCE vector) |
| HTTP/3 + WebTransport first-class | [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md) | `h3` in scope, not deferred; browser streaming uses QUIC streams |
| HTTP server + client host colocated | [ADR-039](../../decisions/039-http-server-and-client-host-colocated.md) | One crate for server + adapters (shared HTTP deps, shared mapping) |
| WebTransport ALPN-stream-proxy | [ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md) | Browser → WebTransport stream → any ALPN handler (SSH, git, SFTP) via WASM parser |
| WebTransport ALPN-stream-proxy | [ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md) | The substrate's mechanism for non-call ALPNs (SSH, git, SFTP) — browser → WebTransport stream → target ALPN handler via WASM parser |
| `to_mcp` tool-gateway pattern | [ADR-041](../../decisions/041-mcp-tool-gateway-pattern.md) | 4 fixed gateway tools (search/schema/call/batch), not one tool per operation |
| `to_openapi` gateway pattern | [ADR-042](../../decisions/042-openapi-gateway-pattern.md) | 5 fixed gateway endpoints (search/schema/call/batch/subscribe); per-caller AccessControl-filtered |
| WebTransport bidirectional ALPN substrate | [ADR-043](../../decisions/043-webtransport-bidirectional-alpn-substrate.md) | WebTransport carries ALPNs as bidirectional streams; call protocol is the first target; both sides can initiate calls; non-peer clients use a connection-local overlay |
| `alknet-call` is protocol-foundation | [ADR-003](../../decisions/003-crate-decomposition.md) Am. 1 | `alknet-http` depends on `alknet-call` (types, not peer handler) |
| Bearer auth via `resolve_from_token` | [ADR-004](../../decisions/004-auth-as-shared-core.md) | HTTP handler credential source + resolution (settled) |
| Stealth mode = HTTP handler on standard ALPNs | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Decoy for unknown paths (settled) |

View File

@@ -6,11 +6,17 @@ last_updated: 2026-06-29
# WebTransport — the h3 ALPN handler
The `HttpAdapter` registration for the `h3` ALPN: HTTP/3 and
WebTransport. This document covers the WebTransport session/stream
handling, the browser streaming path, the ALPN-stream-proxy (browser
access to any ALPN handler via WebTransport), and the relationship to
the `h2`/`http/1.1` server. The `h3` support is a first-class transport,
not a deferral (ADR-038).
WebTransport. WebTransport is a **bidirectional ALPN transport
substrate** (ADR-043) — it carries ALPN protocols as bidirectional
streams, with the call protocol as the first/canonical target (needs no
WASM parser) and the ALPN-stream-proxy (ADR-040) as the mechanism for
non-call ALPNs (SSH, git, SFTP) that need a client-side parser. This
document covers the WebTransport session/stream handling, the
substrate's three stream destinations, the no-`PeerId` connection-local
overlay for non-peer clients, and the relationship to the `h2`/
`http/1.1` server (the one-directional projection WebTransport restores
bidirectionality for). The `h3` support is a first-class transport
(ADR-038).
## What
@@ -20,18 +26,32 @@ enabled. It serves two things on a single `h3` connection:
1. **HTTP/3 requests** — the standard HTTP/3 over QUIC framing. An
HTTP/3 request is dispatched through the same axum `Router` as `h2`/
`http/1.1` requests (ADR-036 — the HTTP path IS the operation path).
From the axum router's perspective, an HTTP/3 request is just
`http/1.1` requests (ADR-036 — the HTTP path IS the operation path
on the direct-call surface; ADR-042 — the gateway endpoints). From
the axum router's perspective, an HTTP/3 request is just
another HTTP request; the framing difference is handled below the
router.
2. **WebTransport sessions** — the browser streaming path. A WebTransport
session is a long-lived connection over which the browser opens
bidirectional and unidirectional streams. Streams within a session
target one of three destinations (see [ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md)):
- The call protocol (`EventEnvelope` → the call `Dispatcher`),
router. The HTTP/3 request path is the **one-directional projection**
(client→server calls only — HTTP is request/response; see
[http-server.md](http-server.md) §"One-directional projection").
2. **WebTransport sessions** — the **bidirectional** path. WebTransport
is a transport substrate that carries ALPN protocols as
bidirectional streams (ADR-043), not a browser→hub one-way path. A
WebTransport session is a long-lived connection over which either
side can open bidirectional and unidirectional streams. Streams
within a session target one of three destinations (see
[ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md)):
- The call protocol (`EventEnvelope` → the call `Dispatcher`) — the
canonical target; needs no WASM parser because the EventEnvelope
framing is platform/language/runtime agnostic (JSON-RPC over QUIC
streams). Both sides can initiate calls — the call protocol's
bidirectionality applies unchanged (ADR-043 §2,
[../call/call-protocol.md](../call/call-protocol.md) §
"Bidirectional Calls").
- An ALPN handler proxy (the stream is handed to another ALPN
handler like `SshAdapter` — the browser runs a WASM parser for the
target protocol), or
handler like `SshAdapter` — the client runs a WASM parser for the
target protocol). This is the substrate's mechanism for non-call
ALPNs (SSH, git, SFTP) that need a parser on the client side
(ADR-043 §4).
- Another sub-protocol (declared at CONNECT time).
The ALPN-stream-proxy is what makes the browser a universal alknet
@@ -39,23 +59,38 @@ client: with a WASM parser for SSH (or SFTP, git), a browser can reach
any ALPN handler via WebTransport, no install, no native client, no
VPN. This is the "VPN-like without being a VPN" use case the project
was originally built for, now on a clean foundation. See
[ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md).
[ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md) and
the substrate framing in [ADR-043](../../decisions/043-webtransport-bidirectional-alpn-substrate.md).
### Why h3 is a first-class transport
WebTransport is the browser streaming transport. QUIC streams are cheap
(multiplexed over one connection, no head-of-line blocking), and
WebTransport is supported in major browsers. The call protocol's
subscription/streaming model maps onto WebTransport streams with no
translation loss — a `call.responded` stream over a WebTransport
bidirectional stream is the native representation, not an SSE
translation (which is the projection for `h2`/`http/1.1` clients per
ADR-036).
WebTransport is the bidirectional streaming transport for the call
protocol and a transport substrate for any ALPN. QUIC streams are
cheap (multiplexed over one connection, no head-of-line blocking), and
WebTransport is supported in major browsers and beyond (Deno, Node,
native Rust). The call protocol's subscription/streaming model maps
onto WebTransport streams with no translation loss — a `call.responded`
stream over a WebTransport bidirectional stream is the native
representation, not an SSE translation (which is the projection for
`h2`/`http/1.1` clients per ADR-036).
The Phase 0 research framing ("defer h3/WebTransport past v1") was a
residual of the "two-way door as deferral" anti-pattern (ADR-009 §"What
this framework is NOT"). WebTransport is in scope, in this crate, as a
first-class transport. See ADR-038.
More importantly, **WebTransport restores the call protocol's
bidirectionality** that the HTTP/1.1 + HTTP/2 surface structurally
cannot carry. HTTP is request/response — the client initiates, the
server responds; the server→client *call* direction has no HTTP
expression (see [http-server.md](http-server.md) §"One-directional
projection"). WebTransport is a long-lived connection over which either
side can open bidirectional streams and send `call.requested` in either
direction — the call protocol's native bidirectionality applies
unchanged (ADR-043 §2). WebTransport is also supported beyond browsers
(Deno, Node, native Rust via `wtransport`), and the call protocol —
JSON-RPC over QUIC streams — is platform/language/runtime agnostic, so
call-protocol-over-WebTransport is a general bidirectional RPC
substrate, not a browser-only path (ADR-043 §1).
WebTransport is in scope, in this crate, as a first-class transport
(ADR-038). See [ADR-043](../../decisions/043-webtransport-bidirectional-alpn-substrate.md)
for the substrate framing.
## Architecture
@@ -90,7 +125,7 @@ HTTP/3 request and a WebTransport stream.
### WebTransport session and stream handling
Once a WebTransport session is established (via extended CONNECT), the
browser creates bidirectional streams within it. The handler dispatches
client creates bidirectional streams within it. The handler dispatches
each stream to one of three destinations, determined by the session's
CONNECT path (the routing key, declared at CONNECT time — not by peeking
the first application frame):
@@ -102,14 +137,28 @@ the first application frame):
for `EventEnvelope` and [../call/client-and-adapters.md](../call/client-and-adapters.md)
§"Shared Dispatcher" for the `Dispatcher` — the same dispatch loop
the `CallAdapter` uses for `alknet/call` connections, ADR-012,
stream-agnostic correlation). The browser speaks the EventEnvelope
stream-agnostic correlation). The client speaks the EventEnvelope
wire format directly over the WebTransport stream.
**Bidirectionality (ADR-043 §2):** the call-protocol session inherits
the call protocol's native bidirectionality — both sides can initiate
calls. The client calls operations on the hub; the hub can call
operations registered on the client's side, over the same session,
using the same `PendingRequestMap` and `EventEnvelope` framing as
`alknet/call` (see [../call/call-protocol.md](../call/call-protocol.md)
§"Bidirectional Calls"). The browser case (ADR-034 §4) is the common
case where the client registers no operations of its own, so the
server→client call direction is unused — that is a use-case scoping,
not an architectural limitation. A non-browser WebTransport client
(Deno, Node, a peer preferring WebTransport) that registers
operations receives calls from the hub over the same session.
- **`/alknet/<name>` → ALPN-handler proxy session.** Each bidirectional
stream is handed to the target ALPN handler (e.g., `SshAdapter` for
`/alknet/ssh`, `GitAdapter` for `/alknet/git`) as a `Connection`
wrapping the WebTransport stream. The browser runs a WASM parser for
wrapping the WebTransport stream. The client runs a WASM parser for
the target protocol and speaks it directly over the stream. This is
the ALPN-stream-proxysee
the substrate's mechanism for non-call ALPNs (ADR-043 §4)the
ALPN-stream-proxy — see
[ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md).
The `h3` handler looks up the target ALPN handler in the
`HandlerRegistry` (`HttpAdapter` holds `Arc<HandlerRegistry>` for
@@ -124,14 +173,17 @@ the first application frame):
confirmation for sessions that multiplex sub-protocols, not the
routing mechanism.
The browser's `WebTransport` JS API is the client side of this:
The browser's `WebTransport` JS API is one client side of this:
`new WebTransport('https://hub.example.com/alknet/ssh')`
`transport.createBidirectionalStream()` → the browser's WASM SSH client
reads/writes the stream as a `BiStream` (ADR-007). No SSE translation,
no HTTP framing — the target protocol speaks directly over the
WebTransport stream. For the call-protocol session, the browser writes
`EventEnvelope` frames; for an SSH session, the browser runs the WASM
SSH parser.
SSH parser. A non-browser client (Deno, Node, native Rust) speaks the
same wire formats over the same substrate without a WASM parser — the
call protocol needs no parser, and native ALPN clients (SSH, git) use
native parsers rather than WASM.
### Subscription projection (native, not SSE)
@@ -143,14 +195,23 @@ closes the stream with an error frame. This is the native streaming
projection; SSE (ADR-036) is the projection for `h2`/`http/1.1` clients
that don't speak WebTransport.
### ALPN-stream-proxy (ADR-040)
### ALPN-stream-proxy (ADR-040, repositioned by ADR-043 §4)
The ALPN-stream-proxy is the `h3` handler's third stream destination and
the browser's gateway to every ALPN handler. A browser opens a
WebTransport session to `/alknet/ssh` (or `/alknet/git`, `/alknet/sftp`),
and the `h3` handler hands each bidirectional stream within that session
to the target ALPN handler as a `Connection`. The browser runs a WASM
parser for the target protocol and speaks it directly over the stream.
the substrate's mechanism for non-call ALPNs — the protocols (SSH, git,
SFTP) that need a client-side parser, unlike the call protocol which
speaks EventEnvelope directly. ADR-040 framed it as "the browser's
gateway to every ALPN handler"; ADR-043 §4 repositions it as the
substrate's non-call-ALPN mechanism, of which the browser use case is
the primary (but not the only) instance. The decision in ADR-040 (the
`HandlerRegistry` reference, path-based routing) stands unchanged; the
framing is what ADR-043 refines.
The browser use case: a browser opens a WebTransport session to
`/alknet/ssh` (or `/alknet/git`, `/alknet/sftp`), and the `h3` handler
hands each bidirectional stream within that session to the target ALPN
handler as a `Connection`. The browser runs a WASM parser for the
target protocol and speaks it directly over the stream.
**Why this matters:** SSH-over-WebTransport is HTTPS-shaped at the
network layer (WebTransport is HTTP/3 over QUIC over UDP, the same as
@@ -217,6 +278,43 @@ alknet peer (ADR-034 §4): it gets no `PeerId`, does not enter
`PeerCompositeEnv`, and its "ops" are WebTransport streams served by
the `h3` handler, not entries in the call-protocol peer-keyed overlay.
### The no-`PeerId` connection-local overlay (ADR-043 §3)
A non-peer WebTransport 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
bearer-token-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:
ops discovered land in "that connection's Layer 2 overlay" —
connection-local, no `PeerId`). On the inbound WebTransport path, 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. An implementer building `compose_root_env` for a WebTransport
session applies the ADR-034 §2 connection-local-overlay pattern (mirror
direction) 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. See ADR-043 §3.
### Stealth on h3
The `h3` handler participates in the same stealth model as `h2`/
@@ -261,9 +359,20 @@ as a first-class transport.
authenticates by bearer token, gets no `PeerId` (ADR-034 §4).
- **WebTransport streams target one of three destinations** (the
session's CONNECT path is the routing key): the call protocol
(`EventEnvelope``Dispatcher`), an ALPN handler proxy (→
`HandlerRegistry` lookup → target handler's `handle()`), or another
sub-protocol. See ADR-040.
(`EventEnvelope``Dispatcher`, bidirectional — both sides can
initiate calls), an ALPN handler proxy (→ `HandlerRegistry` lookup
→ target handler's `handle()`, the substrate's non-call-ALPN
mechanism), or another sub-protocol. See ADR-040 and ADR-043.
- **The call-protocol WebTransport session is bidirectional.** Both
sides can initiate calls, inheriting the call protocol's native
bidirectionality (ADR-043 §2). The browser case where the client
registers no ops is a use-case scoping, not an architectural
limitation.
- **Non-peer WebTransport clients use a connection-local overlay.**
A WebTransport client with no `PeerId` (browser, or any non-peer
client) has its registered ops land in a connection-local Layer 2
overlay, not the peer-keyed `PeerCompositeEnv`. This is the inbound
mirror of ADR-034 §2. See ADR-043 §3.
- **The ALPN-stream-proxy requires `Arc<HandlerRegistry>` on
`HttpAdapter`.** The `h3` handler looks up ALPN handlers in the
registry; the `h2`/`http/1.1` path does not use it. The registry is
@@ -280,7 +389,8 @@ as a first-class transport.
| Decision | ADR | Summary |
|----------|-----|---------|
| `h3`/WebTransport is first-class | [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md) | In scope, not deferred; browser streaming uses QUIC streams |
| WebTransport ALPN-stream-proxy | [ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md) | Browser → WebTransport stream → any ALPN handler (SSH, git, SFTP) via WASM parser |
| WebTransport is a bidirectional ALPN transport substrate | [ADR-043](../../decisions/043-webtransport-bidirectional-alpn-substrate.md) | Carries ALPNs as bidirectional streams; call protocol is the first/canonical target (needs no WASM parser); both sides can initiate calls |
| WebTransport ALPN-stream-proxy | [ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md) | The substrate's mechanism for non-call ALPNs (SSH, git, SFTP) — browser → WebTransport stream → target ALPN handler via WASM parser |
| Browsers require X.509 | [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | `h3` needs X.509 (browser limitation) |
| Browsers are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Bearer token, no `PeerId` |
| WebTransport streams → call protocol directly | [ADR-012](../../decisions/012-call-protocol-stream-model.md) | Stream-agnostic; WebTransport stream = QUIC bidirectional stream |
@@ -302,10 +412,16 @@ See [open-questions.md](../../open-questions.md) for full details.
- [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md)
— the decision that `h3` is in scope
- [ADR-043](../../decisions/043-webtransport-bidirectional-alpn-substrate.md)
— the substrate framing: WebTransport carries ALPNs as bidirectional
streams; call protocol is the first target; bidirectionality; the
no-`PeerId` connection-local overlay
- [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) —
the HTTP-to-call mapping (the HTTP/3 request path uses the same
axum `Router`)
- [overview.md](overview.md) — crate overview, feature gates
- [http-server.md](http-server.md) — the `h2`/`http/1.1` companion
(§"One-directional projection" — the lossy HTTP/1.1+HTTP/2 surface
WebTransport restores bidirectionality for)
- `/workspace/wtransport/` — pure-Rust WebTransport reference
implementation (the `h3` feature's candidate dependency)

View File

@@ -214,7 +214,7 @@ This keeps the peer graph populated only by full alknet nodes (role 3
hubs and role-3-style spoke nodes), never by browsers or pure HTTP
clients.
### 5. WebTransport relay-as-proxy is deferred with h3 / WebTransport
### 5. WebTransport relay-as-proxy is a transport-only feature, scoped separately
A **WebTransport proxy** that terminates the browser's WebTransport
connection and proxies encrypted traffic to a hub's P2P endpoint
@@ -231,13 +231,18 @@ is a real feature, especially for the browser-to-P2P-peer case. It is
Ed25519 identity is the same `ed25519:<hex>` whether the client
connected directly or through the proxy.
WebTransport support is already deferred past v1 in the alknet-http
Phase 0 findings (decision point DH-2, "h3/WebTransport — in v1 or
deferred?"). The WebTransport-relay-as-proxy feature
belongs in that same deferral bucket — it lands when `h3` /
WebTransport lands, and it does not require any change to the auth
model in this ADR. It is recorded here so it is not lost; it is not an
open question for the auth model.
> **Amendment (wording only — the decision stands):** An earlier draft
> of this section framed the relay-as-proxy as belonging to an
> "h3/WebTransport deferral bucket" and "lands when `h3` /
> WebTransport lands." That framing was a residual of the "two-way door
> as deferral" anti-pattern (ADR-009 §"What this framework is NOT")
> that [ADR-038](038-http3-and-webtransport-as-first-class.md) was later
> written to reject — `h3`/WebTransport is a first-class transport, in
> scope, not deferred. The *auth-model* decision in this §5 (the proxy
> is transport-only; it does not change identity resolution) is
> unchanged. The *scope* question (does the proxy belong in
> `alknet-http` or a separate relay crate?) is tracked as OQ-38 — a
> genuine scope question, not a deferral.
### 6. On-chain / smart-contract peer discovery fits the OQ-36 adapter pattern
@@ -300,9 +305,10 @@ It is noted here only to confirm it does not reopen OQ-37.
WebTransport/HTTPS) is confirmed to need no new types — ADR-030's
`fingerprints: Vec<String>` already covers it.
- The WebTransport-relay-as-proxy and on-chain-discovery use cases are
recorded with clear homes (h3/WebTransport deferral bucket; OQ-36
adapter pattern) so they don't get lost and don't reopen the auth
model.
recorded with clear homes (the relay-as-proxy is a transport-only
feature whose scope is tracked as OQ-38; the on-chain discovery
follows the OQ-36 adapter pattern) so they don't get lost and don't
reopen the auth model.
**Negative:**
- The `alknet-http` and `alknet-call` client paths must branch on
@@ -389,8 +395,13 @@ It is noted here only to confirm it does not reopen OQ-37.
repo/adapter pattern (trait in core, adapter additive in a separate
crate)
- `docs/research/alknet-http/phase-0-findings.md` — DH-2 (h3 /
WebTransport deferred past v1); the WebTransport-relay-as-proxy
feature noted in this ADR's §5 belongs in that deferral bucket
WebTransport; the original "deferred past v1" framing is rejected by
ADR-038); the WebTransport-relay-as-proxy feature noted in this ADR's
§5 is a transport-only feature whose scope is tracked as OQ-38
- [ADR-038](038-http3-and-webtransport-as-first-class.md) — `h3` /
WebTransport is a first-class transport, not deferred (amends the
"deferral bucket" wording in this ADR's §5; the auth-model decision
stands)
- `docs/research/references/iroh/iroh/04-sub-crates.md` — iroh's
transport relay (`iroh-relay`), referenced to distinguish it from
alknet's hub role

View File

@@ -57,6 +57,21 @@ 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.
> **Amendment (superseded by [ADR-042](042-openapi-gateway-pattern.md) on
> the `to_openapi` clause):** The paragraph above described the original
> "per-operation-paths projection" — `to_openapi` generating one OpenAPI
> path entry per `External` operation, mirroring `/{service}/{op}`. ADR-042
> replaces this with the **gateway pattern**: `to_openapi` generates 5
> fixed gateway endpoints (`/search`, `/schema`, `/call`, `/batch`,
> `/subscribe`) instead of one path per operation. The "no second routing
> table" property is preserved (the gateway endpoints are fixed; the
> per-caller operation surface is discovered via `/search`, not preloaded
> into a generated path set). The direct-call surface (`POST
> /{service}/{op}`) that this ADR defines is **unchanged** — ADR-042 only
> changes what `to_openapi` *describes*, not what the HTTP handler
> *serves*. A traditional per-operation-paths OpenAPI projection remains
> available as an additive alternative (ADR-042 §5).
### HTTP method semantics
The call protocol's `OperationType` (`Query`, `Mutation`, `Subscription`,
@@ -191,6 +206,10 @@ without auth before identity is resolvable.
`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
- [ADR-042](042-openapi-gateway-pattern.md) — supersedes this ADR's
`to_openapi` clause (the per-operation-paths projection is replaced by
the 5-endpoint gateway pattern; the direct-call surface this ADR
defines is unchanged)
- OQ-13 (resolved) — operation path format `/{service}/{op}`
- `docs/research/alknet-http/phase-0-findings.md` DH-3 — the decision this
ADR resolves

View File

@@ -40,8 +40,14 @@ WebTransport stream. So the browser:
The hub's `h3` handler needs to hand that WebTransport stream to the
target ALPN handler (e.g., `SshAdapter`) as if it were a QUIC stream
arriving on that ALPN. The `h3` handler becomes an **ALPN-stream-proxy**:
a browser-side gateway that gives browsers access to any ALPN handler
via WebTransport.
a WebTransport-client-side gateway (browser or otherwise) that gives
WebTransport clients access to any non-call ALPN handler via WebTransport.
> Repositioned by [ADR-043](043-webtransport-bidirectional-alpn-substrate.md)
> §4: the proxy is the substrate's mechanism for non-call ALPNs (SSH,
> git, SFTP) that need a client-side parser, distinct from the call
> protocol which speaks EventEnvelope directly and needs no proxy. The
> browser is the primary use case; the decision (the `HandlerRegistry`
> reference, path-based routing) is unchanged.
### Why this matters
@@ -275,6 +281,10 @@ Two layers, same as a native `alknet/ssh` connection.
- [ADR-038](038-http3-and-webtransport-as-first-class.md) — `h3` is
first-class (this ADR adds the ALPN-stream-proxy as the third stream
destination)
- [ADR-043](043-webtransport-bidirectional-alpn-substrate.md) §4 —
repositions this ADR's framing: the proxy is the substrate's mechanism
for non-call ALPNs (not the browser's gateway to every ALPN). The
decision stands; the framing is refined.
- `crates/http/webtransport.md` — the spec that implements this proxy
- `crates/core/endpoint.md``HandlerRegistry` (the registry the
`h3` handler gains a reference to)

View File

@@ -0,0 +1,331 @@
# ADR-043: WebTransport as a Bidirectional ALPN Transport Substrate
## Status
Proposed
## Context
`alknet-http`'s `h3`/WebTransport specs
([webtransport.md](../crates/http/webtransport.md),
[ADR-040](040-webtransport-alpn-stream-proxy.md)) 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](../crates/call/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-`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](012-call-protocol-stream-model.md) — stream-agnostic
correlation (a WebTransport stream is a QUIC bidirectional stream;
the `Dispatcher` is the same dispatch loop)
- [ADR-007](007-bistream-type-definition.md) — `BiStream` trait
(source-agnostic; the contract a WebTransport stream satisfies)
- [ADR-027](027-tls-identity-redesign-acme-rawkey-decoupling.md) —
browsers require X.509 (the `h3` handler is domain-hosted)
- [ADR-034](034-outgoing-only-x509-and-three-peer-roles.md) §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-038](038-http3-and-webtransport-as-first-class.md) — `h3` is
first-class (this ADR refines the framing, not the scope)
- [ADR-040](040-webtransport-alpn-stream-proxy.md) — the ALPN-stream-
proxy (this ADR repositions it as the substrate's non-call-ALPN
mechanism; the decision stands)
- [ADR-029](029-peer-graph-routing-model.md) — `PeerCompositeEnv` /
`PeerRef` (the peer-keyed overlay that non-peer WebTransport clients
do not enter)
- [ADR-030](030-peerentry-and-identity-id-decoupling.md) — `PeerId`
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