The #1 gap in alknet-call: the outbound connection opener. Every downstream
consumer (runner, container service, bilateral exchange, NAPI, agent
cross-node dispatch) is blocked on it.
Shared dispatch loop (ADR-017 §1 — the architectural commitment that keeps
CallClient from becoming a parallel protocol implementation):
- Extracts the accept-path dispatch (sweeper, accept_bi loop, handle_stream,
dispatch_requested, build_root_context, compose_root_env, fail_all on
close) out of CallAdapter into a new protocol/dispatch.rs Dispatcher struct.
Both CallAdapter::handle and CallClient::connect produce a CallConnection
and hand it to Dispatcher::run_loop — the loop is genuinely shared
(refactored, not duplicated).
- CallAdapter keeps its public API and test-facing wrappers (pub(crate),
#[cfg(test)]-gated) that delegate to the Dispatcher.
Peer-scoped default-deny (ADR-028 — the one-way-door security dimension):
- RemoteFilter { trusted_peer: bool } on the Dispatcher. In default-deny
mode (CallClient::new), an incoming call to an op with remote_safe: false
returns NOT_FOUND *before* any capability material reaches the handler —
a remote peer's call must not populate OperationContext.capabilities from
the local registration bundle unless the op is explicitly remote-safe
(ADR-028 Context). Trusted-peer mode (CallClient::trusted_peer, explicit
opt-in) bypasses the filter.
- The accept path (CallAdapter) uses RemoteFilter::trusted() by convention: a
direct QUIC client is not a filtered CallClient peer in the ADR-028 sense.
- OperationRegistry::list_operations_peer_scoped(trusted_peer) +
services_list_handler_peer_scoped for the CallClient's services/list
serving path (ADR-028 Assumption 2: a peer should not see ops it cannot
call, so discovery and dispatch filters agree).
CallClient (src/client/call_client.rs):
- CallClient { registry, identity_provider, trusted_peer: bool }.
- new() default-deny; trusted_peer() explicit opt-in (ADR-028 §3).
- connect(addr, CallCredentials) dials QUIC on ALPN alknet/call (quinn
feature), spawns Dispatcher::run_loop, returns a live CallConnection.
- spawn_dispatch(connection) shared path for connect + tests.
- CallCredentials { tls_identity, auth_token, remote_identity } — all from
Capabilities (ADR-014), never env vars (no-env-vars invariant). v1
connects without client-auth TLS identity (server uses
AcceptAnyCertVerifier); RawKey client-auth is a two-way-door remainder.
- RemoteIdentity { fingerprint } — concrete shape is a two-way door (OQ-25
remainder); the one-way constraint is it comes from Capabilities.
- ClientError { Transport, TlsSetup, ConnectionClosed }.
- CallConnection is now Clone (shares the inner Arcs) so connect can hand
the caller a live clone while the dispatcher task keeps its clone.
Tests (199 lib + 1 integration):
- Unit: default-deny NOT_FOUND for non-remote-safe; remote_safe dispatches;
trusted-peer dispatches all External; default-deny does NOT populate
capabilities (the load-bearing security assertion — verified by a handler
that inspects context.capabilities and the fact that the handler is never
reached for non-remote-safe ops); remote_safe op populates capabilities;
services/list peer-scoped hide/trusted variants; CallClient constructors;
CallCredentials builder; Send+Sync.
- Integration (tests/two_node_call.rs): real QUIC loopback — CallAdapter
server (self-signed cert via rcgen) accepts, CallClient connects,
client.call() round-trips to server/echo. Proves the connect path +
shared dispatch loop work end-to-end.
clippy + fmt + test all green.
Refs: tasks/call/client/call-client.md
Refs: docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md §1, §2, §7
Refs: docs/architecture/decisions/028-callclient-peer-scoped-registry-filtering.md
Refs: docs/architecture/crates/call/client-and-adapters.md