Files
alkhttp/docs/architecture/decisions/070-from-wss-consumer-adapter.md
T
glm-5.3-flash 8700ed0fea fix(adapters): consumer adapter hygiene (CON-01, CON-03..CON-13)
- CON-01: from_mcp discovery follows tools/list pagination
  (rmcp list_all_tools); three-page paginating-server test
- CON-03: from_wss refuses ws:// with a Bearer token unless
  FromWss::allow_plaintext() is called explicitly (tests: refusal,
  opt-in, token-less passthrough)
- CON-04: audio variant of content_block_union_schema requires
  ["type","data","mimeType"]; jsonschema-validated audio block
- CON-05/07: import-time credential documented on both adapters;
  dead per-call capability read removed
- CON-06: 401 classification typed-first (downcast to rmcp
  StreamableHttpError<reqwest::Error>; AuthRequired/InsufficientScope/
  Client with status 401); a :40101 URL no longer misclassifies (tested)
- CON-11: transport tools/call failures declare MCP_TRANSPORT_ERROR;
  rmcp JSON-RPC errors preserve code (MCP_JRPC_<code>) and data
- CON-12: tool names validated at import (/, whitespace, empty →
  SchemaParse); unit + integration tests
- CON-13: tokens held as alkcall Secret<String> (zeroize, redacted Debug)
- CON-08/09: no close handles; explicit-limitation notes in from_mcp
  module docs, from_wss module docs, and ADR-070
- CON-10: full_surface [[test]] required-features = ["mcp","test-support"];
  cargo test --features mcp now compiles and passes

Verified: cargo test; cargo test --features mcp; cargo test --all-features;
cargo clippy (--all-features) --all-targets -- -D warnings; cargo fmt --check
2026-08-29 10:54:33 +00:00

7.1 KiB

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), 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; record: alkcall ADR-022):

    pub struct FromWss {
        endpoint: url::Url,          // wss://host/alk/channels
        auth_token: Option<String>,  // injected via Capabilities at registration
        namespace: Option<String>,   // local-naming sugar, as from_call's namespace_prefix
    }
    
    #[async_trait]
    impl OperationAdapter for FromWss {
        async fn import(&self) -> Result<Vec<HandlerRegistration>, 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, ADR-022).

  • 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 <token>; the token comes from OperationContext.capabilities at handler construction (the no-env-vars path — ADR-014), 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); 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.
  • A close/teardown handle (and with it, safe reconnect) is future work; v1 deliberately does not build a reconnect layer (OQ-03).

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.

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.

References

  • websocket.md — the channels-over-WS session spec (framing shared with this adapter)
  • ADR-067 — the server-side counterpart (upgrade path /alk/channels)
  • ADR-017 — the OperationAdapter contract
  • ADR-014 — credential injection (the WSS bearer token path)
  • ADR-048 — 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 OQ-01, OQ-03