Files
alkhttp/docs/architecture/decisions/070-from-wss-consumer-adapter.md
T
glm-5.3-flash a80f9948b8 feat(build): feature-sided builds — server/client sides independently selectable (ADR-039 Amendment 1)
Split the feature graph so consumers pulling only the import adapters
(from_openapi / from_jsonschema / from_mcp) no longer compile the axum
/ hyper server stack, and server-only deployments no longer compile
reqwest. One crate, one import path — sides cut by features, not by a
crate split.

Feature graph:
- server (default): axum host, gateway, WS upgrade, to_openapi, to_mcp
- client (default): client host, forward, from_jsonschema, from_openapi
- openapi: shared OpenAPISpec model (implied by both sides)
- mcp: from_mcp needs client, to_mcp needs server
- wss: tungstenite transport (from_wss); tungstenite half of the
  shared WS↔byte-stream adapter
- h2/http1: hyper protocol features; imply server

Wire-contract neutral: gateway endpoints, ALPNs, and all public API
shapes unchanged; defaults keep both sides on.

Supporting changes:
- forward.rs drops its axum::body::Bytes type leak (bytes crate types)
- bounded_join + error-echo caps move to input_validation (usable by
  both sides; openapi_spec no longer imports from forward)
- byte_adapter: axum flavor compiles under server, tungstenite under
  wss; the generic pumps stay shared (WS-11)
- input_validation / openapi_spec import-only internals gated to the
  side that consumes them
- http-body-util moves to dev-dependencies (was test-only)
- integration-test required-features updated for the new sides
- from_wss unit tests (axum producer harness) gated to server

Verified: cargo test (defaults, 453) and --all-features (575) pass;
lean side builds (client / server / client,mcp / client,wss /
server,wss / openapi-only) build clean with zero warnings;
clippy -D warnings clean across all feature combinations; fmt clean.
2026-08-31 17:19:05 +00:00

173 lines
8.1 KiB
Markdown

# 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). `from_wss` itself additionally requires the `client`
feature side (ADR-039 Amendment 1).
- **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<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](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 <token>`; 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