Files
alknet/tasks/call/client/call-client.md
glm-5.2 2649e068e5 docs(arch): call-completion — ADR-028 peer-scoped filtering + client-and-adapters spec + tasks
Resolves the four gap-analysis decisions (DC-1..4) blocking the alknet-call
client/adapter surface specced in ADR-017:

- ADR-028 (new): locks the one-way door for DC-1 — CallClient registry is
  default-deny (remote_safe: bool on HandlerRegistration, default false across
  all provenance); share-global is an explicit trusted-peer opt-in; filtering
  is a dispatch-time read over the single Layer-0 registry, not a copy.
- client-and-adapters.md (new spec): operationally fills the gap ADR-017 left
  to implementation — CallClient, from_call, from_jsonschema, OperationAdapter
  trait, adapter location map, no-env-vars invariant, exchange-of-operations
  pattern. Keeps call-protocol.md and operation-registry.md under the
  700-line split threshold.
- ADR-017 amended: records DC-2/3/4 v1 defaults (auto-on-reconnect,
  error-on-collision, Result error type) and points DC-1 at ADR-028.
- OQ-25..28 (new): two-way-door remainders (remote_safe shape, AdapterError
  variants, re-import trigger, namespace collision) with v1 defaults recorded.
- Index/cross-ref updates across READMEs and the two existing call specs.

Tasks: 6 task files under tasks/call/ decomposing the completion work along
the gap-analysis priority order — remote-safe-marking (one-way door, first)
→ call-client (phase-risk) → from-call → operation-adapter-trait →
from-jsonschema (parallel with call-client) → review-completion. Graph
validated with taskgraph; parallelism designed in (from-jsonschema runs
concurrent with call-client/from-call once the trait lands).
2026-06-26 12:25:13 +00:00

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) pending
call/protocol/call-connection
call/registry/remote-safe-marking
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 incoming call.requested for an op name resolves to the registration; if registration.remote_safe == false, return NOT_FOUND (not FORBIDDEN — same posture as Visibility::Internal per ADR-015). If true, dispatch normally. OperationContext.capabilities is 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 the remote_safe filter; expose all External ops. 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 existing Visibility::External filter). A peer should not see ops it cannot call.
  • Trusted-peer mode: list all External ops regardless of remote_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.rs exists with CallClient struct (registry, idp, trusted_peer)
  • CallClient::new constructs default-deny (trusted_peer: false)
  • CallClient::trusted_peer constructs trusted-peer mode
  • connect() opens a QUIC connection on ALPN alknet/call
  • connect() returns a CallConnection running the shared dispatch loop
  • Dispatch loop is shared with CallAdapter (refactored, not duplicated)
  • Default mode: incoming call to op with remote_safe == false returns NOT_FOUND
  • Default mode: incoming call to op with remote_safe == true dispatches
  • 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
  • CallCredentials carries 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-call succeeds
  • cargo clippy -p alknet-call --all-targets succeeds 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 + the remote_safe: bool field from remote-safe-marking; per-peer allowlists are OQ-25 and explicitly out of scope. Credentials come from capabilities, never env vars (no-env-vars invariant).