diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 1db289b..57db3d0 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -18,7 +18,7 @@ The storage and auth strategy research (`docs/research/alknet-storage-strategy/f The alknet-call crate is **implemented and reviewed** — both the server-side core and the client/adapter surface (207 lib + 2 integration tests passing). The alknet-core and alknet-call crate specs are in draft; the alknet-vault crate specs are stable. -**alknet-http specs drafted 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` 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` 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 (030–033) 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/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/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-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. | @@ -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 | | [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 | +| [048](decisions/048-websocket-native-session-not-gateway.md) | WebSocket Carries the Native Call-Protocol Session, Not the Gateway Shape | Accepted | ## Open Questions diff --git a/docs/architecture/crates/call/call-protocol.md b/docs/architecture/crates/call/call-protocol.md index 8b9a93f..23d6deb 100644 --- a/docs/architecture/crates/call/call-protocol.md +++ b/docs/architecture/crates/call/call-protocol.md @@ -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. - 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. -- 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. - **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. diff --git a/docs/architecture/crates/http/README.md b/docs/architecture/crates/http/README.md index 27350fc..c5eb7c2 100644 --- a/docs/architecture/crates/http/README.md +++ b/docs/architecture/crates/http/README.md @@ -17,7 +17,8 @@ protocol), and hosts the HTTP-backed call-protocol adapters | Document | Status | Description | |----------|--------|-------------| | [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-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 | @@ -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 | | [046](../../decisions/046-assembly-layer-custom-http-routes.md) | Assembly-Layer Custom HTTP Routes on HttpAdapter | `extra_routes: Option` 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 | +| [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 @@ -107,16 +109,25 @@ protocol), and hosts the HTTP-backed call-protocol adapters arbitrary executable = RCE. Streamable HTTP is network-isolated, auth-gatable, and runs under alknet's auth model. See [ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md). -6. **WebSocket is the browser bidirectional path.** A browser upgrades - an HTTP/1.1 or HTTP/2 request to WebSocket and speaks the call +6. **WebSocket is the browser bidirectional path, and it carries the + 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 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) — a scope decision (the browser bidirectional path doesn't require - WebTransport's stream model; WebSocket suffices). The reversal - trigger is a concrete ALPN-stream-proxy use case (a browser running - a WASM SSH/SFTP/git client). + WebTransport's stream model; WebSocket suffices). The reversal trigger + is a concrete ALPN-stream-proxy use case (a browser running a WASM + 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 it revives, WebTransport) authenticates by bearer token, gets no `PeerId`, and its registered ops land in a connection-local Layer 2 diff --git a/docs/architecture/crates/http/http-server.md b/docs/architecture/crates/http/http-server.md index c16e22e..277461b 100644 --- a/docs/architecture/crates/http/http-server.md +++ b/docs/architecture/crates/http/http-server.md @@ -6,12 +6,13 @@ last_updated: 2026-06-30 # HTTP Server 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); the deferred spec is at [webtransport.md](webtransport.md). This document covers how axum is run over a QUIC bidirectional stream, Bearer auth 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 @@ -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 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 -(§"WebSocket browser path") layers on top of the same hyper connection -driver — a WS upgrade is an HTTP/1.1 or HTTP/2 request that switches -protocols. The `h3` ALPN is deferred (ADR-044); the deferred handler -design is at [webtransport.md](webtransport.md). +(see [websocket.md](websocket.md)) layers on top of the same hyper +connection driver — a WS upgrade is an HTTP/1.1 or HTTP/2 request that +switches protocols. The `h3` ALPN is deferred (ADR-044); the deferred +handler design is at [webtransport.md](webtransport.md). ## Why @@ -198,7 +199,7 @@ The axum route handler: to descendants per ADR-016. 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, no SSE framing. WebTransport (`h3`, deferred per ADR-044) would project 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 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` frames in either direction — the call protocol's native bidirectionality 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 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 -WebSocket (RFC 6455). The resulting full-duplex WS connection carries -call-protocol `EventEnvelope` frames as binary WebSocket messages — one -envelope per message. The browser authenticates by bearer token on the -upgrade request (the HTTP `Authorization` header), resolved by the hub's -`IdentityProvider::resolve_from_token`, same as any HTTP request. The WS -connection is then a **bidirectional call-protocol session**: +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 the +call protocol's native `EventEnvelope` session over binary messages, and +is the surface that **restores the call protocol's native bidirectionality +for browsers** (unlike the one-directional HTTP projection above). The WS +path carries the **native session, not the HTTP gateway shape** (ADR-048): +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 handler hands the WS message stream to the call protocol's - `Dispatcher` — the same dispatch loop the `CallAdapter` uses for - `alknet/call` QUIC connections (ADR-012, stream-agnostic correlation). -- 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. - -**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. +The full WS handler specification — the upgrade route, framing, dispatch +handoff to the shared `Dispatcher`, bidirectionality, the connection-local +Layer 2 overlay, the "browsers are not alknet peers" rationale +(ADR-034 §4, amended by ADR-044 §5), the streaming projection, and the +deferred `from_wss` adapter — is at [websocket.md](websocket.md). +`h3`/WebTransport is deferred per ADR-044; the deferred handler design is +at [webtransport.md](webtransport.md). When WebTransport revives, the two +coexist: WS stays as the simpler call-protocol path; WebTransport adds the +ALPN-stream-proxy path (ADR-040). Neither replaces the other. ### Auth @@ -444,11 +405,14 @@ two-way door (add/remove freely). See Capabilities are used for outbound calls (`from_openapi`), never serialized into HTTP response bodies. - **`/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 - messages. `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. + messages — the **native `EventEnvelope` session, not the gateway shape** + (the gateway endpoints are HTTP-only; discovery via `services/list`/ + `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 (ADR-046).** The assembly layer injects an `axum::Router` of extra 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 | | 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) | -| 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 | -| 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) | +| 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) — 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_` prefix for imported | | Custom HTTP routes from the assembly layer | [ADR-046](../../decisions/046-assembly-layer-custom-http-routes.md) | `extra_routes: Option` 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 `EventEnvelope { type, id, payload }` client/server the call 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 - [webtransport.md](webtransport.md) — the deferred `h3` ALPN handler (kept intact for revival) diff --git a/docs/architecture/crates/http/overview.md b/docs/architecture/crates/http/overview.md index 593b7ea..ac41f30 100644 --- a/docs/architecture/crates/http/overview.md +++ b/docs/architecture/crates/http/overview.md @@ -157,11 +157,15 @@ alknet-call (lean — no HTTP client, no HTTP server) └── CallClient (outbound connection opener) 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) ├── to_openapi (generate OpenAPI doc from local registry) ├── 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` @@ -223,10 +227,16 @@ verified against this invariant. See ADR-014 and ## Architecture (component pointers) - **[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, - stealth decoy, the HTTP-to-call dispatch (ADR-036), and the WebSocket - browser bidirectional path (ADR-044). + stealth decoy, the HTTP-to-call dispatch (ADR-036/042/047), and the + 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 OpenAPI, build forwarding handlers with `reqwest`) and `to_openapi` (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) | | 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 | +| 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/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 | diff --git a/docs/architecture/crates/http/websocket.md b/docs/architecture/crates/http/websocket.md new file mode 100644 index 0000000..6ba7a04 --- /dev/null +++ b/docs/architecture/crates/http/websocket.md @@ -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` and + `Arc` 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 ` 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. \ No newline at end of file diff --git a/docs/architecture/crates/http/webtransport.md b/docs/architecture/crates/http/webtransport.md index f40db10..8eee9fa 100644 --- a/docs/architecture/crates/http/webtransport.md +++ b/docs/architecture/crates/http/webtransport.md @@ -9,7 +9,7 @@ last_updated: 2026-06-30 > This spec is kept intact for revival. `h3`/WebTransport is not > implemented in the initial `alknet-http` release; the browser > 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 > unchanged when WebTransport revives). The reversal trigger is a > concrete deployment needing the ALPN-stream-proxy (a browser running diff --git a/docs/architecture/decisions/042-openapi-gateway-pattern.md b/docs/architecture/decisions/042-openapi-gateway-pattern.md index a5167e7..c2e664a 100644 --- a/docs/architecture/decisions/042-openapi-gateway-pattern.md +++ b/docs/architecture/decisions/042-openapi-gateway-pattern.md @@ -219,7 +219,8 @@ require it for the common case. browser bidirectional path, ADR-044), subscriptions project onto the WS connection directly as binary messages — the gateway's `/subscribe` 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 streams; the deferred design is at `webtransport.md`. diff --git a/docs/architecture/decisions/048-websocket-native-session-not-gateway.md b/docs/architecture/decisions/048-websocket-native-session-not-gateway.md new file mode 100644 index 0000000..da9caa9 --- /dev/null +++ b/docs/architecture/decisions/048-websocket-native-session-not-gateway.md @@ -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` \ No newline at end of file