Files
alkcall/docs/architecture/client-and-adapters.md
glm-5.2 cc470a363a docs: port architecture specs + 45 ADRs from alknet, renumbered
Port the call + channels architecture documentation from the alknet
mono-repo into docs/architecture/, renumbered as alkcall ADR-001..045.

Renumbering map (alknet -> alkcall):
  Core:        001,002,004,006,007,011,065,070,092,014,050,091 -> 001-012
  Call:        005,064,012,023,015,022,024,016,049,017,028,029,030,032,066,069,067,068 -> 013-030
  Shared:      003,009,013 -> 031-033
  Channels:    071,093,072,073,074,075,076,094,079,080,081,089 -> 034-045

3 superseded/reversed ADRs kept for historical trail:
  - ADR-013 (irpc foundation, superseded by ADR-014)
  - ADR-023 (peer-scoped filtering, superseded by ADR-024)
  - ADR-077 (TTY inside channels, reversed by ADR-035 — not ported, TTY-only)

Ported docs (11 spec files + README + open-questions):
  - call-README.md, call-protocol.md, operation-registry.md, client-and-adapters.md
  - channels-README.md, channels-overview.md, channels-wire.md, channels-connection.md, channels-adapter.md, channel-operations.md, channel-client.md
  - README.md (index with doc table, ADR table grouped by category, key principles)
  - open-questions.md (lean — 30 OQs, renumbered OQ-01..030; includes new OQ-22 for the pub/sub gap)

Cross-reference rewriting:
  - All ADR-NNN references rewritten single-pass (no chaining bug)
  - Markdown link paths fixed
  - Title lines aligned with filenames
  - Non-ported ADR refs (052, 082, 086, etc.) left as-is with README note

The open-questions.md includes OQ-22 (new): the call protocol pub/sub
gap — subscribe exists but pub does not, needed for channels
channel/resources/subscribe fan-out. This is the next ADR to write
(alkcall ADR-046).
2026-08-12 07:06:57 +00:00

49 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-022 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-027 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; dial lives in AlknetClient per ADR-045); the dispatch loop is shared with the server-side CallAdapter (ADR-022 §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-027 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-022 specced this surface. This document is the spec that operationally fills the gap ADR-022 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-023 (remote_safe/trusted_peer), but a subsequent research pass (docs/research/alknet-call-peer-routing/findings.md) found that ADR-023'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-024 supersedes ADR-023: 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-007 Connection::from_stream / from_bidi). The primary constructor (spawn_dispatch) takes a pre-established Connection from any transport; the dial lives in AlknetClient (alknet-client, ADR-045). This mirrors ChannelClient::from_connection (ADR-043) and is the client-side analogue of the server-side generalization ADR-007 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-022 Am. 2026-07-13) — it must not be coupled to
    /// a transport.
    pub fn spawn_dispatch(&self, connection: Connection) -> CallConnection;
}

Peer authorization flows through the existing AccessControl::check against the peer's resolved Identity (ADR-024 §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-024 §3 for the full mapping of the three remote_safe cases to AccessControl/Visibility.

The connection is symmetric after establishment (ADR-022 §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-022 §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-024 §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. The dial lives in AlknetClient (alknet-client, ADR-045): 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-043) and is the client-side analogue of the server-side generalization ADR-007 made. The call protocol, like the channels protocol, is transport-agnostic — Connection::from_stream / from_bidi (ADR-007) accept any AsyncRead + AsyncWrite, and spawn_dispatch takes the resulting Connection unchanged.

Peer-keyed composition env (ADR-024)

The composition env that aggregates multiple connections is peer-keyed (ADR-024 §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-025)

OperationEnv gains a peer-routing method with a PeerRef selector (Specific(PeerId) / Any), default-impl for back-compat. See ADR-024 §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. There is a single AccessControl-filtered handler (no peer_scoped variant, no remote_safe filter — both retired by ADR-024). 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-024 §6.

Credential sources for connections

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

  • ConnectionCredentials (in alknet-core, per ADR-012) — 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). See ADR-012 for the credential-bundle decoupling.

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

// Transport-level (alknet-core, consumed by the dial — ADR-012)
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-022 §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. The transport dimensions (local_identity, remote_identity) are in ConnectionCredentials in alknet-core per ADR-012.

  • 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-010).

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-025 §5). Without it, the ADR-024 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-010) 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-024). Ops discovered via from_call on such a connection land in the connection's Layer 2 overlay (ADR-019) 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-022 §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-016).
  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-021):
      • 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-020). 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-018).
  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 the dial (in AlknetClient). The overlay is per-connection (Layer 2, ADR-019), 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-028.

Namespace collision (DC-3, OQ-28): under the peer-graph model (ADR-024), 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-017) 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-020) crosses the node boundary transparently through the forwarding handler's parent_request_id.

Forwarded-for identity (ADR-026): 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-026.

from_jsonschema

from_jsonschema was originally specified here (ADR-022 §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-027 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-022 §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-022 §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-007).
  • FromOpenAPI — HTTP-backed (in alknet-http).
  • FromJsonSchema — HTTP-backed, single-endpoint (in alknet-http per ADR-027; 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-022 §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-022 §5)
├── from_call                     (transport-agnostic — discovers remote ops via
│                                  call protocol over any Connection)
└── CallClient                    (outbound connection take-over —
                                  spawn_dispatch, transport-agnostic; dial in AlknetClient)

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-027)
├── 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-031 dependency note: alknet-http implementing from_openapi/ from_mcp means alknet-http depends on alknet-call (for OperationSpec, Handler, HandlerRegistration, OperationAdapter). ADR-031'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-031 (alknet-call is protocol-foundation, not a peer handler). The alknet-http spec should note this explicitly; a one-line amendment to ADR-031 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-010/019/025)
    → Capabilities (non-serializable, zeroized, immutable — ADR-010)
      → HandlerRegistration.capabilities (ADR-018, the registration bundle)
        → OperationContext.capabilities (per-request, populated by dispatch
          path from the bundle — ADR-018 §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-018 §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-010; 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-019)
  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-022 §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-027).

  4. DC-1 resolution (peer-graph routing model, ADR-024) — the peer-keyed overlay + AccessControl-based peer authorization model that replaces ADR-023'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-024 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-027 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-010). 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-024 §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-024 §1-2).
  • from_call is a manual free function. The assembly layer calls it after the dial (in AlknetClient). The overlay is per-connection so re-import on reconnect is naturally scoped (DC-2, OQ-27). See ADR-028.
  • from_call namespace collision is same-peer only. Cross-peer collision dissolves (same name on different peers is fine — separate sub-overlays, ADR-024 §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-022 CallClient opens connections; from_call imports remote ops; connection direction independent of call direction; trait is async; adapters produce HandlerRegistration bundles
from_jsonschema as HTTP-backed single-endpoint adapter in alknet-http ADR-027 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-023) ADR-024 Peer-keyed overlays + PeerRef routing; peer authorization via existing AccessControl::check(peer_identity); retires remote_safe/trusted_peer
PeerEntry and Identity.id decoupling ADR-025 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-026 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
Secret material flow and capability injection ADR-010 The no-env-vars invariant's foundation; capabilities injected at assembly layer
Handler registration, provenance, and composition authority ADR-018 The registration bundle adapters produce; composition_authority: None for leaves
Operation registry layering ADR-019 Layer 2 per-connection overlay where from_call imports land
Privilege model and authority context ADR-017 Adapter-registered ops are Internal by default; default-deny posture
Abort cascade for nested calls ADR-020 Cross-node abort through from_call forwarding handler's parent_request_id
Operation error schemas ADR-016 error_schemas mirrored by from_call from remote op's spec
Streaming handler for subscriptions ADR-021 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-031 alknet-http depends on alknet-call (protocol-foundation exception, noted in Adapter Location Map)
One-way door decision framework ADR-032 Door-type classification for DC-1..4

Open Questions

See open-questions.md for full details.

  • OQ-25 (dissolved by ADR-024): 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 the dial (in AlknetClient). A CallConnection::refresh() method is a genuine feature addition — non-breaking, additive. See ADR-028.
  • OQ-28 (resolved): from_call namespace collision — same-peer collision = error; cross-peer dissolved by ADR-024 (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-024 §3.7).
  • OQ-33 (resolved by ADR-025): PeerId is a logical id. Source is Identity.id from IdentityProvider resolution (= PeerEntry.peer_id, stable across key rotation). See OQ-33 in open-questions.md.
  • OQ-34 (resolved by ADR-025 + 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-022: Call Protocol Client and Adapter Contract (the spec this document operationally fills)
  • ADR-024: Peer-Graph Routing Model (resolves DC-1 with peer-keyed overlays
    • AccessControl-based peer authorization)
  • call-protocol.mdCallAdapter, CallConnection, dispatch loop, stream model (the server-side complement to this document)
  • operation-registry.mdHandlerRegistration, 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-023's structural gap and validated the ADR-024 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