Files
alknet/docs/architecture/crates/call/client-and-adapters.md
T
glm-5.2 3b10fc1817 refactor(core,tls,client): align ConnectionCredentials field name with ADR-091 (tls_identity -> local_identity)
ADR-091 decided `ConnectionCredentials.local_identity`; the code implemented
`tls_identity` (tasks/core/connection-credentials.md deferred the rename as
"path of least resistance" during the extraction). The tangle that made the
rename hard no longer exists, so align the code with the decision.

Scope is the `ConnectionCredentials` field + builder only:
- alknet-core/credentials.rs: field, with_local_identity, doc, test
- alknet-tls/client.rs: field access in TlsClientConfig::new, test builders, docs
- alknet-client/dial/quinn.rs: test builder

NOT renamed (distinct concepts sharing the words):
- StaticConfig.tls_identity (server-side static config; ADR-082/027/083)
- TlsIdentity enum type name
- alknet-tls server fn params named tls_identity (&TlsIdentity value)

Also fixes dial_iroh.rs doc comments that claimed the local key is extracted
from creds.local_identity — the key is actually on the pre-built iroh endpoint
(set at with_iroh time); the dial reads only creds.remote_identity and ignores
creds.local_identity (per client/README.md §iroh).

Architecture specs updated to match (call/client-and-adapters.md, tls/README.md,
client/README.md). Historical ADR context describing the old CallCredentials
field stays as-is; tasks/ and docs/research/ are historical artifacts.

Resolves follow-up #2 from the post-extraction spec sync (c6eef73).
2026-07-17 15:32:04 +00:00

51 KiB

status, last_updated
status last_updated
draft 2026-07-17

alknet-call — Client and Adapters

The outbound half of the call protocol: opening connections, importing remote operations, and the adapter contract that ties import-style adapters together. This document covers what ADR-017 specced but the server-side implementation (call-protocol.md, operation-registry.md) did not include — the CallClient that opens a connection, the from_call adapter, and the OperationAdapter trait. (from_jsonschema was originally specced here too, but ADR-066 moved it to alknet-http — see §"from_jsonschema" below.) The server-side CallAdapter and CallConnection dispatch loop are covered in call-protocol.md; this document covers the client-side connection-establishment half and the adapter surface.

What

This document specifies three components, all in alknet-call:

  1. CallClient — takes over an established transport Connection on ALPN alknet/call, spawns the shared dispatch loop, and produces a CallConnection. Transport-agnostic (spawn_dispatch primary; connect removed per ADR-089 §5 — dial extracted to AlknetClient); the dispatch loop is shared with the server-side CallAdapter (ADR-017 §1); CallClient is the connection-take-over half, not a parallel protocol implementation.
  2. from_call — discovers operations on a remote call-protocol endpoint via services/list + services/schema (already implemented in registry/discovery.rs) and registers them in the connection's Layer 2 overlay as FromCall-provenance leaves with forwarding handlers.
  3. OperationAdapter trait — the async trait that from_call, from_openapi, from_mcp, and from_jsonschema all implement.

from_jsonschema moved. ADR-066 moved from_jsonschema from alknet-call to alknet-http and gave it a real reqwest-backed forwarding handler (it was a broken schema-only placeholder before). It is now an HTTP-backed single-endpoint adapter for non-standard / non-OpenAPI / basic REST endpoints, functionally similar to from_openapi but one endpoint at a time. See crates/http/http-adapters.md §"from_jsonschema". The FromJsonSchema provenance variant stays in alknet-call (OperationProvenance); only the adapter implementation moved.

It also records two cross-cutting architectural mechanisms that the adapter surface rests on:

  • The adapter location map — which adapters live in alknet-call vs alknet-http, and why.
  • The no-env-vars invariant — the architectural mechanism by which downstream consumers' std::env::var credential reads are made unreachable.

And one downstream pattern this completion unblocks:

  • The exchange-of-operations pattern (runner / container service) — the canonical bilateral composition this client surface enables.

Why

The server-side CallAdapter (accept path) and CallConnection (dispatch loop) are implemented and tested. The client side is the #1 gap blocking every downstream consumer: the runner pattern (a process that connects outward to a hub and exposes local ops), the container-service rewrite, the bilateral exchange, the NAPI projection, and the agent's cross-node tool dispatch all require a CallClient. from_call is the #2 gap; the OperationAdapter trait is the enabling gap for alknet-http's from_openapi/from_mcp.

ADR-017 specced this surface. This document is the spec that operationally fills the gap ADR-017 left to implementation: the CallClient API, the from_call flow, the trait signature, the adapter location, the credential invariant, and the bilateral pattern. The gap analysis (docs/research/alknet-call-completion/gap-analysis.md) identified four decisions (DC-1..4) needed before implementation. DC-1 was initially resolved by ADR-028 (remote_safe/trusted_peer), but a subsequent research pass (docs/research/alknet-call-peer-routing/findings.md) found that ADR-028's model was structurally broken for the head→N-workers pattern (the primary use case) and that its parallel remote_safe/trusted_peer authorization system duplicated the existing AccessControl/Identity machinery. ADR-029 supersedes ADR-028: peer-keyed overlays + PeerRef routing, and peer authorization through the existing AccessControl::check(peer_identity). DC-2/3/4 are two-way-door defaults recorded here (DC-2→OQ-27, DC-3→OQ-28 cross-peer dissolved / same-peer stays, DC-4→OQ-26).

Architecture

CallClient

CallClient takes over an established transport Connection on ALPN alknet/call, spawns the shared dispatch loop, and produces a CallConnection. The CallConnection type is already implemented (call-protocol.md §"CallConnection") — it wraps an established Connection and holds the Layer 2 imported-ops overlay. CallClient is the producer on the outbound side; CallAdapter's accept path is the producer on the inbound side. Both produce the same CallConnection and hand it to the same shared dispatch loop.

CallClient is transport-agnostic. The call protocol runs over any ordered, reliable bidirectional stream — QUIC, TCP+TLS, WebTransport, SSH direct-tcpip, a WebSocket (ADR-065 Connection::from_stream / from_bidi). The primary constructor (spawn_dispatch) takes a pre-established Connection from any transport; the QUIC convenience (connect) dials QUIC and calls spawn_dispatch. This mirrors ChannelClient::from_connection / connect_quic (ADR-080) and is the client-side analogue of the server-side generalization ADR-065 made.

pub struct CallClient {
    registry: Arc<OperationRegistry>,
    identity_provider: Arc<dyn IdentityProvider>,
}

impl CallClient {
    pub fn new(registry: Arc<OperationRegistry>, idp: Arc<dyn IdentityProvider>) -> Self;

    /// Transport-agnostic primary constructor. Takes a pre-established
    /// `Connection` on ALPN `alknet/call` (any transport — QUIC via
    /// `from_quinn`, TCP+TLS via `from_bidi`, WebTransport, SSH
    /// `direct-tcpip`, a WebSocket), spawns the shared dispatch loop,
    /// and returns a live `CallConnection`. Mirrors the server-side
    /// `CallAdapter::handle(Connection)`. This is the one-way-door
    /// API surface (ADR-017 Am. 2026-07-13) — it must not be coupled to
    /// a transport.
    pub fn spawn_dispatch(&self, connection: Connection) -> CallConnection;

    /// **REMOVED per ADR-089 §5.** The dial is extracted into
    /// `AlknetClient` (`alknet-client`); `connect` is deleted, not
    /// delegated, to avoid `alknet-call` depending on `alknet-client`
    /// and to let `alknet-call` shed its TLS/transport deps entirely.
    /// Callers compose `AlknetClient::dial_quic(...).await?` +
    /// `CallClient::new(...).spawn_dispatch(conn)`. `ClientError` is
    /// removed (it was produced only by `connect`). `CallCredentials`
    /// is removed (its `auth_token` field had no reader; `auth_token`
    /// is a per-request payload field — ADR-091, amended 2026-07-17).
    #[cfg(feature = "quinn")]
    pub async fn connect(
        &self,
        addr: SocketAddr,
        credentials: CallCredentials,  // REMOVED — CallCredentials is removed
    ) -> Result<CallConnection, ClientError>;
}

Peer authorization flows through the existing AccessControl::check against the peer's resolved Identity (ADR-029 §3) — there is no trusted_peer flag and no remote_safe marking. When a remote peer calls an op, the dispatch path resolves the peer's Identity (from the connection's TLS fingerprint or the auth_token payload, via the existing IdentityProvider) and runs AccessControl::check(peer_identity) against the op's AccessControl. If the op's required scopes/resources are satisfied, the call dispatches; if not, FORBIDDEN before the handler runs (capabilities never populated — the security property). An op that should never be callable from the wire uses Visibility::Internal (existing mechanism, NOT_FOUND before ACL). See ADR-029 §3 for the full mapping of the three remote_safe cases to AccessControl/Visibility.

The connection is symmetric after establishment (ADR-017 §2): both sides can send and receive call.requested. Connection direction (who opened it) is independent of call direction (who calls whom). The CallClient is therefore both a caller and a callee — it dispatches incoming calls from the remote peer through the same AccessControl-gated path, and it initiates outgoing calls through the CallConnection::call() / subscribe() / abort() API.

Shared Dispatcher

The shared dispatch loop lives in protocol/dispatch.rs as the Dispatcher struct. This is the architectural mechanism that keeps CallClient from becoming a parallel protocol implementation (ADR-017 §1): both CallAdapter's accept path and CallClient's connect path construct a Dispatcher and call run_loop — the dispatch half is one implementation, the connection-establishment half differs (accept vs dial).

/// Shared dispatcher for an established CallConnection. Constructed by both
/// CallAdapter (accept path) and CallClient (connect path). Holds no
/// per-connection state; the CallConnection is passed into run_loop.
pub struct Dispatcher {
    pub registry: Arc<OperationRegistry>,
    pub identity_provider: Arc<dyn IdentityProvider>,
    pub session_source: Option<Arc<dyn SessionOverlaySource + Send + Sync>>,
    pub default_timeout: Duration,
}

The dispatch path resolves the peer's Identity, runs AccessControl::check against the op's AccessControl, and dispatches if allowed — the same authorization machinery that gates every other call. No RemoteFilter, no remote_safe gate (ADR-029 §3 retires these).

CallClient::spawn_dispatch(connection) is the transport-agnostic primary constructor — it takes a pre-established Connection, constructs a CallConnection, builds a Dispatcher, spawns the dispatch task, and returns the live CallConnection. connect() is removed per ADR-089 §5: the dial is extracted into AlknetClient (alknet-client), and keeping a QUIC convenience constructor on CallClient would make alknet-call depend on alknet-client, contradicting the dep graph (the protocol crates are parallel to the dial, not downstream of it). Callers compose AlknetClient::dial_quic

  • spawn_dispatch — two lines, the dial then the take-over. Tests use spawn_dispatch directly to wire mock/loopback connections. The one-way-door surface is spawn_dispatch; the dial lives in alknet-client.

This mirrors ChannelClient::from_connection (ADR-080; its connect_quic is likewise removed per ADR-089 §5) and is the client-side analogue of the server-side generalization ADR-065 made. The call protocol, like the channels protocol, is transport-agnostic — Connection::from_stream / from_bidi (ADR-065) accept any AsyncRead + AsyncWrite, and spawn_dispatch takes the resulting Connection unchanged.

Peer-keyed composition env (ADR-029)

The composition env that aggregates multiple connections is peer-keyed (ADR-029 §1). CompositeOperationEnv's singular connection: Option<Arc<dyn OperationEnv>> is replaced by PeerCompositeEnv with peer-keyed connections:

pub struct PeerCompositeEnv {
    pub base: Arc<dyn OperationEnv + Send + Sync>,       // Layer 0 curated
    pub session: Option<Arc<dyn OperationEnv + Send + Sync>>,  // Layer 1
    pub connections: HashMap<PeerId, Arc<dyn OperationEnv + Send + Sync>>,  // Layer 2, peer-keyed
    connection_order: Vec<PeerId>,  // insertion order for PeerRef::Any first-match
}
pub type PeerId = String;  // = Identity.id from IdentityProvider resolution
                           // = PeerEntry.peer_id (stable, not crypto material — ADR-030)

OperationEnv gains a peer-routing method with a PeerRef selector (Specific(PeerId) / Any), default-impl for back-compat. See ADR-029 §2 for the full invoke_peer signature and ScopedPeerEnv peer-qualified reachability. The per-CallConnection overlay stays flat (one connection = one peer); the peer-keying is at the aggregation layer (the head node's composition env).

services/list

services/list filters by AccessControl::check(calling_peer_identity) — the calling peer sees only ops it is authorized to call. The services_list_handler / services_list_handler_peer_scoped split collapses to a single AccessControl-filtered handler (the peer_scoped variant and the remote_safe filter are removed). services/list-peers is the opt-in for peer-attributed re-export listing (each peer's sub-overlay listed with attribution, filtered by the calling peer's authorization). See ADR-029 §6.

Credential sources for connections

The credential dimensions are split across two layers (ADR-091, amended 2026-07-17):

  • ConnectionCredentials (in alknet-core, per ADR-091) — the transport-level credential bundle, consumed by the dial (AlknetClient). Carries the two transport-identity dimensions: local_identity (the local node's TlsIdentity) and remote_identity (the expected fingerprint). The dial does not depend on the call protocol for this type.
  • auth_token — a per-request payload field, not a call-protocol credential bundle. Dispatcher::resolve_identity reads payload.get("auth_token") on each call.requested payload. Browsers send it directly in the WebSocket call payload; the HTTP gateway resolves the bearer token to an Identity at its boundary (the call layer sees the identity, not the token). CallCredentials is removed (its auth_token field had no reader — connect() read only tls_identity + remote_identity; spawn_dispatch takes no credentials; the from_call forwarding path's auth_token source was OpSummary.credentials_auth_token: Option<String>, always None, never connected to CallCredentials.auth_token). See ADR-091 (amended 2026-07-17) for the full trace.

Credentials come from Capabilities (ADR-014), never from environment variables. The transport-identity dimensions (ADR-017 §7):

// Transport-level (alknet-core, consumed by the dial — ADR-091)
pub struct ConnectionCredentials {
    pub local_identity: Option<TlsIdentity>,     // RFC 7250 raw key or X.509
    pub remote_identity: Option<RemoteIdentity>, // expected fingerprint (None = CA path / fail-closed)
}

// auth_token is a per-request payload field, not a credential struct.
// Browsers send it in the WebSocket call payload; the HTTP gateway
// resolves bearer → Identity at its boundary.
// Dispatcher::resolve_identity reads payload.get("auth_token").

RemoteIdentity (ADR-017 §7, extended by ADR-034 §2) carries a fingerprint string the assembly layer derives from Capabilities when the local node has a PeerEntry for the remote (the known-peer case → fingerprint pin). remote_identity: None is the public X.509 endpoint case: the local node has no PeerEntry for the remote, so there is no fingerprint to pin. Combined with an X.509 transport, None selects CA verification (WebPkiServerVerifier) per the verifier-selection rule in ADR-034 §3. Combined with an Ed25519 raw-key transport, None fails closed (raw-key remotes are always known peers — no CA to fall back to). The Option is load-bearing, not cosmetic: Some(fingerprint) means "pin this" (known peer), None means "trust the CA or fail" (unknown remote). An implementer must not default remote_identity to a placeholder value to "satisfy" the field — None is a real state that drives verifier selection.

pub struct RemoteIdentity { pub fingerprint: String }

There is no call-protocol credential bundle. CallCredentials is removed. The transport dimensions (local_identity, remote_identity) are in ConnectionCredentials in alknet-core per ADR-091.

  • TLS identity — the local node's Ed25519 raw key (RFC 7250) or X.509 cert, derived from the vault at startup (ADR-020, ADR-026, ADR-027).
  • Auth token — an opaque call-protocol-level token, decrypted from the vault or derived from a shared secret.
  • Remote identity verification — the expected fingerprint/cert of the remote node, stored as a capability. Some → fingerprint pin (known peer with a PeerEntry); None → CA verification for X.509 remotes, fail-closed for Ed25519 raw-key remotes (ADR-034 §2/§3). The None case is the public-X.509-endpoint path, not a missing field.

These are populated by the assembly layer at CallClient construction time from vault-derived Capabilities. The credential path is the no-env-vars invariant (below). The concrete shapes of TlsIdentity, AuthToken, and RemoteIdentity are implementation-detail two-way doors; the one-way constraints are that they come from Capabilities, not env vars (ADR-014).

TLS client-auth presentation (OQ-29 #1, wired): the client presents its Ed25519 key as an RFC 7250 raw public key client cert — the client-side equivalent of the server's RawKeyCertResolver. This is wired now, not additive: it is what activates the PeerEntry fingerprint → peer_id resolution path on quinn connections (ADR-030 §5). Without it, the ADR-029 peer graph doesn't populate for quinn connections — PeerId resolution fails because the server has no client cert to extract a fingerprint from. The iroh path already works (iroh uses RFC 7250 raw keys and exchanges Ed25519 public keys during the TLS handshake automatically); the gap was quinn-only, and OQ-29 #1 resolves it by replacing with_no_client_auth() with presenting the key. The one-way constraint (credentials from Capabilities, not env vars, ADR-014) is unaffected — the auth_token dimension flows through the call-protocol auth_token payload field, not TLS, so the no-env-vars invariant holds independently of the TLS layer.

Remote-identity verification (OQ-29 #2, additive): verifying the server's fingerprint against an expected value (credentials.remote_identity) is additive — the server-side fingerprint extraction is what matters for PeerId, not the client-side verification. The verifier for raw keys can start as "accept any, extract fingerprint" and add fingerprint-pinning later. This is a two-way-door remainder; the one-way constraint (credentials from Capabilities, not env vars) is unaffected.

Server cert verifier selection (OQ-29 #2 + ADR-034 §3): the client-side ServerCertVerifier is selected by whether the local node has a PeerEntry for the remote, not by key type alone. A pure-client connection to a public X.509 endpoint (no PeerEntry on the local side — e.g., dialing api.alk.dev or a third-party API) uses WebPkiServerVerifier (CA verification), gets no PeerId on the client side, and is not added to PeerCompositeEnv — it is not in the call-protocol peer graph (ADR-029). Ops discovered via from_call on such a connection land in the connection's Layer 2 overlay (ADR-024) and are invoked through the CallConnection handle directly, not via PeerRef::Specific. A connection to a hub (a PeerEntry with mixed Ed25519 + X.509 fingerprints) uses fingerprint pinning on both cert paths and does enter the peer graph. An unknown Ed25519 raw-key remote fails closed (no CA to fall back to — raw-key remotes are always known peers). See ADR-034 for the verifier selection rule and the three-role naming.

from_call

from_call discovers the remote peer's External operations and registers them in the connection's Layer 2 overlay as FromCall-provenance leaves with forwarding handlers. The discovery mechanism (services/list + services/schema) is already implemented in registry/discovery.rs; from_call is the client-side consumer of that API.

pub struct FromCallConfig {
    /// Namespace prefix applied to imported operation names. Optional —
    /// default no prefix. Collision on import is an error (DC-3, OQ-28),
    /// not last-wins.
    pub namespace_prefix: Option<String>,
    /// Optional filter — import only operations whose names match. None
    /// imports all External ops discovered via services/list.
    pub operation_filter: Option<HashSet<String>>,
}

/// Discover the remote peer's External ops and construct HandlerRegistration
/// bundles with FromCall provenance and forwarding handlers. The caller
/// registers the bundles in the connection's overlay via
/// CallConnection::register_imported_all().
pub async fn from_call(
    connection: &CallConnection,
    config: FromCallConfig,
) -> Result<Vec<HandlerRegistration>, AdapterError>;

The flow (ADR-017 §3):

  1. Call services/list on the remote → list of External operations.
  2. Call services/schema for each → input/output JSON Schemas and declared error_schemas (ADR-023).
  3. For each discovered op, construct a HandlerRegistration:
    • spec mirrors the remote op's name (with optional prefix), namespace, type, schemas, access control.
    • handler is a forwarding handler, branched on op_type (ADR-049):
      • Query / Mutation → a Handler (registered as HandlerKind::Once): sends call.requested via CallConnection::call_with_payload(), awaits the single call.responded (or call.error), returns the ResponseEnvelope.
      • Subscription → a StreamingHandler (registered as HandlerKind::Stream): calls CallConnection::subscribe(), which returns impl Stream<Item = ResponseEnvelope> (the client-side streaming path, already implemented), maps it to a BoxStream<ResponseEnvelope>. The remote stream flows end-to-end: each call.responded the remote sends becomes a stream item; the remote's call.completed ends the stream (→ wire call.completed); call.aborted drops the stream (cascade per ADR-016). No truncation, no first-value fallback — a from_call-imported subscription forwards the full remote stream.
    • provenance: FromCall, composition_authority: None, scoped_env: None (leaf — ADR-022).
  4. The caller registers the bundles via CallConnection::register_imported_all().

Re-import on reconnection (DC-2, OQ-27): from_call is a free function; the assembly layer calls it after connect(). The overlay is per-connection (Layer 2, ADR-024), so a stale overlay dies with the connection; re-import on reconnect is naturally scoped to the new connection. A CallConnection::refresh() method for mid-connection re-discovery is a genuine feature addition — non-breaking, additive — if a deployment needs manual re-discovery without drop-and-reconnect. See ADR-069.

Namespace collision (DC-3, OQ-28): under the peer-graph model (ADR-029), cross-peer collision dissolves — same name on different peers is fine (they live in separate peer sub-overlays, no prefix needed). Same-peer collision stays an error (a peer shouldn't expose two ops with the same name). FromCallConfig::namespace_prefix is optional local-naming sugar for when the importing node wants to expose a peer's ops under a different name locally — a local-naming concern, not a disambiguation concern. It defaults to None.

Trust is transitive (recorded in operation-registry.md): a from_call-imported operation executes the remote node's code, not yours. The scoped env (ADR-015) bounds which operations are reachable, not what they do. from_call means "I trust the remote node as much as my own handlers." The abort cascade (ADR-016) crosses the node boundary transparently through the forwarding handler's parent_request_id.

Forwarded-for identity (ADR-032): the from_call forwarding handler populates forwarded_for on the call.requested payload it constructs to send to the spoke. The hub reads its own OperationContext.identity (the end user it authenticated) and sets forwarded_for to that identity when forwarding. The spoke receives it as metadata on its OperationContext — available for logging, auditing, per-user rate limiting, but never used by AccessControl::check (the spoke authorizes the hub, its direct caller, not the end user). The hub may set forwarded_for: None if it doesn't want to disclose the originator. See ADR-032.

from_jsonschema

from_jsonschema was originally specified here (ADR-017 §5) as a schema-only adapter in alknet-call — a placeholder handler returning NOT_FOUND. That was broken: an op in the registry needs a real handler, and the "schema-only, no handler" concept conflated schema validation (a planning activity that doesn't need a registry entry) with operation registration (which always needs a handler).

ADR-066 moved from_jsonschema to alknet-http as an HTTP-backed single-endpoint adapter: the caller supplies an OperationSpec + HttpServiceConfig + path template + method, and the adapter builds one HandlerRegistration with a real reqwest forwarding handler and FromJsonSchema provenance. It is functionally similar to from_openapi but one endpoint at a time, for non-standard / non-OpenAPI / basic REST endpoints that don't have a full OpenAPI document. See crates/http/http-adapters.md §"from_jsonschema".

The schema-validation-without-a-handler use case (the original stated purpose) is served by consuming OperationSpec directly — the spec already carries the input/output JSON Schemas. No adapter, no registry entry, no handler is needed for that.

The FromJsonSchema provenance variant stays in alknet-call (OperationProvenance in registry/registration.rs); only the adapter implementation moved.

OperationAdapter trait

The shared shape across import-style adapters. The trait lives in alknet-call (where the types live); the implementations live where their transport dependencies live (see "Adapter Location Map" below).

#[async_trait]
pub trait OperationAdapter: Send + Sync {
    async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
}

The trait is async because from_call requires async discovery (services/list + services/schema over a call-protocol connection, which may be QUIC, TCP+TLS, or any other transport). Sync adapters (from_openapi, from_mcp reading a static spec) trivially satisfy an async trait — their import() bodies contain no .await points. This is locked by ADR-017 §5.

The error type (DC-4, OQ-26) is Result<Vec<HandlerRegistration>, AdapterError> where AdapterError is a crate-level enum covering the failure modes real implementations hit: discovery transport failure (from_call remote unreachable), schema parse failure (from_openapi, from_jsonschema), unauthorized (HTTP 401 for from_openapi, from_mcp). The exact AdapterError variants are the two-way-door remainder; the presence of an error type is filled in here. ADR-017 §5 showed async fn import(&self) -> Vec<HandlerRegistration> with no error type; the spec omitted the error type as an implementation-detail two-way door, recorded here.

Implementations:

  • FromCall — call-protocol-backed, transport-agnostic (in alknet-call). from_call discovers ops over a CallConnection, which may be QUIC, TCP+TLS, or any transport Connection::from_stream supports (ADR-065).
  • FromOpenAPI — HTTP-backed (in alknet-http).
  • FromJsonSchema — HTTP-backed, single-endpoint (in alknet-http per ADR-066; was a broken schema-only placeholder in alknet-call).
  • FromMCP — MCP streamable-HTTP-backed (in alknet-http, feature-gated).

The to_* adapters (to_openapi, to_mcp) are outbound projections, not OperationAdapter implementations — they consume the registry, they don't produce entries for it (ADR-017 §5).

Adapter Location Map

The decomposition principle: the adapter trait lives where the types live (alknet-call); the adapter implementations live where their transport dependencies live.

alknet-call (lean — no HTTP client, no HTTP server)
├── OperationAdapter trait          (the contract — async, per ADR-017 §5)
├── from_call                     (transport-agnostic — discovers remote ops via
│                                  call protocol over any Connection)
└── CallClient                    (outbound connection take-over — spawn_dispatch
                                  transport-agnostic, connect QUIC convenience)

alknet-http (owns HTTP server + HTTP client — separate crate, separate Phase 0)
├── ProtocolHandler for h2/http1.1/h3   (axum server — inbound HTTP)
├── from_openapi                   (parse OpenAPI doc + reqwest forwarding handler)
├── from_jsonschema                (single-endpoint reqwest forwarding handler — ADR-066)
├── to_openapi                     (generate OpenAPI doc from local registry)
├── from_mcp  (feature-gated)       (import remote MCP tools over streamable HTTP — reqwest)
└── to_mcp    (feature-gated)       (expose local ops as MCP tools over streamable HTTP — axum)

Not built: MCP stdio transport
  — stdio = spawn arbitrary executable = built-in RCE ("download untrusted MCP servers")
  — streamable HTTP is the only supported MCP transport in alknet
  — recorded as an explicit security position, not a feature gap

alknet-call never sees the HTTP client. The from_openapi/from_mcp forwarding handlers are opaque Arc<dyn Handler> from the registry's perspective — constructed by alknet_http::from_openapi() at registration time, stored in HandlerRegistration, dispatched by the CallAdapter which doesn't know reqwest is involved. alknet-call stays lean (no reqwest, no axum); alknet-http owns both HTTP directions.

ADR-003 dependency note: alknet-http implementing from_openapi/ from_mcp means alknet-http depends on alknet-call (for OperationSpec, Handler, HandlerRegistration, OperationAdapter). ADR-003's rule is "no handler crate depends on another handler crate" — but alknet-call is both a handler and the protocol foundation that alknet-agent and alknet-napi already consume. alknet-http depending on alknet-call is "HTTP uses the call protocol types," not "HTTP depends on SSH." This is within the spirit of ADR-003 (alknet-call is protocol-foundation, not a peer handler). The alknet-http spec should note this explicitly; a one-line amendment to ADR-003 clarifying that alknet-call is a protocol-foundation crate is deferred to the alknet-http Phase 0.

No-Env-Vars Invariant

The architectural mechanism for the env-var problem in downstream consumers (the Rust port of Vercel's AI SDK at /workspace/aisdk/, whose providers all read std::env::var("OPENAI_API_KEY") in their Default impls). The fix is not to modify those consumers — it's that the env-var path is never taken because the assembly layer never calls Default::default().

The credential injection path:

vault (seed)
  → assembly layer (derive + decrypt at startup, per ADR-014/019/025)
    → Capabilities (non-serializable, zeroized, immutable — ADR-014)
      → HandlerRegistration.capabilities (ADR-022, the registration bundle)
        → OperationContext.capabilities (per-request, populated by dispatch
          path from the bundle — ADR-022 §6)
          → from_openapi handler reads context.capabilities.get("openai")
            → injects into HTTP Authorization header
              → reqwest request goes out with vault-derived credential

The from_openapi/from_mcp forwarding handlers (in alknet-http) are the credential injection point. They read from context.capabilities, not from std::env::var. The downstream consumers' Default impls reading env vars are simply never called — the assembly layer constructs providers with vault-derived credentials through the builder API, or the provider's HTTP calls are routed through from_openapi operations that carry the credential in Capabilities.

This is a spec-level invariant in alknet-call, not a runtime convention. The dispatch path (build_root_context and OperationEnv::invoke() per ADR-022 §6) populates OperationContext.capabilities from the registration bundle. The invariant is: no handler reads outbound credentials from any source other than OperationContext.capabilities. This is already the architectural intent of ADR-014; this document records it as an explicit invariant that the from_openapi/from_mcp handler implementations (in alknet-http) are verified against.

Exchange-of-Operations Pattern (Runner / Container Service)

The canonical downstream pattern this completion unblocks, recorded here so Phase 1 specs can reference it. Concrete example: the container service at /workspace/@alkdev/dispatch (axum + russh SSH client for "reverse git runner" over Docker/vast.ai) gets rewritten as a call-protocol service.

Bilateral exchange:

Container service (runs on a vast.ai/docker instance):
  Defines Local ops: /container/exec, /container/list, /container/logs...
  (real handlers — calls bollard or vast.ai API)
  Connects to hub as a CallClient (outbound connection — runner pattern)

Hub (central server):
  Runs CallAdapter (server) on alknet/call (already implemented)
  When the container service connects:
    hub runs from_call → discovers /container/* via services/list + services/schema
    registers them as FromCall provenance (leaf, forwarding handlers) in the
    connection's Layer 2 overlay (ADR-024)
  Now the hub (or anything connected to the hub) can call /container/exec
  The from_call handler forwards over the connection back to the container service

Bilateral: the container service ALSO runs from_call against the hub,
  discovers the hub's External ops, and can call them.
  Connection direction (container → hub) is independent of call direction
  (both can call each other) per ADR-017 §2.

What this requires:

  1. CallClient — the container service uses it to open the outbound connection to the hub. The #1 gap.
  2. from_call — both sides run it to populate their Layer 2 overlays with the other side's External ops. The #2 gap.
  3. OperationAdapter trait — from_call implements it. The #3 gap (enabling, not blocking — from_call can be built as a free function before the trait exists, but the trait is needed for alknet-http's adapters).

Why the container service doesn't need alknet-ssh: under the call protocol, the container service is a CallClient that dials the hub's alknet/call ALPN (over QUIC, TCP+TLS, or any transport) — no SSH in the loop. SSH port forwarding becomes the transitional mechanism for targets that can't run a call-protocol client (the alknet-ssh phase-0 findings document this transition). Once the container service runs a CallClient, SSH is out of the path entirely.

This is the "dev runner" pattern: a call-protocol client that connects back to a hub and exposes core dev tools (bash, fs, etc.) as operations. The agent service (alknet-agent, downstream) is the consumer that orchestrates these via env.invoke().

Implementation Priority Order

Based on the gap analysis and the downstream unblock chain:

  1. CallClient (critical) — outbound connection opener. Without it, no runner, no container service, no bilateral exchange. Reuses the existing CallConnection for the dispatch loop; adds only the connection-establishment + credential-handling half. The single highest-value piece of work in the entire alknet-call completion.

  2. from_call (critical, depends on CallClient) — consumes the already-implemented services/list + services/schema discovery API.

  3. OperationAdapter trait (enabling) — the async trait. Small, standalone, unblocks alknet-http Phase 1 (including from_jsonschema per ADR-066).

  4. DC-1 resolution (peer-graph routing model, ADR-029) — the peer-keyed overlay + AccessControl-based peer authorization model that replaces ADR-028's remote_safe/trusted_peer. This is a structural change to CompositeOperationEnv (→ PeerCompositeEnv), the dispatch path (retire RemoteFilter), and OperationEnv (gain invoke_peer). See ADR-029 for the migration; the POC shapes in the research doc are the reference.

What This Completion Unblocks

Downstream crate What it needs from alknet-call Status without completion
alknet-http OperationAdapter trait (to implement from_openapi/from_mcp) Blocked — can't define HTTP-backed adapters without the trait
alknet-ssh Stable alknet-call types (no adapter dependency) Not blocked — ssh depends on alknet-core, not alknet-call's adapters. Proceeds in parallel.
alknet-agent CallClient (tool dispatch), from_call (remote tool import), OperationAdapter (provider adapters) Blocked on CallClient + from_call
Container service (dispatch rewrite) CallClient + from_call Blocked — this is the primary consumer
Runner pattern (dev runner, opencode runner) CallClient + from_call Blocked — the runner IS a CallClient
alknet-napi CallClient (Node.js calls remote ops) Blocked — NAPI projects CallClient to JS

Constraints

  • No HTTP in alknet-call. from_openapi/from_mcp/from_jsonschema/ to_openapi/to_mcp live in alknet-http. The OperationAdapter trait and the call-protocol-backed adapter (from_call, transport- agnostic) live in alknet-call. from_jsonschema was originally (mis)placed in alknet-call as a schema-only placeholder; ADR-066 moved it to alknet-http as a real HTTP-backed adapter. See Adapter Location Map.
  • No secret material on the wire. ConnectionCredentials carries vault-derived material for the outbound connection (TLS identity); auth_token is a per-request payload field (browsers send it in the WebSocket call payload; the HTTP gateway resolves bearer → Identity at its boundary). The call protocol's wire format carries no private keys, API keys, or decrypted credentials (ADR-014). The no-env-vars invariant (above) is the dispatch-side corollary.
  • Peer authorization via AccessControl. A remote peer's call is authorized by AccessControl::check(peer_identity) against the op's AccessControl — the same mechanism that gates every other call. No remote_safe flag, no trusted_peer bypass (ADR-029 §3). An op with AccessControl::default() is callable by any peer; an op with required_scopes is callable only by peers whose Identity.scopes satisfy them; an op with Visibility::Internal is never callable from the wire.
  • Composition env is peer-keyed. A head node with N worker connections holds a PeerCompositeEnv with connections: HashMap<PeerId, Arc<dyn OperationEnv>>, not a singular connection overlay. invoke_peer() routes to the right peer via PeerRef::Specific / PeerRef::Any (ADR-029 §1-2).
  • from_call is a manual free function. The assembly layer calls it after connect(). The overlay is per-connection so re-import on reconnect is naturally scoped (DC-2, OQ-27). See ADR-069.
  • from_call namespace collision is same-peer only. Cross-peer collision dissolves (same name on different peers is fine — separate sub-overlays, ADR-029 §5). Same-peer collision stays an error. namespace_prefix is optional local-naming sugar, not the disambiguation mechanism (DC-3, OQ-28).
  • OperationAdapter::import() returns Result. Failures surface as AdapterError (DC-4, OQ-26).
  • MCP stdio transport is not built. Streamable HTTP is the only supported MCP transport in alknet. stdio = spawn arbitrary executable = built-in RCE. Recorded as an explicit security position, not a feature gap.
  • Pure-client X.509 connections are not in the peer graph on the client side. A CallClient connection to a public X.509 endpoint with no local PeerEntry for the remote gets no PeerId, is not added to PeerCompositeEnv, and is not addressable via PeerRef::Specific. Ops discovered on it live in the connection's Layer 2 overlay and are invoked through the CallConnection handle. The client-side ServerCertVerifier uses CA verification (WebPkiServerVerifier) for such remotes; known peers (hub with PeerEntry) use fingerprint pinning. See ADR-034.
  • ConnectionCredentials.remote_identity: None is load-bearing. None means "no PeerEntry for this remote → use CA verification (X.509) or fail closed (Ed25519 raw key)" per the ADR-034 §3 verifier rule. The implementation must not default remote_identity to a placeholder to satisfy the field, and must not treat None as "skip verification" — None + X.509 is CA verification, None + raw key is a hard failure. Some(fingerprint) is the known-peer pin path.

Design Decisions

Decision ADR Summary
Call protocol client and adapter contract ADR-017 CallClient opens connections; from_call imports remote ops; connection direction independent of call direction; trait is async; adapters produce HandlerRegistration bundles. from_jsonschema clause superseded by ADR-066
from_jsonschema as HTTP-backed single-endpoint adapter in alknet-http ADR-066 Moved from_jsonschema from alknet-call (broken schema-only placeholder) to alknet-http as a real reqwest-backed single-endpoint adapter; FromJsonSchema provenance stays in alknet-call as a leaf
Peer-graph routing model (DC-1, supersedes ADR-028) ADR-029 Peer-keyed overlays + PeerRef routing; peer authorization via existing AccessControl::check(peer_identity); retires remote_safe/trusted_peer
PeerEntry and Identity.id decoupling ADR-030 PeerId source changes from UUID to Identity.id (= PeerEntry.peer_id, stable across key rotation); Identity.id decoupled from crypto material on the fingerprint path
Forwarded-for identity ADR-032 forwarded_for field on call.requested and OperationContext; the from_call handler populates it; metadata only, never used by AccessControl::check
Storage boundary and repo/adapter pattern ADR-033 Core defines repo traits + in-memory defaults; persistence adapters are separate crates
Peer-scoped registry filtering (superseded) ADR-028 Default-deny; remote_safe: bool; trusted-peer opt-in — superseded by ADR-029 (flat-namespace single-peer model couldn't express head→N-workers; parallel auth system duplicated existing AccessControl)
Secret material flow and capability injection ADR-014 The no-env-vars invariant's foundation; capabilities injected at assembly layer
Handler registration, provenance, and composition authority ADR-022 The registration bundle adapters produce; composition_authority: None for leaves
Operation registry layering ADR-024 Layer 2 per-connection overlay where from_call imports land
Privilege model and authority context ADR-015 Adapter-registered ops are Internal by default; default-deny posture
Abort cascade for nested calls ADR-016 Cross-node abort through from_call forwarding handler's parent_request_id
Operation error schemas ADR-023 error_schemas mirrored by from_call from remote op's spec
Streaming handler for subscriptions ADR-049 from_call Subscription ops register a StreamingHandler (HandlerKind::Stream) that calls CallConnection::subscribe() and forwards the remote stream; Query/Mutation stay HandlerKind::Once
TLS identity redesign ADR-027 RFC 7250 raw key / X.509 cert dimensions of the local TlsIdentity (now carried by ConnectionCredentials.local_identity)
Outgoing-only X.509 and three peer roles ADR-034 Public X.509 endpoint is not a PeerEntry on the client side (no PeerId, not in peer graph); client-side verifier by PeerEntry presence (CA vs fingerprint pin); hub = mixed-fingerprint PeerEntry
HD derivation for encryption keys ADR-020 Vault-derived TLS identity material
Vault key model ADR-026 Vault-derived TLS identity material
Vault local-only dispatch ADR-025 Vault access at assembly layer only; the credential injection path's first hop
Crate decomposition ADR-003 alknet-http depends on alknet-call (protocol-foundation exception, noted in Adapter Location Map)
One-way door decision framework ADR-009 Door-type classification for DC-1..4

Open Questions

See open-questions.md for full details.

  • OQ-25 (dissolved by ADR-029): remote_safe marking shape — moot. remote_safe/trusted_peer are retired; peer authorization is AccessControl::check(peer_identity). No marking to shape.
  • OQ-26 (resolved): AdapterError variants — DiscoveryFailed, SchemaParse, Transport, Unauthorized, SamePeerCollision (replaces flat Conflict). #[non_exhaustive].
  • OQ-27 (resolved): from_call re-import trigger — from_call is a manual free function; the assembly layer calls it after connect(). A CallConnection::refresh() method is a genuine feature addition — non-breaking, additive. See ADR-069.
  • OQ-28 (resolved): from_call namespace collision — same-peer collision = error; cross-peer dissolved by ADR-029 (separate sub-overlays). namespace_prefix is optional local-naming sugar.
  • OQ-29 (resolved): CallClient TLS client-auth — wire quinn client-auth (present Ed25519 key as raw public key client cert); key-type-aware server cert verification (raw key = fingerprint match, X.509 = CA verification); fingerprint normalization (ed25519: across quinn/iroh). The iroh path already works; the gap was quinn-only. See OQ-29 in open-questions.md.
  • OQ-30 (resolved): PeerRef::Any routing policy — insertion-order first-match. A richer RoutingPolicy is a feature extension.
  • OQ-31 (resolved): services/list-peers — opt-in; services/list is "own ops only."
  • OQ-32 (open, feature extension): Multi-hop federation — the one-hop model is the architectural commitment; multi-hop is a feature extension that doesn't break downstream. The peer-keyed model extends to multi-hop without redesign; petgraph is the candidate if path-finding becomes real (ADR-029 §3.7).
  • OQ-33 (resolved by ADR-030): PeerId is a logical id. Source is Identity.id from IdentityProvider resolution (= PeerEntry.peer_id, stable across key rotation), not a connection-assigned UUID. The UUID workaround is removed. See OQ-33 in open-questions.md.
  • OQ-34 (resolved by ADR-030 + ADR-033): Persistent peer registry — the storage boundary is core trait + in-memory default (config-backed ConfigIdentityProvider now; persistence adapters additive in separate crates). See OQ-34 in open-questions.md.
  • OQ-35 (dissolved): the "API key asymmetry" framing was wrong; PeerEntry supports multiple credential paths (fingerprints + auth_token_hash), ApiKeyEntry is for tokens that ARE the identity. See OQ-35 in open-questions.md.
  • OQ-36 (resolved by ADR-035): Concrete persistence adapter shapes — read-sync / write-async split (IdentityStore async write trait extends the sync IdentityProvider read trait); SQLite adapter caches in memory and uses honker NOTIFY/LISTEN for no-restart cache invalidation; alknet-store-sqlite crate implements both IdentityStore and CredentialStore. See ADR-035 and OQ-36 in open-questions.md.
  • OQ-37 (resolved by ADR-034): X.509 outgoing-only case — three remote roles named (public X.509 endpoint, transport relay, hub). PeerEntry asymmetry is correct: a pure-client connection to a public X.509 endpoint is not in the call-protocol peer graph on the client side — no PeerEntry, no PeerId, no PeerRef::Specific routing. Ops discovered via from_call/from_openapi/from_mcp land in the connection's Layer 2 overlay and are invoked through the connection handle. The client-side ServerCertVerifier is selected by PeerEntry presence: known peer → fingerprint pin; unknown X.509 remote → CA verification (WebPkiServerVerifier). See ADR-034 and OQ-37 in open-questions.md.

References

  • ADR-017: Call Protocol Client and Adapter Contract (the spec this document operationally fills)
  • ADR-029: Peer-Graph Routing Model (supersedes ADR-028; resolves DC-1 with peer-keyed overlays + AccessControl-based peer authorization)
  • ADR-028: Peer-Scoped Registry Filtering (superseded by ADR-029)
  • call-protocol.md — CallAdapter, CallConnection, dispatch loop, stream model (the server-side complement to this document)
  • operation-registry.md — HandlerRegistration, provenance, capability injection, service discovery (the discovery API from_call consumes)
  • docs/research/alknet-call-completion/gap-analysis.md — DC-1..4, the implementation-state audit, the downstream unblock chain
  • docs/research/alknet-call-peer-routing/findings.md — the peer-graph routing research that identified ADR-028's structural gap and validated the ADR-029 design via POC
  • /workspace/@alkdev/operations/ — TypeScript prior art (from_openapi.ts, from_mcp.ts, from_schema.ts, scanner.ts)
  • /workspace/@alkdev/dispatch/ — concrete downstream consumer (container service / "reverse git runner") this completion unblocks
  • /workspace/aisdk/ — downstream consumer (Rust port of Vercel AI SDK); the no-env-vars invariant makes its std::env::var reads unreachable
  • /workspace/rust-sdk/ — MCP Rust SDK (rmcp); streamable HTTP transport for alknet-http's from_mcp/to_mcp (separate crate, separate Phase 0)
  • docs/research/alknet-ssh/phase-0-findings.md — alknet-ssh Phase 0; confirms ssh depends on alknet-core not alknet-call's adapters, so it proceeds in parallel with this completion