docs(http): add ADR-048 and websocket.md — WS carries native session, not gateway

Promote the WebSocket browser path from a section in http-server.md to a
first-class spec (websocket.md) and commit the contract-pattern decision
(ADR-048): a WS connection carries the native EventEnvelope call-protocol
session, not the HTTP gateway shape. The gateway endpoints are HTTP-only;
discovery on WS is via services/list/services/schema as ordinary call-protocol
ops; subscriptions project as native call.responded events (no SSE).

ADR-044 already decided WS as the v1 browser bidirectional path; ADR-048
clarifies the shape of what ADR-044 committed (§1 implies native session;
the ADR makes it an explicit implementer-visible rule). The from_wss adapter
(importing a remote node's ops over WS) is recorded as out-of-scope with a
concrete reversal trigger so it is not re-derived later.

Spec cleanup: http-server.md WS section collapsed to a stub pointer;
websocket.md Why section references ADRs rather than re-arguing them;
length-prefix decision made canonical (no prefix on WS — message boundary
is the delimiter); default upgrade path pinned (/alknet/call) with HTTP/2
extended CONNECT noted; indexes (README, http/README, overview) updated.
This commit is contained in:
2026-06-30 12:27:00 +00:00
parent bfd1621b9b
commit b71db99753
9 changed files with 859 additions and 90 deletions

View File

@@ -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. 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 and consistency-reviewed.** The alknet-http crate (HTTP interface — `h2`/`http/1.1` server + WebSocket browser path + `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 twelve ADRs — [ADR-036](decisions/036-http-to-call-operation-mapping.md) (HTTP-to-call mapping; direct-call surface — **routing superseded by ADR-047**, non-routing clauses survive), [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 — **superseded by ADR-044**; its correction of the two-way-door-as-deferral anti-pattern stands, its specific decision is reversed by the scope deferral), [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 — **parked** per ADR-044; revives unchanged when WebTransport revives), [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 — **parked** per ADR-044; §2/§3 transfer to WebSocket for v1), [ADR-044](decisions/044-defer-webtransport-browsers-use-websocket.md) (defer `h3`/WebTransport; browsers use WebSocket for the bidirectional call-protocol path; a scope decision per ADR-009 §"What this framework is NOT"; reversal trigger = a concrete ALPN-stream-proxy use case; states the "browser is not a peer" rationale — addressability vs. bidirectionality — that amends ADR-034 §4), and [ADR-045](decisions/045-to-openapi-gateway-spec-versioning.md) (`to_openapi` published-spec versioning — `info.version` semver tracks the gateway endpoint contract, not the operation set; resolves OQ-39), and [ADR-046](decisions/046-assembly-layer-custom-http-routes.md) (assembly-layer custom HTTP routes on HttpAdapter — `extra_routes: Option<Router>` for deployment-specific endpoints like an OAI-compatible proxy; default surface unchanged, takes precedence on collision), and [ADR-047](decisions/047-remove-direct-call-http-surface.md) (remove the direct-call `POST /{service}/{op}` surface — the gateway `/call` is the sole invoke path; the simplified contract is the few-fixed-endpoints model, not a per-operation REST tree; ADR-036's non-routing clauses survive). 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 WebSocket (and, when revived, WebTransport) substrate. The specs are in draft; implementation has not started. Two open questions carried: OQ-38 (WebTransport standalone relay service scope — distinct from the in-process ALPN-stream-proxy resolved by ADR-040) and OQ-40 (reqwest client config — since resolved by the `ClientWithMiddleware` + middleware stack design). OQ-39 (`to_openapi` published-spec versioning) is resolved by ADR-045. **alknet-http specs drafted and consistency-reviewed.** The alknet-http crate (HTTP interface — `h2`/`http/1.1` server + WebSocket browser path + `from_openapi`/`to_openapi`/`from_mcp`/`to_mcp` adapters) now has architecture specs: [crates/http/](crates/http/) (overview, http-server, websocket, http-adapters, http-mcp, webtransport) and thirteen ADRs — [ADR-036](decisions/036-http-to-call-operation-mapping.md) (HTTP-to-call mapping; direct-call surface — **routing superseded by ADR-047**, non-routing clauses survive), [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 — **superseded by ADR-044**; its correction of the two-way-door-as-deferral anti-pattern stands, its specific decision is reversed by the scope deferral), [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 — **parked** per ADR-044; revives unchanged when WebTransport revives), [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 — **parked** per ADR-044; §2/§3 transfer to WebSocket for v1), [ADR-044](decisions/044-defer-webtransport-browsers-use-websocket.md) (defer `h3`/WebTransport; browsers use WebSocket for the bidirectional call-protocol path; a scope decision per ADR-009 §"What this framework is NOT"; reversal trigger = a concrete ALPN-stream-proxy use case; states the "browser is not a peer" rationale — addressability vs. bidirectionality — that amends ADR-034 §4), and [ADR-045](decisions/045-to-openapi-gateway-spec-versioning.md) (`to_openapi` published-spec versioning — `info.version` semver tracks the gateway endpoint contract, not the operation set; resolves OQ-39), and [ADR-046](decisions/046-assembly-layer-custom-http-routes.md) (assembly-layer custom HTTP routes on HttpAdapter — `extra_routes: Option<Router>` for deployment-specific endpoints like an OAI-compatible proxy; default surface unchanged, takes precedence on collision), and [ADR-047](decisions/047-remove-direct-call-http-surface.md) (remove the direct-call `POST /{service}/{op}` surface — the gateway `/call` is the sole invoke path; the simplified contract is the few-fixed-endpoints model, not a per-operation REST tree; ADR-036's non-routing clauses survive), and [ADR-048](decisions/048-websocket-native-session-not-gateway.md) (WebSocket carries the native `EventEnvelope` call-protocol session, not the HTTP gateway shape — the gateway endpoints are HTTP-only; discovery via `services/list`/`services/schema` as call-protocol ops; clarifies the WS-path shape ADR-044 committed). 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 WebSocket (and, when revived, WebTransport) substrate. The WebSocket path is promoted to its own spec ([websocket.md](crates/http/websocket.md)) with the native-session-vs-gateway distinction made explicit (ADR-048). The specs are in draft; implementation has not started. Two open questions carried: OQ-38 (WebTransport standalone relay service scope — distinct from the in-process ALPN-stream-proxy resolved by ADR-040) and OQ-40 (reqwest client config — since resolved by the `ClientWithMiddleware` + middleware stack design). OQ-39 (`to_openapi` published-spec versioning) is resolved by ADR-045.
**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; `h3`/WebTransport deferred per ADR-044, browser bidirectional path uses WebSocket), 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`. **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; `h3`/WebTransport deferred per ADR-044, browser bidirectional path uses WebSocket), 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`.
@@ -39,7 +39,8 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
| [crates/call/client-and-adapters.md](crates/call/client-and-adapters.md) | draft | CallClient (outbound connection opener), from_call / from_jsonschema, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern | | [crates/call/client-and-adapters.md](crates/call/client-and-adapters.md) | draft | CallClient (outbound connection opener), from_call / from_jsonschema, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern |
| [crates/http/README.md](crates/http/README.md) | draft | alknet-http crate index | | [crates/http/README.md](crates/http/README.md) | draft | alknet-http crate index |
| [crates/http/overview.md](crates/http/overview.md) | draft | Crate purpose, two roles (server + client host), dependencies, adapter location map | | [crates/http/overview.md](crates/http/overview.md) | draft | Crate purpose, two roles (server + client host), dependencies, adapter location map |
| [crates/http/http-server.md](crates/http/http-server.md) | draft | HttpAdapter for h2/http1.1 + WebSocket browser path, axum over QUIC, Bearer auth, stealth, /healthz | | [crates/http/http-server.md](crates/http/http-server.md) | draft | HttpAdapter for h2/http1.1 + WebSocket upgrade route, axum over QUIC, Bearer auth, stealth, /healthz |
| [crates/http/websocket.md](crates/http/websocket.md) | draft | WebSocket browser bidirectional path — native `EventEnvelope` call-protocol session (not the gateway shape); framing, dispatch, bidirectionality, connection-local overlay, browsers-are-not-peers, deferred `from_wss` |
| [crates/http/http-adapters.md](crates/http/http-adapters.md) | draft | from_openapi (reqwest) and to_openapi (projection); no-env-vars injection point | | [crates/http/http-adapters.md](crates/http/http-adapters.md) | draft | from_openapi (reqwest) and to_openapi (projection); no-env-vars injection point |
| [crates/http/http-mcp.md](crates/http/http-mcp.md) | draft | from_mcp / to_mcp (feature-gated), streamable-HTTP-only, stdio exclusion | | [crates/http/http-mcp.md](crates/http/http-mcp.md) | draft | from_mcp / to_mcp (feature-gated), streamable-HTTP-only, stdio exclusion |
| [crates/http/webtransport.md](crates/http/webtransport.md) | deferred | h3/WebTransport handler — deferred per ADR-044; browser bidirectional path uses WebSocket (see http-server.md). Spec kept intact for revival. | | [crates/http/webtransport.md](crates/http/webtransport.md) | deferred | h3/WebTransport handler — deferred per ADR-044; browser bidirectional path uses WebSocket (see http-server.md). Spec kept intact for revival. |
@@ -100,6 +101,7 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
| [045](decisions/045-to-openapi-gateway-spec-versioning.md) | to_openapi Gateway-Spec Versioning | Proposed | | [045](decisions/045-to-openapi-gateway-spec-versioning.md) | to_openapi Gateway-Spec Versioning | Proposed |
| [046](decisions/046-assembly-layer-custom-http-routes.md) | Assembly-Layer Custom HTTP Routes on HttpAdapter | Proposed | | [046](decisions/046-assembly-layer-custom-http-routes.md) | Assembly-Layer Custom HTTP Routes on HttpAdapter | Proposed |
| [047](decisions/047-remove-direct-call-http-surface.md) | Remove the Direct-Call HTTP Surface; Gateway Is the Sole Invoke Path | Proposed | | [047](decisions/047-remove-direct-call-http-surface.md) | Remove the Direct-Call HTTP Surface; Gateway Is the Sole Invoke Path | Proposed |
| [048](decisions/048-websocket-native-session-not-gateway.md) | WebSocket Carries the Native Call-Protocol Session, Not the Gateway Shape | Accepted |
## Open Questions ## Open Questions

View File

@@ -540,7 +540,7 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
- The call protocol does not depend on any database. `PendingRequestMap` is in-memory. Durable session storage is a consumer concern. - The call protocol does not depend on any database. `PendingRequestMap` is in-memory. Durable session storage is a consumer concern.
- Operation specs use JSON Schema. The envelope is always JSON. Binary payloads may be base64-encoded in the `payload` field. - Operation specs use JSON Schema. The envelope is always JSON. Binary payloads may be base64-encoded in the `payload` field.
- Batch is not a protocol primitive — multiple `call.requested` events with correlated IDs provide equivalent semantics. See OQ-14. - Batch is not a protocol primitive — multiple `call.requested` events with correlated IDs provide equivalent semantics. See OQ-14.
- The call protocol is transport-agnostic at the envelope level. The `EventEnvelope` framing can run over QUIC streams, WebSocket frames, or Worker `postMessage`. The `CallAdapter` is the QUIC-specific implementation. **The `EventEnvelope` shape (`{ type, id, payload }`) was derived from the `@alkdev/pubsub` `EventEnvelope` (`/workspace/@alkdev/pubsub/src/types.ts`), which already has a working WebSocket client/server implementation (`event-target-websocket-client.ts` / `event-target-websocket-server.ts`) and a generalized "event target" abstraction. The call protocol refined the envelope with typed event names (`call.requested`, `call.responded`, etc.) and structured payloads; the delta is small and well-defined, making a browser (and Node) WebSocket client straightforward to derive from the pubsub prior art. See ADR-044 and [http-server.md](../http/http-server.md) §"WebSocket browser path". - The call protocol is transport-agnostic at the envelope level. The `EventEnvelope` framing can run over QUIC streams, WebSocket frames, or Worker `postMessage`. The `CallAdapter` is the QUIC-specific implementation. **The `EventEnvelope` shape (`{ type, id, payload }`) was derived from the `@alkdev/pubsub` `EventEnvelope` (`/workspace/@alkdev/pubsub/src/types.ts`), which already has a working WebSocket client/server implementation (`event-target-websocket-client.ts` / `event-target-websocket-server.ts`) and a generalized "event target" abstraction. The call protocol refined the envelope with typed event names (`call.requested`, `call.responded`, etc.) and structured payloads; the delta is small and well-defined, making a browser (and Node) WebSocket client straightforward to derive from the pubsub prior art. See ADR-044, [ADR-048](../../decisions/048-websocket-native-session-not-gateway.md), and [websocket.md](../http/websocket.md).
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer. See ADR-005 and OQ-13. - `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer. See ADR-005 and OQ-13.
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials, raw tokens) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. The wire format carries `serde_json::Value` and cannot enforce this at the type level — the constraint is architectural, enforced by the operation registry and by convention. Operations that need to share public key material use a dedicated operation that returns only the public component. See ADR-014. - **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials, raw tokens) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. The wire format carries `serde_json::Value` and cannot enforce this at the type level — the constraint is architectural, enforced by the operation registry and by convention. Operations that need to share public key material use a dedicated operation that returns only the public component. See ADR-014.
- **Abort cascades to descendants.** `call.aborted` for a parent request cascades to all non-terminal descendants in the call tree. Default policy is `abort-dependents`; `continue-running` is an opt-in. See ADR-016. - **Abort cascades to descendants.** `call.aborted` for a parent request cascades to all non-terminal descendants in the call tree. Default policy is `abort-dependents`; `continue-running` is an opt-in. See ADR-016.

View File

@@ -17,7 +17,8 @@ protocol), and hosts the HTTP-backed call-protocol adapters
| Document | Status | Description | | Document | Status | Description |
|----------|--------|-------------| |----------|--------|-------------|
| [overview.md](overview.md) | draft | Crate purpose, two roles (server + client host), dependencies, adapter location map | | [overview.md](overview.md) | draft | Crate purpose, two roles (server + client host), dependencies, adapter location map |
| [http-server.md](http-server.md) | draft | `HttpAdapter` (`ProtocolHandler` for `h2`/`http/1.1` + WS upgrade), axum over QUIC, Bearer auth, stealth, `/healthz`, WebSocket browser path | | [http-server.md](http-server.md) | draft | `HttpAdapter` (`ProtocolHandler` for `h2`/`http/1.1` + WS upgrade route), axum over QUIC, Bearer auth, stealth, `/healthz`; WS hands off to the native session spec |
| [websocket.md](websocket.md) | draft | WebSocket browser bidirectional path — native `EventEnvelope` call-protocol session (not the gateway shape, ADR-048); framing, dispatch, bidirectionality, connection-local Layer 2 overlay, browsers-are-not-peers rationale, streaming (native `call.responded`, no SSE), deferred `from_wss` adapter |
| [http-adapters.md](http-adapters.md) | draft | `from_openapi` (reqwest client) and `to_openapi` (OpenAPI projection); no-env-vars invariant point | | [http-adapters.md](http-adapters.md) | draft | `from_openapi` (reqwest client) and `to_openapi` (OpenAPI projection); no-env-vars invariant point |
| [http-mcp.md](http-mcp.md) | draft | `from_mcp` / `to_mcp` (feature-gated), streamable-HTTP-only, stdio exclusion | | [http-mcp.md](http-mcp.md) | draft | `from_mcp` / `to_mcp` (feature-gated), streamable-HTTP-only, stdio exclusion |
| [webtransport.md](webtransport.md) | deferred | `h3`/WebTransport handler — **deferred per ADR-044**; spec kept intact for revival | | [webtransport.md](webtransport.md) | deferred | `h3`/WebTransport handler — **deferred per ADR-044**; spec kept intact for revival |
@@ -51,6 +52,7 @@ protocol), and hosts the HTTP-backed call-protocol adapters
| [045](../../decisions/045-to-openapi-gateway-spec-versioning.md) | to_openapi Gateway-Spec Versioning | Published gateway doc carries `info.version` (semver) tracking the gateway endpoint contract, not the operation set; consumers detect breaking changes via the major version | | [045](../../decisions/045-to-openapi-gateway-spec-versioning.md) | to_openapi Gateway-Spec Versioning | Published gateway doc carries `info.version` (semver) tracking the gateway endpoint contract, not the operation set; consumers detect breaking changes via the major version |
| [046](../../decisions/046-assembly-layer-custom-http-routes.md) | Assembly-Layer Custom HTTP Routes on HttpAdapter | `extra_routes: Option<Router>` at construction; deployments add raw HTTP endpoints (e.g., OAI-compatible proxy, or a REST-like per-operation projection) that coexist with the default surface; default surface takes precedence on collision | | [046](../../decisions/046-assembly-layer-custom-http-routes.md) | Assembly-Layer Custom HTTP Routes on HttpAdapter | `extra_routes: Option<Router>` at construction; deployments add raw HTTP endpoints (e.g., OAI-compatible proxy, or a REST-like per-operation projection) that coexist with the default surface; default surface takes precedence on collision |
| [047](../../decisions/047-remove-direct-call-http-surface.md) | Remove the Direct-Call HTTP Surface; Gateway Is the Sole Invoke Path | `POST /{service}/{op}` direct-call surface removed; the 5 gateway endpoints are the sole invoke path; per-caller `AccessControl`-filtered `/search` is the discovery; ADR-036's non-routing clauses survive | | [047](../../decisions/047-remove-direct-call-http-surface.md) | Remove the Direct-Call HTTP Surface; Gateway Is the Sole Invoke Path | `POST /{service}/{op}` direct-call surface removed; the 5 gateway endpoints are the sole invoke path; per-caller `AccessControl`-filtered `/search` is the discovery; ADR-036's non-routing clauses survive |
| [048](../../decisions/048-websocket-native-session-not-gateway.md) | WebSocket Carries the Native Call-Protocol Session, Not the Gateway Shape | WS is the native `EventEnvelope` session; the gateway endpoints (`/search`/`/schema`/`/call`/`/batch`/`/subscribe`) are HTTP-only and do not appear on WS; discovery via `services/list`/`services/schema` as call-protocol ops |
## Relevant Open Questions ## Relevant Open Questions
@@ -107,16 +109,25 @@ protocol), and hosts the HTTP-backed call-protocol adapters
arbitrary executable = RCE. Streamable HTTP is network-isolated, arbitrary executable = RCE. Streamable HTTP is network-isolated,
auth-gatable, and runs under alknet's auth model. See auth-gatable, and runs under alknet's auth model. See
[ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md). [ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md).
6. **WebSocket is the browser bidirectional path.** A browser upgrades 6. **WebSocket is the browser bidirectional path, and it carries the
an HTTP/1.1 or HTTP/2 request to WebSocket and speaks the call native call-protocol session, not the gateway shape.** A browser
upgrades an HTTP/1.1 or HTTP/2 request to WebSocket and speaks the call
protocol over binary WS messages — full-duplex, both sides can protocol over binary WS messages — full-duplex, both sides can
initiate calls (the call protocol's native bidirectionality, ADR-012). initiate calls (the call protocol's native bidirectionality, ADR-012).
HTTP/3 + WebTransport (`h3`) is deferred per The `to_openapi` gateway endpoints (`/search`/`/schema`/`/call`/`/batch`/
`/subscribe`, ADR-042/047) are the HTTP one-directional projection and
**do not appear on the WebSocket path** — WS is the call protocol's
own native session, with discovery via `services/list`/`services/schema`
as ordinary call-protocol ops (ADR-048). HTTP/3 + WebTransport (`h3`)
is deferred per
[ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md) [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md)
— a scope decision (the browser bidirectional path doesn't require — a scope decision (the browser bidirectional path doesn't require
WebTransport's stream model; WebSocket suffices). The reversal WebTransport's stream model; WebSocket suffices). The reversal trigger
trigger is a concrete ALPN-stream-proxy use case (a browser running is a concrete ALPN-stream-proxy use case (a browser running a WASM
a WASM SSH/SFTP/git client). SSH/SFTP/git client). See [websocket.md](websocket.md) for the full
spec, including the deferred `from_wss` adapter (out of scope — a
future `from_call`-aligned importer over WS, not needed for the v1
browser-client case).
7. **Browsers are not alknet peers.** A browser over WebSocket (or, when 7. **Browsers are not alknet peers.** A browser over WebSocket (or, when
it revives, WebTransport) authenticates by bearer token, gets no it revives, WebTransport) authenticates by bearer token, gets no
`PeerId`, and its registered ops land in a connection-local Layer 2 `PeerId`, and its registered ops land in a connection-local Layer 2

View File

@@ -6,12 +6,13 @@ last_updated: 2026-06-30
# HTTP Server # HTTP Server
The `HttpAdapter` — the `ProtocolHandler` for `h2` and `http/1.1` (and The `HttpAdapter` — the `ProtocolHandler` for `h2` and `http/1.1` (and
WebSocket upgrade — see §"WebSocket browser path"). The `h3`/WebTransport WebSocket upgrade — see [websocket.md](websocket.md)). The `h3`/WebTransport
path is deferred per [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md); path is deferred per [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md);
the deferred spec is at [webtransport.md](webtransport.md). This document the deferred spec is at [webtransport.md](webtransport.md). This document
covers how axum is run over a QUIC bidirectional stream, Bearer auth covers how axum is run over a QUIC bidirectional stream, Bearer auth
resolution, the HTTP-to-call dispatch, the `/healthz` raw route, stealth resolution, the HTTP-to-call dispatch, the `/healthz` raw route, stealth
decoy, and the WebSocket browser path. decoy, and the WebSocket upgrade route (which hands off to the native
call-protocol session specified in [websocket.md](websocket.md)).
## What ## What
@@ -66,10 +67,10 @@ The endpoint's `HandlerRegistry` maps each ALPN byte string to the same
adapter instance; `handle()` branches on `connection.remote_alpn()` to adapter instance; `handle()` branches on `connection.remote_alpn()` to
pick the HTTP framing. For `http/1.1` and `h2`, the framing is hyper's pick the HTTP framing. For `http/1.1` and `h2`, the framing is hyper's
HTTP/1.1 or HTTP/2 over a QUIC bidirectional stream. WebSocket upgrade HTTP/1.1 or HTTP/2 over a QUIC bidirectional stream. WebSocket upgrade
(§"WebSocket browser path") layers on top of the same hyper connection (see [websocket.md](websocket.md)) layers on top of the same hyper
driver — a WS upgrade is an HTTP/1.1 or HTTP/2 request that switches connection driver — a WS upgrade is an HTTP/1.1 or HTTP/2 request that
protocols. The `h3` ALPN is deferred (ADR-044); the deferred handler switches protocols. The `h3` ALPN is deferred (ADR-044); the deferred
design is at [webtransport.md](webtransport.md). handler design is at [webtransport.md](webtransport.md).
## Why ## Why
@@ -198,7 +199,7 @@ The axum route handler:
to descendants per ADR-016. to descendants per ADR-016.
This is the HTTP/1.1 + HTTP/2 streaming projection. Over WebSocket This is the HTTP/1.1 + HTTP/2 streaming projection. Over WebSocket
(§"WebSocket browser path" below), the subscription projects directly ([websocket.md](websocket.md)), the subscription projects directly
onto the WS connection — `call.responded` events as binary WS messages, onto the WS connection — `call.responded` events as binary WS messages,
no SSE framing. WebTransport (`h3`, deferred per ADR-044) would project no SSE framing. WebTransport (`h3`, deferred per ADR-044) would project
onto WebTransport bidirectional streams; see onto WebTransport bidirectional streams; see
@@ -222,7 +223,7 @@ SSE response — but even there, the *call* is client-initiated; only the
This is a structural property of HTTP, not a design choice in this This is a structural property of HTTP, not a design choice in this
crate. **WebSocket restores the bidirectional call model for browsers** crate. **WebSocket restores the bidirectional call model for browsers**
(see §"WebSocket browser path" below): a WS connection is a long-lived (see [websocket.md](websocket.md)): a WS connection is a long-lived
full-duplex channel over which either side can send `call.requested` full-duplex channel over which either side can send `call.requested`
frames in either direction — the call protocol's native bidirectionality frames in either direction — the call protocol's native bidirectionality
applies unchanged (ADR-012 — stream-agnostic correlation; a WS message applies unchanged (ADR-012 — stream-agnostic correlation; a WS message
@@ -233,67 +234,27 @@ The HTTP/1.1 + HTTP/2 surface is the projection for clients that only
speak HTTP; WebSocket is the surface for browser clients that speak the speak HTTP; WebSocket is the surface for browser clients that speak the
call protocol in both directions. call protocol in both directions.
### WebSocket browser path (ADR-044) ### WebSocket browser path (ADR-044, ADR-048)
A browser connecting to a hub upgrades an HTTP/1.1 or HTTP/2 request to A browser (or any WS client) upgrades an HTTP/1.1 or HTTP/2 request to
WebSocket (RFC 6455). The resulting full-duplex WS connection carries WebSocket (RFC 6455); the resulting full-duplex WS connection carries the
call-protocol `EventEnvelope` frames as binary WebSocket messages — one call protocol's native `EventEnvelope` session over binary messages, and
envelope per message. The browser authenticates by bearer token on the is the surface that **restores the call protocol's native bidirectionality
upgrade request (the HTTP `Authorization` header), resolved by the hub's for browsers** (unlike the one-directional HTTP projection above). The WS
`IdentityProvider::resolve_from_token`, same as any HTTP request. The WS path carries the **native session, not the HTTP gateway shape** (ADR-048):
connection is then a **bidirectional call-protocol session**: the gateway endpoints are HTTP-only, discovery is via `services/list`/
`services/schema` as call-protocol ops, and subscriptions project as
native `call.responded` events (no SSE).
- The browser opens the WS connection to `/alknet/call` (or `/`). The full WS handler specification — the upgrade route, framing, dispatch
- The handler hands the WS message stream to the call protocol's handoff to the shared `Dispatcher`, bidirectionality, the connection-local
`Dispatcher` — the same dispatch loop the `CallAdapter` uses for Layer 2 overlay, the "browsers are not alknet peers" rationale
`alknet/call` QUIC connections (ADR-012, stream-agnostic correlation). (ADR-034 §4, amended by ADR-044 §5), the streaming projection, and the
- The browser writes `EventEnvelope` frames as binary WS messages; the deferred `from_wss` adapter — is at [websocket.md](websocket.md).
handler reads them and dispatches via `OperationRegistry::invoke()`. `h3`/WebTransport is deferred per ADR-044; the deferred handler design is
- Responses (`call.responded`, `call.error`, `call.completed`, at [webtransport.md](webtransport.md). When WebTransport revives, the two
`call.aborted`) are written back as binary WS messages. coexist: WS stays as the simpler call-protocol path; WebTransport adds the
ALPN-stream-proxy path (ADR-040). Neither replaces the other.
**Bidirectionality:** the WS call-protocol session inherits the call
protocol's native bidirectionality — both sides can initiate calls
(ADR-043 §2, transferred to WebSocket per ADR-044 §3). The browser calls
operations on the hub; the hub can call operations registered on the
browser's side, over the same session, using the same `PendingRequestMap`
and `EventEnvelope` framing as `alknet/call`. The browser case where the
client registers no operations of its own is the common case — the
server→client call direction is unused because the browser has nothing
to call. That is a use-case scoping, not an architectural limitation.
**No SSE translation.** A `Subscription` operation served over WebSocket
projects its `call.responded` stream directly as binary WS messages — no
SSE `data:` framing. `call.completed` closes the stream; `call.aborted`
closes it with an error frame. This is the native streaming projection
for the WS path; SSE (ADR-036) is the projection for `h2`/`http/1.1`
clients that don't upgrade to WebSocket.
**Browsers are not alknet peers.** A browser over WebSocket authenticates
by bearer token, gets no `PeerId`, does not enter `PeerCompositeEnv`, and
its registered ops (if any) land in a connection-local Layer 2 overlay —
the inbound mirror of ADR-034 §2. The rationale (addressability vs.
bidirectionality) is stated in ADR-044 §5 and amends ADR-034 §4 by
reference. In short: "peer" means an 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 thing but not the
first — it has no stable cryptographic identity of its own (it presents
a bearer token the hub issued; nothing to pin), it is ephemeral (close
the tab → connection dies → the connection-local overlay dies with it),
and it is not addressable from other nodes (another alknet node has no
way to reach "the browser currently connected to hub-A"; the hub holds
it as a live `CallConnection` handle, not a peer-graph entry). The
connection-local overlay is what gives the browser bidirectional-call
capability *without* peer-graph membership.
**What WebSocket does not provide (deferred to WebTransport, ADR-044):**
the ALPN-stream-proxy (ADR-040) — a browser running a WASM parser for
SSH/SFTP/git to reach a non-call ALPN — requires WebTransport's
multi-stream model and is the speculative use case whose deferral is
ADR-044's reversal trigger. WebSocket carries the call protocol from a
browser; it does not carry the non-call-ALPN substrate. A browser cannot
reach SSH/SFTP/git ALPNs in the v1 release. See ADR-044.
### Auth ### Auth
@@ -444,11 +405,14 @@ two-way door (add/remove freely). See
Capabilities are used for outbound calls (`from_openapi`), never Capabilities are used for outbound calls (`from_openapi`), never
serialized into HTTP response bodies. serialized into HTTP response bodies.
- **`/healthz` is raw.** No auth, no call protocol. The one raw route. - **`/healthz` is raw.** No auth, no call protocol. The one raw route.
- **WebSocket is the browser bidirectional path (ADR-044).** A browser - **WebSocket is the browser bidirectional path (ADR-044, ADR-048).** A browser
upgrades an HTTP request to WS and speaks the call protocol over binary upgrades an HTTP request to WS and speaks the call protocol over binary
messages. `h3`/WebTransport is deferred (ADR-044); the ALPN-stream-proxy messages — the **native `EventEnvelope` session, not the gateway shape**
(ADR-040) is not available in v1. The `h3` ALPN and its feature gate are (the gateway endpoints are HTTP-only; discovery via `services/list`/
not implemented in the initial release. `services/schema` as call-protocol ops). `h3`/WebTransport is deferred
(ADR-044); the ALPN-stream-proxy (ADR-040) is not available in v1. The
`h3` ALPN and its feature gate are not implemented in the initial release.
Full WS handler spec: [websocket.md](websocket.md).
- **Custom routes are raw HTTP, not call-protocol operations - **Custom routes are raw HTTP, not call-protocol operations
(ADR-046).** The assembly layer injects an `axum::Router` of extra (ADR-046).** The assembly layer injects an `axum::Router` of extra
routes at `HttpAdapter` construction. They are not in the routes at `HttpAdapter` construction. They are not in the
@@ -470,8 +434,8 @@ two-way door (add/remove freely). See
| `/healthz` is a raw route | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | No auth, no call protocol | | `/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 for non-gateway, non-custom, non-`/healthz` paths | | Stealth decoy | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | HTTP handler on standard ALPNs serves decoy for non-gateway, non-custom, non-`/healthz` paths |
| Bearer auth via `resolve_from_token` | [ADR-004](../../decisions/004-auth-as-shared-core.md) | HTTP handler credential source (settled) | | Bearer auth via `resolve_from_token` | [ADR-004](../../decisions/004-auth-as-shared-core.md) | HTTP handler credential source (settled) |
| WebSocket is the browser bidirectional path | [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md) | Browsers upgrade to WS; `EventEnvelope` over binary messages; `h3`/WebTransport deferred | | WebSocket is the browser bidirectional path | [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md), [ADR-048](../../decisions/048-websocket-native-session-not-gateway.md) | Browsers upgrade to WS; `EventEnvelope` over binary messages; `h3`/WebTransport deferred. WS carries the native call-protocol session, not the gateway shape (gateway endpoints are HTTP-only). Full spec: [websocket.md](websocket.md) |
| Browsers are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) §4 (amended by ADR-044 §5) | Bearer token, no `PeerId`, connection-local overlay (addressability vs. bidirectionality) | | Browsers are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) §4 (amended by ADR-044 §5) | Bearer token, no `PeerId`, connection-local overlay (addressability vs. bidirectionality) — full rationale in [websocket.md](websocket.md) |
| Error mapping (call codes → HTTP status) | [ADR-023](../../decisions/023-operation-error-schemas.md) | Protocol/operation codes distinct; `HTTP_<status>` prefix for imported | | Error mapping (call codes → HTTP status) | [ADR-023](../../decisions/023-operation-error-schemas.md) | Protocol/operation codes distinct; `HTTP_<status>` prefix for imported |
| Custom HTTP routes from the assembly layer | [ADR-046](../../decisions/046-assembly-layer-custom-http-routes.md) | `extra_routes: Option<Router>` at construction; raw HTTP, not operations; default surface takes precedence on collision | | Custom HTTP routes from the assembly layer | [ADR-046](../../decisions/046-assembly-layer-custom-http-routes.md) | `extra_routes: Option<Router>` at construction; raw HTTP, not operations; default surface takes precedence on collision |
@@ -499,6 +463,12 @@ See [open-questions.md](../../open-questions.md) for full details.
References the `@alkdev/pubsub` WebSocket prior art (the References the `@alkdev/pubsub` WebSocket prior art (the
`EventEnvelope { type, id, payload }` client/server the call `EventEnvelope { type, id, payload }` client/server the call
protocol's envelope was derived from). protocol's envelope was derived from).
- [ADR-048](../../decisions/048-websocket-native-session-not-gateway.md)
— WS carries the native `EventEnvelope` call-protocol session, not the
HTTP gateway shape; the gateway endpoints are HTTP-only.
- [websocket.md](websocket.md) — the full WS browser path spec (framing,
dispatch, bidirectionality, connection-local overlay, streaming
projection, the deferred `from_wss` adapter).
- [overview.md](overview.md) — crate overview, adapter location map - [overview.md](overview.md) — crate overview, adapter location map
- [webtransport.md](webtransport.md) — the deferred `h3` ALPN handler - [webtransport.md](webtransport.md) — the deferred `h3` ALPN handler
(kept intact for revival) (kept intact for revival)

View File

@@ -157,11 +157,15 @@ alknet-call (lean — no HTTP client, no HTTP server)
└── CallClient (outbound connection opener) └── CallClient (outbound connection opener)
alknet-http (owns HTTP server + HTTP client) alknet-http (owns HTTP server + HTTP client)
├── HttpAdapter (axum server — inbound HTTP on h2/http1.1 + WS upgrade) ├── HttpAdapter (axum server — inbound HTTP on h2/http1.1 + WS upgrade route)
├── [WS upgrade → native session] (hands the WS message stream to the shared Dispatcher —
│ not an adapter; see websocket.md, ADR-048)
├── from_openapi (parse OpenAPI doc + reqwest forwarding handler) ├── from_openapi (parse OpenAPI doc + reqwest forwarding handler)
├── to_openapi (generate OpenAPI doc from local registry) ├── to_openapi (generate OpenAPI doc from local registry)
├── from_mcp (feature-gated) (import remote MCP tools over streamable HTTP — reqwest) ├── from_mcp (feature-gated) (import remote MCP tools over streamable HTTP — reqwest)
── to_mcp (feature-gated) (expose local ops as MCP tools over streamable HTTP — axum) ── to_mcp (feature-gated) (expose local ops as MCP tools over streamable HTTP — axum)
└── from_wss (out of scope) (future: import a remote alknet node's ops over WS —
from_call-aligned, same-protocol; see websocket.md §"Future")
``` ```
`alknet-call` never sees the HTTP client. The `from_openapi`/`from_mcp` `alknet-call` never sees the HTTP client. The `from_openapi`/`from_mcp`
@@ -223,10 +227,16 @@ verified against this invariant. See ADR-014 and
## Architecture (component pointers) ## Architecture (component pointers)
- **[http-server.md](http-server.md)** — the `HttpAdapter` for `h2`/ - **[http-server.md](http-server.md)** — the `HttpAdapter` for `h2`/
`http/1.1` (+ WebSocket upgrade): how axum is run over a QUIC `http/1.1` (+ the WS upgrade route): how axum is run over a QUIC
bidirectional stream, Bearer auth resolution, the `/healthz` raw route, bidirectional stream, Bearer auth resolution, the `/healthz` raw route,
stealth decoy, the HTTP-to-call dispatch (ADR-036), and the WebSocket stealth decoy, the HTTP-to-call dispatch (ADR-036/042/047), and the
browser bidirectional path (ADR-044). WS upgrade route (which hands off to the native call-protocol session).
- **[websocket.md](websocket.md)** — the WebSocket browser bidirectional
path: native `EventEnvelope` call-protocol session (not the gateway
shape, ADR-048), framing, dispatch via the shared `Dispatcher`,
bidirectionality, connection-local Layer 2 overlay, the
browsers-are-not-peers rationale, streaming (native `call.responded`,
no SSE), and the deferred `from_wss` adapter.
- **[http-adapters.md](http-adapters.md)** — `from_openapi` (parse - **[http-adapters.md](http-adapters.md)** — `from_openapi` (parse
OpenAPI, build forwarding handlers with `reqwest`) and `to_openapi` OpenAPI, build forwarding handlers with `reqwest`) and `to_openapi`
(generate an OpenAPI doc from the registry's `External` operations). (generate an OpenAPI doc from the registry's `External` operations).
@@ -244,6 +254,7 @@ verified against this invariant. See ADR-014 and
| HTTP-to-call operation mapping | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | ~~Direct path mapping~~**routing superseded by ADR-047**; gateway `/call` is the sole invoke path; ADR-036's non-routing clauses survive (SSE, auth, `/healthz`, stealth, error mapping) | | HTTP-to-call operation mapping | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | ~~Direct path mapping~~**routing superseded by ADR-047**; gateway `/call` is the sole invoke path; ADR-036's non-routing clauses survive (SSE, auth, `/healthz`, stealth, error mapping) |
| MCP stdio transport exclusion | [ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md) | Streamable HTTP only; stdio is not built (RCE vector) | | MCP stdio transport exclusion | [ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md) | Streamable HTTP only; stdio is not built (RCE vector) |
| Defer h3/WebTransport; browsers use WebSocket | [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md) | `h3`/WebTransport deferred (scope, not hedging); browser bidirectional path uses WebSocket; ADR-038 superseded, ADR-040/043 parked | | Defer h3/WebTransport; browsers use WebSocket | [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md) | `h3`/WebTransport deferred (scope, not hedging); browser bidirectional path uses WebSocket; ADR-038 superseded, ADR-040/043 parked |
| WebSocket carries the native session, not the gateway shape | [ADR-048](../../decisions/048-websocket-native-session-not-gateway.md) | WS is the native `EventEnvelope` session; the gateway endpoints are HTTP-only; discovery via `services/list`/`services/schema` as call-protocol ops; subscriptions as native `call.responded` events (no SSE) |
| 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) | | 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) |
| ~~HTTP/3 + WebTransport first-class~~ | [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md) | **Superseded by ADR-044** (anti-pattern correction stands; specific decision reversed) | | ~~HTTP/3 + WebTransport first-class~~ | [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md) | **Superseded by ADR-044** (anti-pattern correction stands; specific decision reversed) |
| ~~WebTransport ALPN-stream-proxy~~ | [ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md) | **Parked** per ADR-044; revives unchanged when WebTransport revives | | ~~WebTransport ALPN-stream-proxy~~ | [ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md) | **Parked** per ADR-044; revives unchanged when WebTransport revives |

View File

@@ -0,0 +1,486 @@
---
status: draft
last_updated: 2026-06-30
---
# WebSocket — the Browser Bidirectional Path
WebSocket is the v1 browser bidirectional path to the call protocol.
`h3`/WebTransport is deferred per
[ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md);
the deferred handler design is at
[webtransport.md](webtransport.md). This document specifies the WebSocket
upgrade handler on `HttpAdapter`, the framing, the dispatch path, the
identity model, the streaming projection, and the explicit relationship to
the HTTP gateway surface.
A WebSocket connection is a **native `EventEnvelope` call-protocol session**,
not the HTTP gateway shape — the gateway endpoints (`/search`, `/schema`,
`/call`, `/batch`, `/subscribe`, ADR-042/047) are the HTTP one-directional
projection and **do not appear on the WebSocket path**. See
[ADR-048](../../decisions/048-websocket-native-session-not-gateway.md) for
the decision and its rationale.
## What
The WebSocket path is an axum WS upgrade handler on the same `HttpAdapter`
that serves `h2`/`http/1.1` (see [http-server.md](http-server.md)). A browser
(or any WS client — Node, a native app with a WS library) opens an HTTP/1.1
or HTTP/2 request to the upgrade path, authenticates by bearer token on the
upgrade request, and the resulting full-duplex WS connection carries
call-protocol `EventEnvelope` frames as binary WebSocket messages — one
envelope per message. The WS message stream is handed to the call protocol's
shared `Dispatcher`, which runs the same dispatch loop the `CallAdapter` uses
for `alknet/call` QUIC connections (ADR-012, stream-agnostic correlation).
## Why
WebSocket is the HTTP-family transport that restores the call protocol's
native bidirectionality for browsers — HTTP/1.1 + HTTP/2 are
request/response (a one-directional projection of the call protocol),
while WS is a full-duplex, long-lived, framed-message channel over which
the call protocol's native `EventEnvelope` session runs unchanged. The
decision to use WebSocket for the browser bidirectional path (deferring
`h3`/WebTransport) is [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md);
the decision that the WS path carries the native session rather than the
HTTP gateway shape is [ADR-048](../../decisions/048-websocket-native-session-not-gateway.md).
Both decisions' rationale is in those ADRs; this spec covers what the WS
path *is* and how an implementer builds it.
### Prior art: `@alkdev/pubsub`
The browser/Node WS client is mostly already written. The
`@alkdev/pubsub` package (`/workspace/@alkdev/pubsub/`) has a working
WebSocket client (`src/event-target-websocket-client.ts`) and server
(`src/event-target-websocket-server.ts`) built on an
`EventEnvelope { type, id, payload }` shape — the envelope the alknet call
protocol's `EventEnvelope` was derived from (refined with typed event
names `call.requested`/`call.responded`/etc. and structured payloads).
The sibling `@alkdev/operations` package (`/workspace/@alkdev/operations/`)
shares the lineage, with one mechanical delta: `path.do.op` (dot-separated)
vs alknet's `path/to/op` (slash-separated). Syncing the pubsub/operations
WS client to the alknet envelope is a small adjustment (envelope shape,
event-name typing, path separator), not a from-scratch build. See
[call-protocol.md](../call/call-protocol.md) §"Transport agnosticism" and
ADR-044 §"Concrete prior art".
### Illustrative deployment: `api.alk.dev` hub-spokes-browser
A concrete topology this path serves (the early-stage deployment motivating
the spec promotion): `api.alk.dev` runs as a hub. The vast majority of
spokes are Rust processes using `CallClient` to connect to the hub over QUIC
on ALPN `alknet/call` — e.g., a git runner, container services — and are
**peers** (stable `PeerId`, fingerprint-pinned, in `PeerCompositeEnv`,
addressable via `PeerRef::Specific` per ADR-029). A browser UI for the
early stages (while a desktop app is being fleshed out) connects via
WebSocket and is a **bidirectional call target during a live session, not
a peer-graph member** (bearer token, no `PeerId`, connection-local Layer 2
overlay, dies when the tab closes). The browser UI calls `services/list`
to populate its view with only the ops its bearer-token identity is
authorized to call, calls `services/schema` for shapes, and invokes via
`call.requested` — per-privilege filtering comes free from the call
protocol's `AccessControl::check(identity)`-filtered `services/list`. The
browser operator UI sees only what its privs allow; the Rust spokes are
full-addressable peers. This is the canonical ADR-044 §5 scenario.
## Architecture
### The WS upgrade handler
The WS upgrade is an HTTP/1.1 or HTTP/2 request handled by an axum route on
`HttpAdapter`'s router. The handler:
1. Receives the HTTP upgrade request (axum's `WebSocketUpgrade` extractor).
2. Resolves the caller's identity from the `Authorization: Bearer` header
via `identity_provider.resolve_from_token(&AuthToken { raw:
token_bytes })` (the `AuthToken` type is from
[../core/auth.md](../core/auth.md) — a wrapper around the raw bearer
token bytes)
— the same auth path as any HTTP request
([http-server.md](http-server.md) §"Auth"). The upgrade is rejected
(`401`) if no token is present; insufficient scopes for any op the
browser later calls surface as `403`/`FORBIDDEN` at call time, not at
upgrade time (the upgrade doesn't know which ops the browser will call).
3. Upgrades to WebSocket (axum's `WebSocketUpgrade::on_upgrade`), producing
a full-duplex `WebSocket` stream.
4. Wraps the `WebSocket` stream as a `BiStream`-satisfying transport — a WS
binary message in either direction is one `EventEnvelope` frame (see
§"Framing" below for the length-prefix decision).
5. Constructs a `Dispatcher` (the shared dispatch loop,
[../call/client-and-adapters.md](../call/client-and-adapters.md)
§"Shared Dispatcher") with the `Arc<OperationRegistry>` and
`Arc<dyn IdentityProvider>` the `HttpAdapter` holds, plus a
connection-local Layer 2 overlay for any ops the browser registers (see
§"Bidirectionality" below).
6. Spawns the dispatch task (`Dispatcher::run_loop`) on a tokio task; the
WS connection is live until either side closes it or the browser drops
the handle (closes the tab).
The upgrade path is a single axum route. The **default upgrade path is
`/alknet/call`** (the deployment may override it via the `extra_routes`
mechanism of ADR-046, but a deployment that passes no custom routes gets
`/alknet/call`). The path must not collide with the reserved
gateway/`/healthz`/`/openapi.json`/MCP/custom-route paths per ADR-046's
collision rule; `/alknet/call` namespaces away from the reserved set
naturally. The upgrade runs over HTTP/1.1 (the standard `Upgrade: websocket`
header, RFC 6455) or HTTP/2 (the extended CONNECT protocol, RFC 8441);
axum/hyper supports both, and the handler does not branch on which — the
WS frame stream is the same once the upgrade completes.
### Framing: `EventEnvelope` over binary WS messages
Every message on the WS connection is a binary WebSocket message containing
one `EventEnvelope`:
```rust
pub struct EventEnvelope {
pub r#type: String, // "call.requested" | "call.responded" | "call.completed" | "call.aborted" | "call.error"
pub id: String, // Correlation key (request ID, subscription ID)
pub payload: Value, // serde_json::Value — schema depends on event type
}
```
This is the call protocol's wire format verbatim (see
[../call/call-protocol.md](../call/call-protocol.md) §"Wire Format:
EventEnvelope"). The `@alkdev/pubsub` envelope (`{ type, id, payload }`) is
the shape the alknet `EventEnvelope` was derived from; the delta is typed
event names (`call.requested` etc.) and structured payloads, both of which
are already in the alknet envelope. The five event types
(`call.requested`, `call.responded`, `call.completed`, `call.aborted`,
`call.error`) carry request/response and subscription semantics exactly as
over QUIC — see [../call/call-protocol.md](../call/call-protocol.md)
§"Event Types" for the full table.
**Length-prefix decision.** The QUIC path frames `EventEnvelope` as a
4-byte big-endian length prefix + UTF-8 JSON body (see
[../call/call-protocol.md](../call/call-protocol.md) §"Wire Format"),
because a QUIC bidirectional stream is an unbounded byte stream that needs
an explicit delimiter. A WebSocket binary message is already
length-delimited by the WS frame boundary — the receiver gets one complete
message per read, no partial reads across message boundaries (ADR-044
Assumption 1, verified by the `@alkdev/pubsub` prior art). **The WS path
therefore carries no length prefix**: one `EventEnvelope` JSON object = one
binary WS message, and the WS message boundary is the delimiter. The
implementation must not prepend the QUIC length prefix on outbound WS
messages or expect it on inbound ones — the two framings are deliberately
different, matching each transport's native boundary semantics. (The
`FrameFramedReader`/`FrameFramedWriter` types the QUIC dispatch loop uses
are replaced on the WS path by direct JSON serde over the WS message type;
the `Dispatcher` itself is transport-agnostic and consumes `EventEnvelope`
values, not raw bytes — see [../call/client-and-adapters.md](../call/client-and-adapters.md)
§"Shared Dispatcher".)
Binary payloads within `EventEnvelope.payload` follow the same base64-as-
JSON-string convention the QUIC path uses
([../call/call-protocol.md](../call/call-protocol.md) §"Wire Format") —
the envelope carries `serde_json::Value` and does not interpret binary
fields; that's a handler-level concern, transport-agnostic.
Text WS messages are not used; all call-protocol frames are binary. A client
that sends a text message gets a protocol-level close (the WS handler
validates message type).
### Dispatch: the shared `Dispatcher`, unchanged
The WS message stream is handed to the `Dispatcher` — the same dispatch loop
the `CallAdapter` uses for `alknet/call` QUIC connections (ADR-017 §1; see
[../call/client-and-adapters.md](../call/client-and-adapters.md)
§"Shared Dispatcher"). The dispatch half is one implementation; the
connection-establishment half differs (WS upgrade handler vs QUIC
accept/dial), but after establishment the `Dispatcher` runs identically:
- Reads `EventEnvelope` frames from the WS message stream.
- For `call.requested`: resolves the peer's identity (the bearer-token
identity resolved at upgrade time, stored on the connection),
runs `AccessControl::check(identity)` against the op's `AccessControl`,
dispatches via `OperationRegistry::invoke()` if allowed, returns
`FORBIDDEN` (→ `call.error`) before the handler runs if not.
- For `call.responded`/`call.completed`/`call.aborted`: correlates by `id`
via `PendingRequestMap` (keyed by request ID, not by transport — ADR-012).
- Writes response `EventEnvelope` frames back as binary WS messages.
Peer authorization flows through the existing `AccessControl::check` against
the resolved identity — no `RemoteFilter`, no `remote_safe` gate (retired by
ADR-029 §3). An op with `AccessControl::default()` is callable by any
authenticated browser; an op with `required_scopes` is callable only by
browsers whose `Identity.scopes` satisfy them; an op with
`Visibility::Internal` is never callable from the wire (`NOT_FOUND` before
ACL). See [../call/client-and-adapters.md](../call/client-and-adapters.md)
§"CallClient" for the full mapping of the three `remote_safe` cases to
`AccessControl`/`Visibility`.
### Bidirectionality (ADR-043 §2 transferred to WebSocket per ADR-044 §3)
The WS call-protocol session inherits the call protocol's native
bidirectionality: both sides can send `call.requested` frames. The browser
calls operations on the hub; the hub can call operations registered on the
browser's side, over the same session, using the same `PendingRequestMap`
and `EventEnvelope` framing as `alknet/call`.
The browser case where the client registers no operations of its own is the
common case — 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 browser that *does* expose ops (e.g., a UI that registers a
`ui/dragged` op the hub can call to push live updates) registers them in the
connection-local Layer 2 overlay (see §"Connection-local overlay" below),
and the hub reaches them through the live `CallConnection` handle — not
through `PeerRef::Specific` (the browser is not a peer; see §"Browsers are
not alknet peers").
### Connection-local overlay (ADR-043 §3 transferred; ADR-024)
A browser over WebSocket has no `PeerId` on the hub's side. Any ops the
browser registers land in a **connection-local Layer 2 overlay**
(ADR-024) — a per-`CallConnection` overlay that dies when the connection
drops. This is the same mechanism ADR-034 §2 describes for the inbound
browser case: the browser is a bidirectional call target during a live
session, not a peer-graph member, and the connection-local overlay is what
gives it bidirectional-call capability *without* peer-graph membership.
When the WS connection closes (browser closes the tab, network drops), the
overlay and all its registered ops are dropped — no explicit deregistration
needed. A `PeerRef::Specific("browser-X")` from another node would route to
nothing, because there is no `PeerEntry` for the browser (see §"Browsers
are not alknet peers" below for why).
### Streaming: native `call.responded` events, no SSE
A `Subscription` operation invoked over WS streams `call.responded` events
as binary WS messages directly — **no SSE `data:` framing**. SSE is the
`h2`/`http/1.1` streaming projection (ADR-036 §Streaming, applied at the
gateway's `/subscribe` endpoint per ADR-042 §2); on WS it is unnecessary
because WS is already a framed full-duplex channel. The browser receives
`call.responded` events one per WS binary message, with the same `id`
correlating them to the original `call.requested`; `call.completed` closes
the subscription (no more events); `call.aborted` closes it with an error
frame. This mirrors how subscriptions work on the QUIC path — see
[../call/call-protocol.md](../call/call-protocol.md) §"Streaming subscribe
example".
On WS client disconnect (the browser closes the tab mid-subscription),
the WS handler detects the stream close and sends `call.aborted` for the
in-flight subscription, which cascades to descendants per ADR-016.
### Browsers are not alknet peers (ADR-034 §4, amended by ADR-044 §5)
A browser over WebSocket authenticates by bearer token, gets no `PeerId`,
does not enter `PeerCompositeEnv`, and its registered ops (if any) land in
the connection-local Layer 2 overlay (above). The rationale, stated in
ADR-044 §5 and amending ADR-034 §4 by reference, is a load-bearing
distinction:
**"Peer" in alknet means an addressable node in the call-protocol peer
graph** — a stable `PeerId`, reachable via `PeerRef::Specific`, whose ops
land in `PeerCompositeEnv`, whose identity is stable across reconnects. It
does *not* mean "any endpoint that exchanges calls during a live session."
A browser is the second thing but not the first, on three concrete grounds:
1. **No stable cryptographic identity of its own.** A `PeerEntry` is anchored
to fingerprints (Ed25519, X.509) that *the peer* presents and the local
node pins. A browser presents a bearer token the *hub* issued; the
"identity" is the hub's bookkeeping for that token, not something the
browser owns or that could be pinned by another node. There is nothing
to put in `PeerEntry.fingerprints`.
2. **Ephemeral.** Close the tab → connection dies → the connection-local
Layer 2 overlay dies with it. A `PeerEntry` keyed to a browser would be a
permanently-dead entry within seconds. `PeerRef::Specific("browser-X")`
from another node would route to nothing.
3. **Not addressable from other nodes.** `PeerRef::Specific` resolves
through `PeerEntry``PeerId`. Another alknet node has no way to reach
"the browser currently connected to hub-A"; the hub holds that
connection as a live `CallConnection` handle, not as a peer-graph entry.
The connection-local overlay is precisely the mechanism that gives the
browser bidirectional-call capability *without* peer-graph membership.
This is the explicit closure of the "browser as peer" path, on both the
inbound (this section) and outbound (ADR-034 §2) sides. The browser is a
**bidirectional call target during a live session**, not a **peer-graph
member**. The connection-local Layer 2 overlay (ADR-024, ADR-043 §3) is what
makes the former possible without requiring the latter. This rationale
applies transport-agnostically — to WebSocket, to WebTransport when it
revives, and to any future browser transport.
### Auth: bearer token on the upgrade request
Inbound WS auth is `Authorization: Bearer <token>` on the HTTP upgrade
request, resolved via `IdentityProvider::resolve_from_token()` — the same
path as any HTTP request ([http-server.md](http-server.md) §"Auth").
Bearer-only is the auth mechanism; other HTTP auth schemes are not
implemented for the WS upgrade (a deployment needing a different scheme
adds it as axum middleware on the upgrade route, two-way door). The
resolved identity is stored on the `Connection` for observability
(`connection.set_identity(identity)`), same as the HTTP handler.
The bearer token's `Identity` is what `AccessControl::check` runs against
when the browser calls an op via `call.requested` — the browser's
privileges are the token's privileges. This is the mechanism that gives the
browser per-privilege filtering for free: `services/list` is
`AccessControl::check(identity)`-filtered, so the browser's discovery
calls return only the ops its token is authorized to call. The
`api.alk.dev` operator UI sees only operator-authorized ops; a less-
privileged browser session sees a subset.
### What WebSocket does not provide (deferred to WebTransport, ADR-044)
WebSocket carries the call protocol from a browser; it does not carry the
non-call-ALPN substrate. The ALPN-stream-proxy (ADR-040) — a browser running
a WASM parser for SSH/SFTP/git to reach a non-call ALPN — requires
WebTransport's multi-stream model and is the speculative use case whose
deferral is ADR-044's reversal trigger. A browser cannot reach
SSH/SFTP/git ALPNs over WS in the v1 release; it can reach the call
protocol, and through the call protocol any ops the hub exposes. When
WebTransport revives, the two coexist: WSS stays as the simpler
call-protocol path; WebTransport adds the ALPN-stream-proxy path. Neither
replaces the other. See [webtransport.md](webtransport.md) for the deferred
design.
## Constraints
- **The WS path is the native `EventEnvelope` session, not the gateway
shape (ADR-048).** The `to_openapi` gateway endpoints
(`/search`/`/schema`/`/call`/`/batch`/`/subscribe`) are the HTTP
one-directional projection and do not appear on WS. Discovery is via
`services/list` and `services/schema` as ordinary call-protocol ops,
not WS-specific endpoints. Subscriptions project as native
`call.responded` events, not SSE.
- **Bearer-only auth on the upgrade request.** `Authorization: Bearer`
`resolve_from_token`. The resolved identity drives `AccessControl::check`
on every `call.requested` the browser sends; per-privilege filtering is
free via `services/list`'s existing `AccessControl` filtering.
- **Browsers are not alknet peers (ADR-034 §4, amended by ADR-044 §5).**
Bearer token, no `PeerId`, no `PeerCompositeEnv` entry, connection-local
Layer 2 overlay for any browser-registered ops. "Peer" means addressable
peer-graph node, not "any endpoint that exchanges calls during a live
session."
- **`EventEnvelope` frames are binary WS messages; one envelope per message,
no length prefix (ADR-044 Assumption 1).** The WS message boundary is the
delimiter — the QUIC path's 4-byte length prefix is not carried on the WS
path (a WebSocket binary message is already length-delimited by the WS
frame boundary). Text messages are rejected. The property is verified by
the `@alkdev/pubsub` prior art.
- **WebSocket is the v1 browser bidirectional path; `h3`/WebTransport is
deferred (ADR-044).** The ALPN-stream-proxy (ADR-040) is not available
in v1. The `h3` ALPN and its feature gate are not implemented in the
initial release; the WS path uses native axum WS support, no new
dependency.
- **WS upgrade over HTTP/1.1 (`Upgrade: websocket`, RFC 6455) or HTTP/2
(extended CONNECT, RFC 8441) is supported by the axum/hyper stack
natively (ADR-044 Assumption 2).** `axum::extract::ws` provides the
upgrade handler; the underlying connection is the same hyper HTTP
connection the `h2`/`http/1.1` handler already drives. The handler does
not branch on which HTTP version upgraded — the WS frame stream is the
same once the upgrade completes. No new framing library is needed.
- **The shared `Dispatcher` runs over the WS message stream unchanged
(ADR-012).** A WS message stream is another `BiStream`-satisfying
transport; the `Dispatcher` and `PendingRequestMap` are transport-
agnostic. Only the connection-establishment half differs (WS upgrade
vs QUIC accept/dial).
- **The default WS upgrade path is `/alknet/call`; it must not collide
with reserved paths (ADR-046).** The gateway endpoints, `/healthz`,
`/openapi.json`, the MCP route, and any custom routes take precedence;
`/alknet/call` namespaces away from the reserved set naturally. A
deployment may override the path via the `extra_routes` mechanism
(ADR-046).
## Future: `from_wss` adapter (out of scope, named for discoverability)
A `from_wss` adapter — importing a remote alknet node's operations over a
WebSocket connection, mirroring `from_call`'s pattern with WS as the
transport — is **out of scope for the current `alknet-http` work** and is
recorded here so it is not re-derived later. It is architecturally closer
to `from_call` than to `from_openapi`: `from_openapi` imports a
*foreign-protocol* surface (OpenAPI) and translates; `from_call` imports a
*same-protocol* endpoint over a different transport (QUIC) with no
translation. `from_wss` is the latter pattern with WS as the transport
instead of QUIC — open a WS connection, run `services/list` +
`services/schema` over it, register forwarding handlers that forward
`call.requested` over the WS connection. Because the `Dispatcher` is
stream-agnostic (ADR-012), it is `from_call` with a different
`BiStream`-satisfying transport.
Why it is out of scope now: it is a genuinely separate use case from the
browser-client case (it is "import a remote alknet node's ops over WSS,"
not "be a browser client"), and the consumers are not yet concrete enough
to commit the adapter's exact shape. The decision is made when a concrete
consumer arrives — a node that wants to import another node's ops but can
only reach it over WS (e.g., a browser-mediated relay, a restrictive-
network deployment). It is not needed for the v1 `api.alk.dev` topology
(Rust spokes use `from_call` over QUIC; the browser is a client, not an
importer). When revived, `from_wss` implements the `OperationAdapter` trait
(ADR-017 §5) and lives in `alknet-http` alongside `from_openapi`/`from_mcp`,
reusing the `Dispatcher` and the WS framing specified in this document.
This is a genuine scope decision (per ADR-009 §"What this framework is
NOT" — a decision that "genuinely doesn't need to be made yet because the
use case isn't concrete"), not a two-way-door deferral. The reversal
trigger is a concrete deployment needing to import a remote alknet node's
ops over WSS.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Defer `h3`/WebTransport; browsers use WebSocket | [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md) | WS is the v1 browser bidirectional path; `h3`/WebTransport deferred (scope); reversal trigger = concrete ALPN-stream-proxy use case |
| WS carries the native `EventEnvelope` session, not the gateway shape | [ADR-048](../../decisions/048-websocket-native-session-not-gateway.md) | The gateway endpoints are HTTP-only; WS is the call protocol's native session; discovery via `services/list`/`services/schema` as call-protocol ops; subscriptions as native `call.responded` events, no SSE |
| Call protocol stream model (stream-agnostic correlation) | [ADR-012](../../decisions/012-call-protocol-stream-model.md) | A WS message stream is another `BiStream`-satisfying transport; the `Dispatcher` and `PendingRequestMap` run unchanged |
| Call protocol client and adapter contract (`Dispatcher` shared) | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) §1 | The WS handler constructs a `Dispatcher` and calls `run_loop`, same as `CallAdapter`/`CallClient` — the dispatch half is one implementation |
| Operation registry layering (Layer 2 connection-local overlay) | [ADR-024](../../decisions/024-operation-registry-layering.md) | Browser-registered ops (if any) land in a connection-local overlay that dies with the WS connection |
| Bidirectionality transferred from WebTransport to WebSocket | [ADR-043](../../decisions/043-webtransport-bidirectional-alpn-substrate.md) §2 (parked; §2/§3 transfer per ADR-044 §3) | Both sides can `call.requested`; the browser case where the client registers no ops is a use-case scoping, not an architectural limitation |
| No-`PeerId` connection-local overlay transferred from WebTransport | [ADR-043](../../decisions/043-webtransport-bidirectional-alpn-substrate.md) §3 (parked; transfers per ADR-044 §3) | A browser over WS has no `PeerId`; ops land in the connection-local overlay |
| Browsers are not alknet peers (rationale: addressability vs bidirectionality) | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) §4 (amended by ADR-044 §5) | Bearer token, no `PeerId`, no `PeerCompositeEnv` entry; "peer" means addressable peer-graph node, not "any endpoint that exchanges calls during a live session" |
| Peer-graph routing model (peer authorization via `AccessControl`) | [ADR-029](../../decisions/029-peer-graph-routing-model.md) §3 | `AccessControl::check(identity)` gates every `call.requested` from the browser; no `remote_safe`/`trusted_peer` (retired) |
| Abort cascade on WS disconnect | [ADR-016](../../decisions/016-abort-cascade-for-nested-calls.md) | WS close mid-subscription sends `call.aborted`, cascading to descendants |
| Bearer auth via `resolve_from_token` | [ADR-004](../../decisions/004-auth-as-shared-core.md) | WS upgrade request credential source (same as HTTP) |
| Browsers require X.509 (TLS) | [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | The WS upgrade runs over the same `h2`/`http/1.1` TLS as HTTP; browsers don't support RFC 7250 raw keys |
| Stealth: HTTP handler on standard ALPNs serves WS upgrade | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | The WS upgrade route is on `HttpAdapter`'s default surface; a port scanner sees the decoy for unknown paths |
| Custom routes collision rule | [ADR-046](../../decisions/046-assembly-layer-custom-http-routes.md) | The WS upgrade route must not collide with reserved default-surface paths; it namespaces away naturally |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-38** (open, scope): WebTransport standalone relay service scope —
the standalone relay (future `alknet-relay`, fork of iroh-relay) is
distinct from the in-process ALPN-stream-proxy (ADR-040, parked) and
from the WebSocket browser path (this spec). The relay is a separate
service for NAT traversal, not a mode of the WS handler; it does not
affect the WS path.
No new open questions. The `from_wss` deferral (§"Future") is a scope
decision stated explicitly, not an open question — the reversal trigger is
concrete (a deployment needing to import a remote node's ops over WSS),
and there is no architectural question to resolve before then.
## References
- [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md)
— the ADR that committed WS as the v1 browser bidirectional path;
§"Concrete prior art" references the `@alkdev/pubsub` WS client/server;
§5 states the "browser is not a peer" rationale this spec carries.
- [ADR-048](../../decisions/048-websocket-native-session-not-gateway.md)
— the ADR that commits the WS path as the native `EventEnvelope` session,
not the HTTP gateway shape.
- [http-server.md](http-server.md) — the `HttpAdapter` that hosts the WS
upgrade handler; the one-directional HTTP projection (the gateway) the
WS path is contrasted against.
- [webtransport.md](webtransport.md) — the deferred `h3`/WebTransport
handler; kept intact for revival. When WebTransport revives, the two
coexist (WSS for the call protocol; WebTransport for the ALPN-stream-
proxy).
- [../call/call-protocol.md](../call/call-protocol.md) — `EventEnvelope`
wire format, the `Dispatcher`, the `PendingRequestMap`, stream model,
bidirectional calls, §"Transport agnosticism" (the pubsub-lineage note).
- [../call/client-and-adapters.md](../call/client-and-adapters.md) — the
shared `Dispatcher` (§"Shared Dispatcher"), `services/list` filtering,
the `CallClient` (the QUIC-side counterpart to the WS browser client).
- [../call/operation-registry.md](../call/operation-registry.md) —
`OperationRegistry::invoke()`, Layer 2 connection-local overlay.
- `/workspace/@alkdev/pubsub/src/event-target-websocket-client.ts`,
`/workspace/@alkdev/pubsub/src/event-target-websocket-server.ts`
TypeScript prior art: the `EventEnvelope { type, id, payload }` over WS
binary messages. The alknet `EventEnvelope` is a refined superset (typed
event names, structured payloads); the delta is small and well-defined.
- `/workspace/@alkdev/operations/src/` — TypeScript sibling package sharing
the pubsub lineage; the `path.do.op` (dot-separated) convention vs
alknet's `path/to/op` (slash-separated) is the minor mechanical delta a
sync adjusts.

View File

@@ -9,7 +9,7 @@ last_updated: 2026-06-30
> This spec is kept intact for revival. `h3`/WebTransport is not > This spec is kept intact for revival. `h3`/WebTransport is not
> implemented in the initial `alknet-http` release; the browser > implemented in the initial `alknet-http` release; the browser
> bidirectional path uses WebSocket (see > bidirectional path uses WebSocket (see
> [http-server.md](http-server.md) §"WebSocket browser path"). ADR-038 > [websocket.md](websocket.md)). ADR-038
> is superseded; ADR-040 and ADR-043 are parked (their decisions revive > is superseded; ADR-040 and ADR-043 are parked (their decisions revive
> unchanged when WebTransport revives). The reversal trigger is a > unchanged when WebTransport revives). The reversal trigger is a
> concrete deployment needing the ALPN-stream-proxy (a browser running > concrete deployment needing the ALPN-stream-proxy (a browser running

View File

@@ -219,7 +219,8 @@ require it for the common case.
browser bidirectional path, ADR-044), subscriptions project onto the browser bidirectional path, ADR-044), subscriptions project onto the
WS connection directly as binary messages — the gateway's `/subscribe` WS connection directly as binary messages — the gateway's `/subscribe`
is the `h2`/`http/1.1` SSE path; the WebSocket path is the native is the `h2`/`http/1.1` SSE path; the WebSocket path is the native
call-protocol session (`http-server.md` §"WebSocket browser path"). call-protocol session (`websocket.md`; the gateway shape does not
appear on WS per [ADR-048](048-websocket-native-session-not-gateway.md)).
WebTransport (`h3`, deferred per ADR-044) would project onto WebTransport (`h3`, deferred per ADR-044) would project onto
WebTransport streams; the deferred design is at WebTransport streams; the deferred design is at
`webtransport.md`. `webtransport.md`.

View File

@@ -0,0 +1,288 @@
# ADR-048: WebSocket Carries the Native Call-Protocol Session, Not the Gateway Shape
## Status
Accepted
## Context
ADR-044 (Accepted) deferred `h3`/WebTransport and committed WebSocket as the v1
browser bidirectional path: a browser upgrades an HTTP/1.1 or HTTP/2 request
to WebSocket and the resulting full-duplex WS connection carries call-protocol
`EventEnvelope` frames as binary WS messages. ADR-044 §1 established that the
call protocol's `call.requested`/`call.responded`/`call.completed`/`call.aborted`
exchange "works over WebSocket with no protocol change — the same `Dispatcher`,
the same `PendingRequestMap`, the same correlation by request ID."
ADR-044 §1 also established *what shape the WS session carries* — the native
`EventEnvelope` call-protocol session — but it does so as part of a larger
argument about why WebTransport isn't required, not as a crisp rule an
implementer is told not to violate. Two facts make the distinction worth its
own explicit decision record:
1. **The HTTP surface has a deliberate, well-documented invoke contract: the
`to_openapi` gateway pattern** (ADR-042, ADR-047). The gateway is 5 fixed
endpoints (`/search`, `/schema`, `/call`, `/batch`, `/subscribe`), where
`/call` takes `{ "operation": "/fs/readFile", "input": {...} }` and invokes
through `OperationRegistry::invoke()`. It is a well-shaped, simple contract.
An implementer writing the WS handler could plausibly ask: "should the WS
path expose the same 5-endpoint gateway shape, so a WS client looks like an
HTTP client?" That is a reasonable question, and the answer is no — but the
answer is currently implicit in ADR-044's framing, not stated as a rule.
2. **The two surfaces serve different architectural roles, and that difference
is load-bearing.** The HTTP gateway is, by HTTP's nature, a *one-directional
projection* — client initiates, server responds (see
[http-server.md](../crates/http/http-server.md) §"One-directional
projection"). The whole reason WebSocket exists in this architecture is to
*restore the call protocol's native bidirectionality for browsers* (ADR-044
§4): a WS connection is full-duplex, so both sides can initiate
`call.requested` frames. Putting the gateway's one-directional shape on WS
re-introduces the one-directional limitation that WS exists to fix. The two
surfaces are not interchangeable; they are deliberately different tools for
deliberately different jobs.
### The two invoke contracts, contrasted
| Aspect | HTTP gateway (`/call`, ADR-042/047) | WS native session (this ADR) |
|---------|--------------------------------------|------------------------------|
| Direction | One-directional (client→server calls only) | Bidirectional (either side can `call.requested`) |
| Wire unit | HTTP request/response | One `EventEnvelope` = one binary WS message |
| Invoke shape | `POST /call` with `{ "operation": "/fs/readFile", "input": {...} }` | `call.requested` event with `{ operation, input }` payload (the call protocol's native shape) |
| Discovery | `GET /search` (gateway endpoint) | `services/list` as an ordinary call-protocol op |
| Schema | `GET /schema` (gateway endpoint) | `services/schema` as an ordinary call-protocol op |
| Streaming | `GET /subscribe` (SSE frames) | `call.responded` events as binary WS messages (no SSE) |
| Dispatcher | axum route handler → `OperationRegistry::invoke()` | shared `Dispatcher` (ADR-012, stream-agnostic) |
| Multiplexing | HTTP/2 native; HTTP/1.1 sequential | By request ID (ADR-012), not by stream |
The WS row is the call protocol's own native session, with WebSocket as the
transport instead of QUIC. The HTTP row is a projection of that session into
HTTP's one-directional shape, with the gateway as the deliberate interface for
clients that only speak HTTP.
### Why the gateway shape is wrong on WS
Three concrete reasons:
1. **It duplicates the native invoke path with a lossier one.** The call
protocol's `call.requested` event *is* the invoke primitive; the gateway's
`/call` is that primitive wrapped in an HTTP envelope. On WS, the envelope
is unnecessary — there is no HTTP request/response cycle to fit into. A
gateway-on-WS would be a translation layer translating the call protocol to
itself, losing bidirectionality in the process.
2. **It loses the per-caller filtering property the native session already
has.** The gateway's `/search` exists to give HTTP clients the
`AccessControl::check(identity)`-filtered discovery that the call protocol
provides natively via `services/list`. On WS, `services/list` is already a
call-protocol op the browser can call directly — the filtering is already
there. A gateway-on-WS re-implements a filtering property the native session
already provides.
3. **It breaks the symmetry with the QUIC path.** The `alknet/call` QUIC path
(ADR-012, ADR-017) is the native `EventEnvelope` session over QUIC
bidirectional streams. WS is the same session over WS messages. Making the
WS path different from the QUIC path (by putting the gateway on WS) creates
two browser-reachable invoke contracts for no architectural reason — the
QUIC path is the reference, and WS should mirror it, not diverge from it.
### The prior art is already native-session-shaped
The `@alkdev/pubsub` WebSocket client/server (`event-target-websocket-client.ts`,
`event-target-websocket-server.ts`) — the working prior art ADR-044 cites as
the reason the WS path is cheap — already carries the `EventEnvelope { type, id,
payload }` shape over WS binary messages, with no gateway-style wrapping. The
alknet call protocol's `EventEnvelope` was derived from the pubsub envelope
(refined with typed event names and structured payloads); the delta is small
and well-defined (see [call-protocol.md](../crates/call/call-protocol.md)
§"Transport agnosticism"). A browser/Node WS client derived from the pubsub
prior art speaks the native session shape, not a gateway shape. The
gateway-on-WS variant would require *un-translating* the pubsub client's
native-session shape into the gateway's `{ operation, input }` shape — work
that has no payoff because the native shape is what both the QUIC path and the
pubsub prior art use.
## Decision
### 1. A WebSocket connection is a native `EventEnvelope` call-protocol session, not the HTTP gateway shape
The WS handler on `HttpAdapter` hands the WS message stream to the call
protocol's shared `Dispatcher` — the same dispatch loop the `CallAdapter` uses
for `alknet/call` QUIC connections (ADR-012, stream-agnostic correlation; a
WS message stream is another `BiStream`-satisfying transport). The browser
writes `EventEnvelope` frames as binary WS messages; the handler reads them
and dispatches via `OperationRegistry::invoke()`. Responses
(`call.responded`, `call.error`, `call.completed`, `call.aborted`) are written
back as binary WS messages.
The `to_openapi` gateway endpoints (`/search`, `/schema`, `/call`, `/batch`,
`/subscribe` — ADR-042, ADR-047) **do not appear on the WebSocket path**. They
are the HTTP one-directional projection's invoke contract; WS carries the
call protocol's own native session, which is a different (and richer) thing.
### 2. Discovery and schema are call-protocol ops, not WS-specific endpoints
The browser calls `services/list` and `services/schema` as ordinary
`call.requested` events over the WS connection. They are call-protocol
operations, not WS endpoints. There is no `/search` or `/schema` on WS — those
are the HTTP gateway's names for the same discovery primitives. The
filtering the gateway provides via `AccessControl::check(identity)`-filtered
`/search` (ADR-042 §3) is provided on the WS path by the same mechanism the
call protocol uses everywhere: `services/list` is `AccessControl`-filtered
natively (see [client-and-adapters.md](../crates/call/client-and-adapters.md)
§"services/list"). No WS-specific discovery surface exists or is needed.
### 3. Subscriptions project as native `call.responded` events, not SSE
A `Subscription` operation invoked over WS streams `call.responded` events as
binary WS messages directly — no SSE `data:` framing (that is the
`h2`/`http/1.1` projection for `/subscribe`, ADR-036 §Streaming; on WS it is
unnecessary because WS is already a framed full-duplex channel). `call.completed`
closes the stream; `call.aborted` closes it with an error frame. This is the
native streaming projection for the WS path, mirroring how subscriptions work
on the QUIC path.
### 4. Bidirectionality is native and unchanged from the QUIC path
The WS call-protocol session inherits the call protocol's native
bidirectionality (ADR-043 §2, transferred to WebSocket per ADR-044 §3): both
sides can send `call.requested` frames. The browser calls operations on the
hub; the hub can call operations registered on the browser's side, over the
same session, using the same `PendingRequestMap` and `EventEnvelope` framing
as `alknet/call`. The browser case where the client registers no operations of
its own is the common case — the server→client call direction is unused
because the browser has nothing to call. That is a use-case scoping, not an
architectural limitation.
### 5. This is a clarifying decision, not a new one
ADR-044 §1 commits the native-session shape ("the call protocol's
`EventEnvelope` framing fits a WebSocket binary message boundary cleanly ...
the same `Dispatcher`, the same `PendingRequestMap`, the same correlation by
request ID"). This ADR does not change that decision; it makes the
implication an explicit, implementer-visible rule: **the WS path is the
native session, and the gateway shape is deliberately not applied to it.** An
implementer reading ADR-044 alone could plausibly ask "should the WS path
expose the gateway endpoints too?" — this ADR's job is to make the answer
discoverable as a decision record, not implicit in framing.
## Consequences
**Positive:**
- One invoke model for the call protocol, regardless of transport. The QUIC
path and the WS path run the same `EventEnvelope` session through the same
`Dispatcher`; the HTTP gateway is the one-directional projection for clients
that only speak HTTP. An implementer building the WS handler reuses the
`Dispatcher` and `OperationRegistry::invoke()` dispatch path verbatim — no
WS-specific routing, no WS-specific discovery surface, no second invoke
contract to design or maintain.
- The `@alkdev/pubsub`/`@alkdev/operations` TypeScript clients sync to the
alknet call protocol with no translation layer: their `EventEnvelope` shape
is already the native session shape, and the call protocol's envelope is a
refined superset of the pubsub envelope (ADR-044 §"Concrete prior art").
The gateway-on-WS variant would have required un-translating the pubsub
client's native-session shape; this decision avoids that un-translation
entirely.
- Per-caller `AccessControl`-filtered discovery is already a property of the
native session (`services/list`). No WS-specific filtering surface to build
or document; the call protocol's authorization model applies unchanged.
- The browser is a bidirectional call target during a live session, not a
peer-graph member (ADR-044 §5, ADR-034 §4). The native session shape is what
makes this clean: the browser gets bidirectional call capability through the
connection-local Layer 2 overlay (ADR-024, ADR-043 §3) without peer-graph
membership, and the gateway shape would not have changed this — but it would
have made the WS path diverge from the QUIC path for no benefit.
**Negative:**
- A WS client cannot use the gateway's `{ "operation": ..., "input": ... }`
body shape — it must speak the call protocol's native `call.requested`
event. This is honest (the WS path *is* the call protocol), but a developer
who learned the gateway shape from the HTTP surface must learn the
`EventEnvelope` shape for WS. The pubsub prior art and the
`@alkdev/operations` TypeScript client already speak this shape, so the
delta is small for the primary consumer — but it is a real difference from
the HTTP gateway's simpler `{ operation, input }` invoke body.
- The 5 gateway endpoint names (`/search`, `/schema`, `/call`, `/batch`,
`/subscribe`) are HTTP-specific and do not carry over to WS. A deployment
documenting its surface for both HTTP and WS clients documents two invoke
shapes (the gateway for HTTP; the native session for WS). This is the cost
of using the right tool for each transport instead of forcing one shape onto
both.
## Reversal
This ADR clarifies a decision ADR-044 already committed (§1 describes the
native `EventEnvelope` session; this ADR makes that the explicit, implementer-
visible rule). The reversal posture is therefore ADR-044's, not a separate
one: the WS path itself is not deferred (it is the v1 browser path), and
the native-session-not-gateway choice is a clarification of what that path
carries — reversing it would mean adopting the gateway shape on WS, which
would re-introduce the one-directional limitation WS exists to fix (§Context
reason 1). The realistic reversal path is not "switch the WS path to the
gateway shape" but "WebTransport revives and adds a second browser
bidirectional path" — which is ADR-044's reversal trigger (a concrete
ALPN-stream-proxy deployment), not this ADR's. When WebTransport revives,
the two coexist: WS stays as the native-session call-protocol path;
WebTransport adds the ALPN-stream-proxy path (ADR-040). This ADR's rule (WS
= native session, not gateway) is unaffected by WebTransport's revival —
the gateway shape stays HTTP-only regardless of how many browser
bidirectional transports exist.
## Assumptions
1. **The call protocol's `EventEnvelope` framing fits a WebSocket binary
message boundary cleanly.** An `EventEnvelope` is a self-delimited JSON
object; one envelope per WS binary message. No streaming deserializer
across message boundaries is needed. This is ADR-044 Assumption 1, already
verified by the `@alkdev/pubsub` WebSocket client/server prior art.
2. **The shared `Dispatcher` runs over a WS message stream unchanged.**
ADR-012 commits stream-agnostic correlation; a WS message stream is another
`BiStream`-satisfying transport. The `Dispatcher` and `PendingRequestMap`
are transport-agnostic; only the connection-establishment half differs
(WS upgrade handler vs QUIC accept/dial).
3. **The primary WS consumer is a browser or Node client derived from the
`@alkdev/pubsub`/`@alkdev/operations` prior art.** That client already
speaks the native `EventEnvelope` shape. The gateway's simpler
`{ operation, input }` body shape is the HTTP path's affordance for clients
that only speak HTTP; a client that has chosen WS has already opted into
the call protocol's native framing.
4. **`services/list` and `services/schema` are sufficient discovery for the
WS path.** They are `AccessControl`-filtered (per-caller) and return the
full `OperationSpec` respectively. The gateway's `/search` and `/schema`
are HTTP-shaped names for these same primitives; on WS the primitives
apply directly. No WS-specific discovery surface is needed.
## References
- [ADR-012](012-call-protocol-stream-model.md) — stream-agnostic correlation;
a WS message stream is another `BiStream`-satisfying transport
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) §5 — `to_*`
adapters are projections that consume the registry; WS is not a `to_*`
adapter (it carries the native session, it doesn't project it)
- [ADR-024](024-operation-registry-layering.md) — Layer 2 per-connection
overlay where browser-registered ops (if any) land
- [ADR-034](034-outgoing-only-x509-and-three-peer-roles.md) §4 (amended by
ADR-044 §5) — browsers are not alknet peers; the connection-local overlay
gives the browser bidirectional-call capability without peer-graph
membership
- [ADR-036](036-http-to-call-operation-mapping.md) — the SSE projection for
`/subscribe` (the HTTP one-directional streaming path; on WS, subscriptions
project as native `call.responded` events, no SSE)
- [ADR-042](042-openapi-gateway-pattern.md) — the gateway pattern this ADR
clarifies is HTTP-only
- [ADR-043](043-webtransport-bidirectional-alpn-substrate.md) §2/§3 —
bidirectionality and the no-`PeerId` connection-local overlay, transferred
to WebSocket per ADR-044 §3
- [ADR-044](044-defer-webtransport-browsers-use-websocket.md) — the ADR that
committed WS as the v1 browser path; this ADR clarifies the shape of what
ADR-044 committed (§1 implies the native session; this ADR makes it an
explicit rule)
- [ADR-047](047-remove-direct-call-http-surface.md) — the gateway as the
sole HTTP invoke path (the HTTP-only contract this ADR clarifies does not
extend to WS)
- `crates/http/websocket.md` — the spec that implements this decision
- `crates/http/http-server.md` §"WebSocket browser path" — the original
section this decision promotes into `websocket.md`