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
9.3 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | ||
|---|---|---|---|---|---|---|---|---|---|
| call/client/call-client | Implement CallClient (outbound connection opener) with peer-scoped default-deny dispatch (ADR-017, ADR-028) | completed |
|
moderate | high | phase | implementation |
Description
Implement CallClient in src/client/mod.rs (new client module). This is
the #1 gap in alknet-call — the outbound connection opener. Every downstream
consumer (runner pattern, container service, bilateral exchange, NAPI
projection, agent cross-node tool dispatch) is blocked on it. It opens a QUIC
connection to a remote node on ALPN alknet/call, performs credential setup,
and produces a CallConnection running the shared dispatch loop (ADR-017
§1). CallClient is the connection-establishment half; CallAdapter's
accept path is the inbound half. Both produce the same CallConnection.
CallClient struct
pub struct CallClient {
/// The operation registry. The peer-scoped view is a dispatch-time read
/// over this registry, not a copy (ADR-028 §5).
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
/// Trusted-peer mode (ADR-028 §3): when true, the dispatch path exposes
/// all External ops to the remote peer and services/list lists all
/// External ops, ignoring the remote_safe marking. When false (default),
/// only registrations with remote_safe: true dispatch, and services/list
/// hides non-remote-safe ops (ADR-028 Assumption 2).
trusted_peer: bool,
}
impl CallClient {
/// Default: peer-scoped (default-deny). Filters dispatch + services/list
/// by remote_safe == true.
pub fn new(registry: Arc<OperationRegistry>, idp: Arc<dyn IdentityProvider>) -> Self;
/// Trusted-peer mode: expose all External ops, ignore remote_safe.
/// Explicit opt-in per ADR-028 §3.
pub fn trusted_peer(registry: Arc<OperationRegistry>, idp: Arc<dyn IdentityProvider>) -> Self;
/// Open a QUIC connection to `addr` on ALPN `alknet/call`, perform
/// credential handshake, and return a CallConnection running the shared
/// dispatch loop. Credentials come from capabilities (ADR-014), not env
/// vars — see client-and-adapters.md "No-Env-Vars Invariant".
pub async fn connect(
&self,
addr: SocketAddr,
credentials: CallCredentials,
) -> Result<CallConnection, ClientError>;
}
Shared dispatch loop
The dispatch loop is shared with CallAdapter. Once a connection is
established (whether accepted or opened), the same logic applies: read
EventEnvelope frames, dispatch to the operation registry, write responses,
send outgoing call.requested for calls initiated on this side. Refactor the
existing accept-path dispatch out of CallAdapter into a shared function
(likely in src/protocol/connection.rs or a new src/protocol/dispatch.rs)
that both CallAdapter::handle and CallClient::connect call. Do not
duplicate the dispatch loop — ADR-017 §1 is explicit that the client is the
connection-establishment half, not a parallel protocol implementation.
The CallConnection type already exists (protocol/connection.rs) and
holds the Layer 2 overlay + call/subscribe/abort API. CallClient::connect
constructs it from the opened connection (vs CallAdapter constructing it
from the accepted connection).
Peer-scoped dispatch (ADR-028 — default-deny)
The incoming-call dispatch path in the CallClient must filter by
remote_safe:
- Default mode (
trusted_peer: false): an incomingcall.requestedfor an op name resolves to the registration; ifregistration.remote_safe == false, returnNOT_FOUND(notFORBIDDEN— same posture asVisibility::Internalper ADR-015). Iftrue, dispatch normally.OperationContext.capabilitiesis populated from the registration bundle only for remote-safe ops — this is the security argument for default-deny (ADR-028 Context): a remote peer's call must not trigger dispatch that populates capabilities from the local node's registration bundle unless the op is explicitly exposed. - Trusted-peer mode (
trusted_peer: true): bypass theremote_safefilter; expose allExternalops. The operator has made the trust decision explicitly.
This is a dispatch-time read over the single Layer-0 registry (ADR-028 §5) —
not a copied subset, not a third registry instance. The OperationRegistry
from remote-safe-marking is the single source.
services/list hide behavior (ADR-028 Assumption 2)
When the CallClient serves services/list to the remote peer:
- Default mode: hide ops where
remote_safe == false(in addition to the existingVisibility::Externalfilter). A peer should not see ops it cannot call. - Trusted-peer mode: list all
Externalops regardless ofremote_safe.
The existing services_list_handler in registry/discovery.rs filters by
Visibility::External only. Wire the additional remote_safe filter for the
CallClient's serving path. (The CallAdapter's serving path — local
accept — is unchanged; it continues to list all External ops, since a
direct QUIC client is not a CallClient peer in the filtered sense. Clarify
this split in code comments and a test.)
Credentials
connect() takes a CallCredentials bundle. Credentials come from
Capabilities (ADR-014), never env vars. The three dimensions (ADR-017 §7):
TLS identity (RFC 7250 raw key or X.509, ADR-027), auth token (opaque,
vault-decrypted), remote identity verification (expected fingerprint/cert).
Populated by the assembly layer at CallClient construction time from
vault-derived Capabilities. The concrete TlsIdentity / AuthToken /
RemoteIdentity shapes are implementation-detail two-way doors (recorded in
client-and-adapters.md); the one-way constraint is they come from
capabilities, not env vars.
Connection symmetry
After establishment, the connection is symmetric (ADR-017 §2): both sides
can send and receive call.requested. Connection direction is independent of
call direction. The CallClient is both a caller (initiates outgoing calls
via CallConnection::call()/subscribe()/abort()) and a callee
(dispatches incoming calls against its peer-scoped view).
Acceptance Criteria
src/client/mod.rsexists withCallClientstruct (registry, idp, trusted_peer)CallClient::newconstructs default-deny (trusted_peer: false)CallClient::trusted_peerconstructs trusted-peer modeconnect()opens a QUIC connection on ALPNalknet/callconnect()returns aCallConnectionrunning the shared dispatch loop- Dispatch loop is shared with
CallAdapter(refactored, not duplicated) - Default mode: incoming call to op with
remote_safe == falsereturns NOT_FOUND - Default mode: incoming call to op with
remote_safe == truedispatches - Default mode: capabilities populated only for remote-safe dispatched ops
- Trusted-peer mode: all External ops dispatch regardless of remote_safe
- Default mode: services/list hides non-remote-safe ops from the peer
- Trusted-peer mode: services/list lists all External ops
- Outgoing call()/subscribe()/abort() work through the returned CallConnection
- Connection symmetry: remote peer can call back into the CallClient
CallCredentialscarries TLS identity / auth token / remote identity (from capabilities)- No env-var reads in the credential path (no-env-vars invariant, ADR-014)
- Integration test: two-node call (CallClient connects to CallAdapter, both call each other)
- Integration test: default-deny op returns NOT_FOUND to remote peer
- Integration test: remote_safe op dispatches to remote peer
- Integration test: trusted-peer mode exposes all External ops
- Integration test: services/list hides non-remote-safe in default mode
cargo test -p alknet-callsucceedscargo clippy -p alknet-call --all-targetssucceeds with no warnings
References
- docs/architecture/crates/call/client-and-adapters.md — CallClient §, credential sources §
- docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md — ADR-017 §1 (shared loop), §2 (symmetry), §7 (credentials)
- docs/architecture/decisions/028-callclient-peer-scoped-registry-filtering.md — ADR-028 (default-deny, trusted-peer, Assumption 2 services/list hide)
- docs/architecture/crates/call/call-protocol.md — CallConnection (the type connect() produces)
- tasks/call/protocol/call-connection.md — completed CallConnection task
- tasks/call/registry/remote-safe-marking.md — prerequisite (adds remote_safe field)
- docs/research/alknet-call-completion/gap-analysis.md — DC-1, implementation priority #1
Notes
This is the single highest-value piece of work in the alknet-call completion — every downstream consumer is blocked on it. The dispatch loop is shared with CallAdapter (refactor, don't duplicate — ADR-017 §1 is explicit). The peer-scoped default-deny (ADR-028) is the one-way-door security dimension: a remote peer's call must not populate OperationContext.capabilities from the local bundle unless the op is explicitly remote-safe. The v1 shape is
trusted_peer: bool+ theremote_safe: boolfield fromremote-safe-marking; per-peer allowlists are OQ-25 and explicitly out of scope. Credentials come from capabilities, never env vars (no-env-vars invariant).