Files
alknet/docs/architecture/crates/http/websocket.md
glm-5.2 e0c6f61e6a docs(http): pre-decomposition sanity check fixes — /subscribe POST, direct-call cleanup, from_mcp output handling
Three issues found in the http crate spec sanity check that would have
caused problems during task decomposition, now fixed:

C1 — /subscribe GET→POST: the gateway's /subscribe is an invoke endpoint
carrying { operation, input } in the body, but was listed as GET (which
has no body). Flipped to POST with Accept: text/event-stream negotiating
the SSE response, consistent with /call's flat-JSON-body invariant.
Browsers using EventSource can't POST but use WebSocket for the
bidirectional path; the HTTP gateway's /subscribe is for non-browser
HTTP clients (fetch + ReadableStream). Touches ADR-042, ADR-047,
ADR-048, http-adapters.md, http-server.md.

C2 — stale direct-call references: three spots contradicted ADR-047
(which removed the POST /{service}/{op} direct-call surface) and
ADR-046 §3 (which states /{service}/{op} is no longer reserved).
Cleaned up in http-server.md (custom-routes intro + collision list) and
ADR-046 §6 (default-surface list).

W2 — from_mcp output handling: the spec's fallback for tools without
outputSchema was Type.Unknown(), but the correct fallback is the MCP
ContentBlock union (text|image|audio|resource|resource_link) — a
well-defined MCP type, not Unknown. Fixed http-mcp.md with the full
structuredContent-preferred-over-content-blocks logic (matching the TS
adapter and rmcp SDK), enriched references with specific rmcp source
files. Also added shared-dispatch-spine notes to http-mcp.md and
http-adapters.md cross-referencing the new research findings.

Research (docs/research/alknet-http-gateway-factoring/findings.md):
to_mcp and to_openapi share a dispatch spine (resolve → invoke → map).
Recommendation: extract a thin shared struct now, not a GatewayDispatch
trait — the server-integration layers (axum routes vs rmcp
StreamableHttpService) and wire-framing stay per-gateway. A third
gateway is not on the horizon; if one appears its server-integration
needs its own shape anyway.

Minor: WS route precedence note (websocket.md), OpenAPISpec
shared-type-not-shape clarification (http-adapters.md), date bumps.
2026-07-01 05:41:07 +00:00

30 KiB

status, last_updated
status last_updated
draft 2026-07-01

WebSocket — the Browser Bidirectional Path

WebSocket is the v1 browser bidirectional path to the call protocol. h3/WebTransport is deferred per ADR-044; the deferred handler design is at 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 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). 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; the decision that the WS path carries the native session rather than the HTTP gateway shape is ADR-048. 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 §"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 — a wrapper around the raw bearer token bytes) — the same auth path as any HTTP request (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 §"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. A deployment that builds a custom REST projection with POST /{service}/{op} routes (ADR-047 §4) coexists with the WS upgrade at /alknet/call — axum's Router::merge prioritizes specific routes over wildcards, so the WS upgrade's exact /alknet/call path wins over any /{service}/{op} wildcard a custom route projection might register, and the two do not collide. 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:

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 §"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 §"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 §"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 §"Shared Dispatcher".)

Binary payloads within EventEnvelope.payload follow the same base64-as- JSON-string convention the QUIC path uses (../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 §"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 §"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 §"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 PeerEntryPeerId. 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 §"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 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: Bearerresolve_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 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 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 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 §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 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 §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 §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 §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 §3 AccessControl::check(identity) gates every call.requested from the browser; no remote_safe/trusted_peer (retired)
Abort cascade on WS disconnect ADR-016 WS close mid-subscription sends call.aborted, cascading to descendants
Bearer auth via resolve_from_token ADR-004 WS upgrade request credential source (same as HTTP)
Browsers require X.509 (TLS) ADR-027 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 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 The WS upgrade route must not collide with reserved default-surface paths; it namespaces away naturally

Open Questions

See 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 — 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 — the ADR that commits the WS path as the native EventEnvelope session, not the HTTP gateway shape.
  • 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 — 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.mdEventEnvelope wire format, the Dispatcher, the PendingRequestMap, stream model, bidirectional calls, §"Transport agnosticism" (the pubsub-lineage note).
  • ../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.mdOperationRegistry::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.