--- status: draft last_updated: 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`](../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. ```rust pub struct CallClient { registry: Arc, identity_provider: Arc, } impl CallClient { pub fn new(registry: Arc, idp: Arc) -> 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](decisions/029-peer-graph-routing-model.md) §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). ```rust /// 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, pub identity_provider: Arc, pub session_source: Option>, 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>` is replaced by `PeerCompositeEnv` with peer-keyed connections: ```rust pub struct PeerCompositeEnv { pub base: Arc, // Layer 0 curated pub session: Option>, // Layer 1 pub connections: HashMap>, // Layer 2, peer-keyed connection_order: Vec, // 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](decisions/029-peer-graph-routing-model.md) §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](decisions/029-peer-graph-routing-model.md) §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): ```rust // Transport-level (alknet-core, consumed by the dial — ADR-012) pub struct ConnectionCredentials { pub local_identity: Option, // RFC 7250 raw key or X.509 pub remote_identity: Option, // 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. ```rust 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](decisions/034-outgoing-only-x509-and-three-peer-roles.md) 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. ```rust 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, /// Optional filter — import only operations whose names match. None /// imports all External ops discovered via services/list. pub operation_filter: Option>, } /// 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, 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` (the client-side streaming path, already implemented), maps it to a `BoxStream`. 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](decisions/069-from-call-manual-free-function.md). **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](decisions/032-forwarded-for-identity.md). ### 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](decisions/066-from-jsonschema-as-http-adapter.md) 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`](../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). ```rust #[async_trait] pub trait OperationAdapter: Send + Sync { async fn import(&self) -> Result, 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, 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` 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` 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>`, 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](decisions/069-from-call-manual-free-function.md). - **`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](decisions/034-outgoing-only-x509-and-three-peer-roles.md). - **`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](decisions/017-call-protocol-client-and-adapter-contract.md) | `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](decisions/066-from-jsonschema-as-http-adapter.md) | 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](decisions/029-peer-graph-routing-model.md) | 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](decisions/030-peerentry-and-identity-id-decoupling.md) | `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](decisions/032-forwarded-for-identity.md) | `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](decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Core defines repo traits + in-memory defaults; persistence adapters are separate crates | | Secret material flow and capability injection | [ADR-010](decisions/014-secret-material-flow-and-capability-injection.md) | The no-env-vars invariant's foundation; capabilities injected at assembly layer | | Handler registration, provenance, and composition authority | [ADR-018](decisions/022-handler-registration-provenance-and-composition-authority.md) | The registration bundle adapters produce; `composition_authority: None` for leaves | | Operation registry layering | [ADR-019](decisions/024-operation-registry-layering.md) | Layer 2 per-connection overlay where `from_call` imports land | | Privilege model and authority context | [ADR-017](decisions/015-privilege-model-and-authority-context.md) | Adapter-registered ops are `Internal` by default; default-deny posture | | Abort cascade for nested calls | [ADR-020](decisions/016-abort-cascade-for-nested-calls.md) | Cross-node abort through `from_call` forwarding handler's `parent_request_id` | | Operation error schemas | [ADR-016](decisions/023-operation-error-schemas.md) | `error_schemas` mirrored by `from_call` from remote op's spec | | Streaming handler for subscriptions | [ADR-021](decisions/049-streaming-handler-for-subscriptions.md) | `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](decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | 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](decisions/034-outgoing-only-x509-and-three-peer-roles.md) | 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](decisions/020-hd-derivation-for-encryption-keys.md) | Vault-derived TLS identity material | | Vault key model | [ADR-026](decisions/026-vault-key-model-hd-derivation.md) | Vault-derived TLS identity material | | Vault local-only dispatch | [ADR-025](decisions/025-vault-local-only-dispatch.md) | Vault access at assembly layer only; the credential injection path's first hop | | Crate decomposition | [ADR-031](decisions/003-crate-decomposition.md) | `alknet-http` depends on `alknet-call` (protocol-foundation exception, noted in Adapter Location Map) | | One-way door decision framework | [ADR-032](decisions/009-one-way-door-decision-framework.md) | Door-type classification for DC-1..4 | ## Open Questions See [open-questions.md](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](decisions/069-from-call-manual-free-function.md). - **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.md` — `CallAdapter`, `CallConnection`, dispatch loop, stream model (the server-side complement to this document) - `operation-registry.md` — `HandlerRegistration`, provenance, capability injection, service discovery (the discovery API `from_call` consumes) - `docs/research/alknet-call-completion/gap-analysis.md` — DC-1..4, the implementation-state audit, the downstream unblock chain - `docs/research/alknet-call-peer-routing/findings.md` — the peer-graph routing research that identified ADR-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