# ADR-070: `from_wss` — the WSS Consumer Adapter ## Status Accepted ## Context The alknet design named `from_wss` as a future, out-of-scope adapter (alknet `websocket.md` §"Future"): importing a remote alk node's operations over a WebSocket connection, mirroring `from_call`'s pattern with WSS as the transport. It was deferred there because no concrete consumer existed. Extraction changes the calculus. The browser path is now channels-over-WebSocket ([ADR-067](067-websocket-carries-channels.md)), which means: 1. **The server half of WS-channels is being built anyway.** The consumer half (`ChannelClient`-over-WS) is the mirror image sharing the same WS↔byte-stream adapter (OQ-01) — building it later means re-validating the adapter independently. 2. **A deployment pattern became concrete:** a Rust process behind a restrictive network (outbound-only HTTPS/WSS allowed, no QUIC reachability) that needs to consume another node's operations. QUIC (`from_call` over an `alk/call` connection) and TCP+TLS (`alk/channels`) both require reachability the deployment may not have; WSS rides standard HTTPS infrastructure. 3. **alkcall provides both halves.** `ChannelClient` (alkcall ADR-043) is the channels consumer; `from_call` (alkcall ADR-028) is the same-protocol importer pattern. `from_wss` composes them over a WSS transport. ## Decision **alkhttp ships `from_wss`: a consumer adapter that connects to a remote node's WSS endpoint, runs the channels-over-WS session, and imports the remote node's operations as forwarding handlers — the same-protocol importer, with WSS as the transport instead of QUIC.** - **Feature gate:** `wss = ["dep:tokio-tungstenite"]` (not default — a process that never consumes over WSS should not compile a WS client). - **Shape:** implements `OperationAdapter` ([ADR-017](017-call-protocol-client-and-adapter-contract.md); record: alkcall ADR-022): ```rust pub struct FromWss { endpoint: url::Url, // wss://host/alk/channels auth_token: Option, // injected via Capabilities at registration namespace: Option, // local-naming sugar, as from_call's namespace_prefix } #[async_trait] impl OperationAdapter for FromWss { async fn import(&self) -> Result, AdapterError>; } ``` - **Import flow:** dial WSS → wrap the tungstenite stream as a `Connection` (`Connection::from_bidi`, ALPN `alk/channels`) → run the consumer side of the channels session (alkcall `ChannelClient` machinery: install channel 0, run the client dispatch loop) → `services/list` + `services/schema` over channel 0 → one forwarding `HandlerRegistration` per discovered op (provenance `FromCall`, leaf, `Internal` by default — [ADR-015](015-privilege-model-and-authority-context.md), [ADR-022](022-handler-registration-provenance-and-composition-authority.md)). - **Forwarding at call time:** each imported op's handler serializes the call input as a `call.requested` frame, writes it (length-prefixed JSON, alkcall ADR-014) into channel 0's write half, and correlates `call.responded`/`call.completed`/`call.aborted` by `id` via the pending map — identical to `from_call`'s forwarding shape, because the protocol is the same protocol. - **Auth:** the WSS upgrade request carries `Authorization: Bearer `; the token comes from `OperationContext.capabilities` at handler construction (the no-env-vars path — [ADR-014](014-secret-material-flow-and-capability-injection.md)), never from `std::env::var`. - **Relationship to the server path:** the WS↔byte-stream adapter is shared with the server-side upgrade handler (OQ-01) — one implementation, used in both directions. The session itself is alkcall's (`ChannelClient` consumer half + channel 0 dispatch loop), not forked. ### Not in scope - **Reconnection policy semantics** (auto-reconnect, stale registration invalidation): tracked in OQ-03; v1 surfaces a connection-drop as imported-op call failures (`INTERNAL`, retryable) and leaves policy to the assembly layer. - **Serving WSS from the consumer side** — the producer side of a WSS session is the `HttpAdapter` upgrade route ([ADR-067](067-websocket-carries-channels.md)); no separate server type. - **`from_wss` over non-TLS `ws://`** — plaintext WS is refused when a Bearer token is present unless `FromWss::allow_plaintext` was called explicitly (review-001 CON-03): a long-lived credential must not ride an unencrypted connection without an explicit opt-in. Plaintext for local/test use without a token stays allowed. ### Explicit session-lifetime limitation (v1) **There is no teardown handle on `FromWss` in v1** (review-001 CON-08/CON-09): `import()` detaches the session fire-and-forget so the imported handlers keep working off the Arc'd `CallConnection`, and nothing closes the underlying server-side session when the assembly layer is done with the import. Consequences, stated explicitly: - Calling `import()` again (e.g. on a reconnect timer) stacks a second full WS session over the first: duplicate op names in the registry, and the original session is never torn down. - v1 disposition: import **once per process**; treat the imported surface as live for the process lifetime. A reconnecting assembly layer should tear down its whole registry and re-import, accepting the accumulated server-side sessions until the remote times them out. - **Close/teardown handle**: future work; there is no `WssSession::close` in v1 (OQ-03). The connection-drop monitor is however self-limiting (review-002 CON-18): once WS read EOF is observed, pending calls fail with retryable `CONNECTION_CLOSED`, registrations racing the drop are drained by a fast-fail sweep (50 ms interval) during a bounded post-EOF grace window (8 consecutive empty drains), and the monitor task then ends — a dead import leaves no permanent task behind. ## Consequences **Positive:** - Consumers behind outbound-only HTTPS get the full operation surface of a remote node without QUIC/TCP reachability. - The WS↔byte-stream adapter is validated in both directions by construction (server upgrade path + consumer path share it). - Same-protocol import means zero translation: the remote node's ops appear in the local registry with their real specs and error schemas. - A dead import (peer gone) self-cleans: after EOF the drop monitor fails everything pending, drains drop-racing registrations for a bounded grace window, and ends — no per-dead-session task leak scales with import count (review-002 CON-18). **Negative:** - One more WS client dependency in the tree when `wss` is enabled (`tokio-tungstenite`). - Long-lived WSS connections behind proxies/load balancers need keepalive/timeout tuning — deployment concern, but worth documenting. - Reconnection semantics are initially minimal (OQ-03); a consumer wanting hot re-registration must wait for or build the policy. - Registrations landing in the pending map after the bounded post-EOF grace window elapses wait for the 30 s sweeper deadline instead of failing fast — acceptable because registration-vs-EOF races resolve well within the window in practice, and the mux write-failure path covers the rest. ## References - [websocket.md](../websocket.md) — the channels-over-WS session spec (framing shared with this adapter) - [ADR-067](067-websocket-carries-channels.md) — the server-side counterpart (upgrade path `/alk/channels`) - [ADR-017](017-call-protocol-client-and-adapter-contract.md) — the `OperationAdapter` contract - [ADR-014](014-secret-material-flow-and-capability-injection.md) — credential injection (the WSS bearer token path) - [ADR-048](048-websocket-native-session-not-gateway.md) — why the consumer speaks the native session, not the gateway shape - alkcall ADR-022 (adapter contract), ADR-028 (`from_call` — the same-protocol importer pattern this mirrors), ADR-043 (`ChannelClient`), ADR-014 (framing), ADR-034/035 (channels wire) - [open-questions.md](../open-questions.md) OQ-01, OQ-03