Files
alkcall/docs/architecture/client-and-adapters.md
T
glm-5.3-flash 5f019c02f1 docs: re-key architecture docs off the alknet mono-repo — speak as alkcall
The ported docs still framed alknet siblings (alknet-core/-call/-channels/
-http/-tls/-client) as live load-bearing context of this crate. With the
extraction complete and alknet headed for glue-level replacement, the docs
now speak as alkcall and cite the real sibling crates (alkhttp, alktls,
alktty, alktunnels, alktrader).

- ADR-054 (new): re-own the verifier-selection rule in-crate — three
  remote roles (public X.509 endpoint / transport relay / hub),
  ServerCertVerifier by PeerEntry presence, RemoteIdentity None
  load-bearing. Provenance: alknet ADR-034 (never ported), demoted to
  footnote. Resolves OQ-21's cross-repo authority gap.
- OQ-43 (new, deferred(scope)): composition-layer ownership and shape
  after the alknet replacement — the named receiver for the
  downstream-side deferrals (OQ-33 endpoint layer, OQ-41 QUIC-native
  substrate, OQ-42 hub broker, OQ-19 persistence adapters, ADR-045 dial
  seam). README layering diagram redrawn with the composition layer as
  TBD-ownership instead of "alknet / alknode" in concrete.
- Spec docs reframed: titles, self-references, dial/TLS/from_jsonschema
  homes, malformed legacy cite rows removed; dead relative research
  paths qualified as source-repo paths.
- ADR amendments (append-only): 027 (alknet-http -> alkhttp), 031 (post-
  extraction state), 044 (split landed as modules in alkcall), 045
  (name normalization; citation-space note).
- Citation hygiene: unprefixed/colliding source-repo cites prefixed
  ("alknet ADR-NNN (provenance)") across 009/011/022/034/035/038/040/
  043/044/045; stale OQ cites corrected (session registries OQ-04, batch
  semantics OQ-03, verifier rule ADR-054); stale anchors fixed.
- README provenance note consolidated; citation conventions documented.

Verification: markdown link check (all local links resolve, all in-crate
ADR cites point at existing decisions/ files); table integrity check
(uniform row widths across open-questions.md and edited tables); residual
alknet-mention sweep (only provenance/historical/intentional framing
remains); architecture review subagent pass with zero remaining criticals.
Docs-only change — no code paths touched.
2026-10-13 10:53:47 +00:00

51 KiB

status, last_updated
status last_updated
draft 2026-10-13 (alkcall reframing)

alkcall — 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 the HTTP host crate — today alkhttp (then named 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 alkcall:

  1. CallClient — takes over an established transport Connection on ALPN alk/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 the call protocol to the HTTP host crate 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. It lives in alkhttp; its full spec is alkhttp's http-adapters.md (original source-repo copy at /workspace/@alkdev/alknet/docs/architecture/crates/http/http-adapters.md, provenance). The FromJsonSchema provenance variant stays in alkcall (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 alkcall vs alkhttp, 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 alkhttp'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 (source-repo research, now at /workspace/@alkdev/alknet/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 (source-repo research, now at /workspace/@alkdev/alknet/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-12, DC-3→OQ-13 cross-peer dissolved / same-peer stays, DC-4→OQ-11).

Architecture

CallClient

CallClient takes over an established transport Connection on ALPN alk/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 the composition layer's dial helper, AlknetClient (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 `alk/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 the composition layer's dial helper, AlknetClient (ADR-045): keeping a QUIC convenience constructor on CallClient would make alkcall depend on the dial crate, 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 the composition layer (AlknetClient today — future owner OQ-43).

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 (vendored core types in alkcall, 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 (vendored core in alkcall, 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 alknet ADR-034 §2 — the verifier-rule ADR, re-owned as ADR-054) 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-054. 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 with the vendored core types in alkcall, per ADR-012.

  • TLS identity — the local node's Ed25519 raw key (RFC 7250) or X.509 cert, derived from the vault at startup (vault ADRs live in the source repo — alknet ADR-020/026/027, provenance).
  • 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-054). 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), and that ConnectionCredentials lives with the vendored core types in alkcall (ADR-012). TLS client-auth presentation (OQ-14 #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-14 #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-14 #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-14 #2 + ADR-054 §2, re-owned from the source repo's ADR-034): 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-054 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-13),
    /// 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.
      • Pub → the sink forwarding shape (registered as HandlerKind::Sink, ADR-046 + ADR-022 amendment 2026-10-13): a real forwarding body, not an advertised-always-failing stub. A local publish() on the imported op streams the call.published chunks across the connection; the remote peer's SinkHandler consumes them.
    • provenance: FromCall, composition_authority: None, scoped_env: None (leaf — ADR-018).
  4. The caller registers the bundles via CallConnection::register_imported_all().

Marked ops are not importable (ADR-047 amendment 4 §4.8): from_call's discovery reconstructs the channel_open marker (ADR-047 Gap F) so the importing side can report the remote channel-machinery ops, but register_imported / register_imported_all refuse marked specs — the marker's data plane is the allocated channel's BiStream, which has no overlay path (OperationEnv::invoke is request/response only, ADR-046 §6). A hub that needs a marked op composes it with the channel machinery instead: ChannelRelay::register_relay_openable (ADR-051) on the relaying side, or direct consumption via ChannelClient (ADR-043). Likewise op/register refuses a marked announcement with MARKED_SPEC_UNSUPPORTED (ADR-022 amendment 2026-10-13). This resolves OQ-37: there is no from_call relay wrapper for marked ops — the relay composition is the sanctioned path.

Re-import on reconnection (DC-2, OQ-12): from_call is a free function; the assembly layer calls it after the dial (in the composition layer's dial helper, AlknetClient — today the alknet glue; OQ-43). 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-13): 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 the call protocol — 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 the HTTP host crate — today alkhttp (ex-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 alkhttp's http-adapters.md §"from_jsonschema" (its original source-repo copy at /workspace/@alkdev/alknet/docs/architecture/crates/http/http-adapters.md, provenance).

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 alkcall (OperationProvenance in registry/registration.rs); only the adapter implementation moved.

OperationAdapter trait

The shared shape across import-style adapters. The trait lives in alkcall (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-11) 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 alkcall). 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 alkhttp).
  • FromJsonSchema — HTTP-backed, single-endpoint (in alkhttp per ADR-027; was a broken schema-only placeholder in the call protocol).
  • FromMCP — MCP streamable-HTTP-backed (in alkhttp, 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 (alkcall); the adapter implementations live where their transport dependencies live.

alkcall (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
                                  the composition layer's helper, AlknetClient)

alkhttp (owns HTTP server + HTTP client — sibling host crate)
├── 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 the alk stack
  — recorded as an explicit security position, not a feature gap

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

ADR-031 dependency note: alkhttp implementing from_openapi/ from_mcp means alkhttp depends on alkcall (for OperationSpec, Handler, HandlerRegistration, OperationAdapter). ADR-031's rule is "no handler crate depends on another handler crate" — but alkcall is the protocol foundation that every downstream consumer (including the HTTP host and any agent crate) builds on. alkhttp depending on alkcall is "HTTP uses the call protocol types," not "HTTP depends on a sibling handler." This is within the spirit of ADR-031 (alkcall is protocol-foundation, not a peer handler).

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 alkhttp) 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 alkcall, 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 alkhttp) 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 alk/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 alkhttp's adapters).

Why the container service doesn't need SSH: under the call protocol, the container service is a CallClient that dials the hub's alk/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 source repo's SSH phase-0 findings — alknet, provenance — 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 crate (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 call-completion effort.

  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 alkhttp's adapter work (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

Historical planning table (source-repo era; crate names as they were). The completion has since landed in this crate — alknet-http is now alkhttp.

Downstream crate What it needs from alkcall Status without completion
alknet-http (→ alkhttp) OperationAdapter trait (to implement from_openapi/from_mcp) Blocked — can't define HTTP-backed adapters without the trait
alknet-ssh (historical) Stable alkcall types (no adapter dependency) Not blocked — ssh depends on the vendored core types, not alkcall's adapters. Proceeds in parallel.
alknet-agent (historical) 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 (historical) CallClient (Node.js calls remote ops) Blocked — NAPI projects CallClient to JS

Constraints

  • No HTTP in alkcall. from_openapi/from_mcp/from_jsonschema/ to_openapi/to_mcp live in alkhttp. The OperationAdapter trait and the call-protocol-backed adapter (from_call, transport- agnostic) live in alkcall. from_jsonschema was originally (mis)placed in the call protocol as a schema-only placeholder; ADR-027 moved it to the HTTP host crate 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 the composition layer's dial helper, AlknetClient — today the alknet glue; OQ-43). The overlay is per-connection so re-import on reconnect is naturally scoped (DC-2, OQ-12). 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-13).
  • OperationAdapter::import() returns Result. Failures surface as AdapterError (DC-4, OQ-11).
  • MCP stdio transport is not built. Streamable HTTP is the only supported MCP transport in the alk stack. 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-054.
  • 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 verifier rule (ADR-054). 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 ADR-027 Moved from_jsonschema from the call protocol (broken schema-only placeholder) to the HTTP host crate as a real reqwest-backed single-endpoint adapter — today alkhttp (ex-alknet-http); FromJsonSchema provenance stays in alkcall 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 alknet ADR-033 (provenance) Core defines repo traits + in-memory defaults; persistence adapters are separate crates (composition-layer work — OQ-43)
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 alknet 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 remote roles ADR-054 (re-owned; provenance: alknet 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 alknet ADR-020 (provenance — vault ADRs live in the source repo) Vault-derived TLS identity material
Vault key model alknet ADR-026 (provenance) Vault-derived TLS identity material
Vault local-only dispatch alknet ADR-025 (provenance) Vault access at assembly layer only; the credential injection path's first hop
Crate decomposition ADR-031 alkhttp depends on alkcall (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-10 (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-11 (resolved): AdapterError variants — DiscoveryFailed, SchemaParse, Transport, Unauthorized, SamePeerCollision (replaces flat Conflict). #[non_exhaustive].
  • OQ-12 (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-13 (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-14 (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-14 in open-questions.md.
  • OQ-15 (resolved): PeerRef::Any routing policy — insertion-order first-match. A richer RoutingPolicy is a feature extension.
  • OQ-16 (resolved): services/list-peers — opt-in; services/list is "own ops only."
  • OQ-17 (deferred(scope), 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-18 (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-18 in open-questions.md.
  • OQ-19 (resolved by ADR-025): Persistent peer registry — the storage boundary is core trait + in-memory default (config-backed ConfigIdentityProvider now; persistence adapters additive in separate crates). See OQ-19 in open-questions.md.
  • OQ-20 (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-20 in open-questions.md.
  • OQ-21 (resolved by ADR-054): 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). The rule is re-owned in-crate by ADR-054 (provenance: alknet ADR-034, "Outgoing-Only X.509 and Three Peer Roles", not ported); ADR-025's PeerEntry asymmetry is the registry surface. See OQ-21 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.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)
  • Source-repo research (alknet, provenance — these paths live in /workspace/@alkdev/alknet/docs/research/, not this repo):
    • alknet-call-completion/gap-analysis.md — DC-1..4, the implementation-state audit, the downstream unblock chain
    • 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
    • alknet-ssh/phase-0-findings.md — SSH Phase 0; confirms ssh depends on the vendored core types, not alkcall's adapters, so it proceeds in parallel with the completion
  • /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 alkhttp's from_mcp/to_mcp