ADR-091 decided `ConnectionCredentials.local_identity`; the code implemented
`tls_identity` (tasks/core/connection-credentials.md deferred the rename as
"path of least resistance" during the extraction). The tangle that made the
rename hard no longer exists, so align the code with the decision.
Scope is the `ConnectionCredentials` field + builder only:
- alknet-core/credentials.rs: field, with_local_identity, doc, test
- alknet-tls/client.rs: field access in TlsClientConfig::new, test builders, docs
- alknet-client/dial/quinn.rs: test builder
NOT renamed (distinct concepts sharing the words):
- StaticConfig.tls_identity (server-side static config; ADR-082/027/083)
- TlsIdentity enum type name
- alknet-tls server fn params named tls_identity (&TlsIdentity value)
Also fixes dial_iroh.rs doc comments that claimed the local key is extracted
from creds.local_identity — the key is actually on the pre-built iroh endpoint
(set at with_iroh time); the dial reads only creds.remote_identity and ignores
creds.local_identity (per client/README.md §iroh).
Architecture specs updated to match (call/client-and-adapters.md, tls/README.md,
client/README.md). Historical ADR context describing the old CallCredentials
field stays as-is; tasks/ and docs/research/ are historical artifacts.
Resolves follow-up #2 from the post-extraction spec sync (c6eef73).
51 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-07-17 |
alknet-call — Client and Adapters
The outbound half of the call protocol: opening connections, importing remote
operations, and the adapter contract that ties import-style adapters together.
This document covers what ADR-017 specced but the server-side implementation
(call-protocol.md, operation-registry.md) did not include — the CallClient
that opens a connection, the from_call adapter, and the
OperationAdapter trait. (from_jsonschema was originally specced here
too, but ADR-066 moved it to alknet-http — see §"from_jsonschema" below.)
The server-side CallAdapter and CallConnection
dispatch loop are covered in call-protocol.md; this document covers the
client-side connection-establishment half and the adapter surface.
What
This document specifies three components, all in alknet-call:
CallClient— takes over an established transportConnectionon ALPNalknet/call, spawns the shared dispatch loop, and produces aCallConnection. Transport-agnostic (spawn_dispatchprimary;connectremoved per ADR-089 §5 — dial extracted toAlknetClient); the dispatch loop is shared with the server-sideCallAdapter(ADR-017 §1);CallClientis the connection-take-over half, not a parallel protocol implementation.from_call— discovers operations on a remote call-protocol endpoint viaservices/list+services/schema(already implemented inregistry/discovery.rs) and registers them in the connection's Layer 2 overlay asFromCall-provenance leaves with forwarding handlers.OperationAdaptertrait — the async trait thatfrom_call,from_openapi,from_mcp, andfrom_jsonschemaall implement.
from_jsonschemamoved. ADR-066 movedfrom_jsonschemafromalknet-calltoalknet-httpand 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 tofrom_openapibut one endpoint at a time. Seecrates/http/http-adapters.md§"from_jsonschema". TheFromJsonSchemaprovenance variant stays inalknet-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-callvsalknet-http, and why. - The no-env-vars invariant — the architectural mechanism by which
downstream consumers'
std::env::varcredential reads are made unreachable.
And one downstream pattern this completion unblocks:
- The exchange-of-operations pattern (runner / container service) — the canonical bilateral composition this client surface enables.
Why
The server-side CallAdapter (accept path) and CallConnection (dispatch
loop) are implemented and tested. The client side is the #1 gap blocking every
downstream consumer: the runner pattern (a process that connects outward to a
hub and exposes local ops), the container-service rewrite, the bilateral
exchange, the NAPI projection, and the agent's cross-node tool dispatch all
require a CallClient. from_call is the #2 gap; the OperationAdapter
trait is the enabling gap for alknet-http's from_openapi/from_mcp.
ADR-017 specced this surface. This document is the spec that operationally
fills the gap ADR-017 left to implementation: the CallClient API, the
from_call flow, the trait signature, the adapter location, the credential
invariant, and the bilateral pattern. The gap
analysis (docs/research/alknet-call-completion/gap-analysis.md) identified
four decisions (DC-1..4) needed before implementation. DC-1 was initially
resolved by ADR-028 (remote_safe/trusted_peer), but a subsequent research
pass (docs/research/alknet-call-peer-routing/findings.md) found that
ADR-028's model was structurally broken for the head→N-workers pattern (the
primary use case) and that its parallel remote_safe/trusted_peer
authorization system duplicated the existing AccessControl/Identity
machinery. ADR-029 supersedes ADR-028: peer-keyed overlays + PeerRef
routing, and peer authorization through the existing AccessControl::check(peer_identity).
DC-2/3/4 are two-way-door defaults recorded here (DC-2→OQ-27, DC-3→OQ-28
cross-peer dissolved / same-peer stays, DC-4→OQ-26).
Architecture
CallClient
CallClient takes over an established transport Connection on ALPN
alknet/call, spawns the shared dispatch loop, and produces a
CallConnection. The CallConnection type is already implemented
(call-protocol.md §"CallConnection") — it wraps an established
Connection and holds the Layer 2 imported-ops overlay. CallClient
is the producer on the outbound side; CallAdapter's accept path is
the producer on the inbound side. Both produce the same
CallConnection and hand it to the same shared dispatch loop.
CallClient is transport-agnostic. The call protocol runs over any
ordered, reliable bidirectional stream — QUIC, TCP+TLS, WebTransport,
SSH direct-tcpip, a WebSocket (ADR-065 Connection::from_stream /
from_bidi). The primary constructor (spawn_dispatch) takes a
pre-established Connection from any transport; the QUIC convenience
(connect) dials QUIC and calls spawn_dispatch. This mirrors
ChannelClient::from_connection / connect_quic (ADR-080) and is the
client-side analogue of the server-side generalization ADR-065 made.
pub struct CallClient {
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
}
impl CallClient {
pub fn new(registry: Arc<OperationRegistry>, idp: Arc<dyn IdentityProvider>) -> Self;
/// Transport-agnostic primary constructor. Takes a pre-established
/// `Connection` on ALPN `alknet/call` (any transport — QUIC via
/// `from_quinn`, TCP+TLS via `from_bidi`, WebTransport, SSH
/// `direct-tcpip`, a WebSocket), spawns the shared dispatch loop,
/// and returns a live `CallConnection`. Mirrors the server-side
/// `CallAdapter::handle(Connection)`. This is the one-way-door
/// API surface (ADR-017 Am. 2026-07-13) — it must not be coupled to
/// a transport.
pub fn spawn_dispatch(&self, connection: Connection) -> CallConnection;
/// **REMOVED per ADR-089 §5.** The dial is extracted into
/// `AlknetClient` (`alknet-client`); `connect` is deleted, not
/// delegated, to avoid `alknet-call` depending on `alknet-client`
/// and to let `alknet-call` shed its TLS/transport deps entirely.
/// Callers compose `AlknetClient::dial_quic(...).await?` +
/// `CallClient::new(...).spawn_dispatch(conn)`. `ClientError` is
/// removed (it was produced only by `connect`). `CallCredentials`
/// is removed (its `auth_token` field had no reader; `auth_token`
/// is a per-request payload field — ADR-091, amended 2026-07-17).
#[cfg(feature = "quinn")]
pub async fn connect(
&self,
addr: SocketAddr,
credentials: CallCredentials, // REMOVED — CallCredentials is removed
) -> Result<CallConnection, ClientError>;
}
Peer authorization flows through the existing AccessControl::check against
the peer's resolved Identity (ADR-029 §3) — there is no trusted_peer flag
and no remote_safe marking. When a remote peer calls an op, the dispatch
path resolves the peer's Identity (from the connection's TLS fingerprint or
the auth_token payload, via the existing IdentityProvider) and runs
AccessControl::check(peer_identity) against the op's AccessControl. If
the op's required scopes/resources are satisfied, the call dispatches; if not,
FORBIDDEN before the handler runs (capabilities never populated — the
security property). An op that should never be callable from the wire uses
Visibility::Internal (existing mechanism, NOT_FOUND before ACL). See
ADR-029 §3 for the full
mapping of the three remote_safe cases to AccessControl/Visibility.
The connection is symmetric after establishment (ADR-017 §2): both sides can
send and receive call.requested. Connection direction (who opened it) is
independent of call direction (who calls whom). The CallClient is therefore
both a caller and a callee — it dispatches incoming calls from the remote
peer through the same AccessControl-gated path, and it initiates outgoing
calls through the CallConnection::call() / subscribe() / abort() API.
Shared Dispatcher
The shared dispatch loop lives in protocol/dispatch.rs as the Dispatcher
struct. This is the architectural mechanism that keeps CallClient from
becoming a parallel protocol implementation (ADR-017 §1): both CallAdapter's
accept path and CallClient's connect path construct a Dispatcher and call
run_loop — the dispatch half is one implementation, the
connection-establishment half differs (accept vs dial).
/// Shared dispatcher for an established CallConnection. Constructed by both
/// CallAdapter (accept path) and CallClient (connect path). Holds no
/// per-connection state; the CallConnection is passed into run_loop.
pub struct Dispatcher {
pub registry: Arc<OperationRegistry>,
pub identity_provider: Arc<dyn IdentityProvider>,
pub session_source: Option<Arc<dyn SessionOverlaySource + Send + Sync>>,
pub default_timeout: Duration,
}
The dispatch path resolves the peer's Identity, runs AccessControl::check
against the op's AccessControl, and dispatches if allowed — the same
authorization machinery that gates every other call. No RemoteFilter, no
remote_safe gate (ADR-029 §3 retires these).
CallClient::spawn_dispatch(connection) is the transport-agnostic
primary constructor — it takes a pre-established Connection,
constructs a CallConnection, builds a Dispatcher, spawns the
dispatch task, and returns the live CallConnection. connect() is
removed per ADR-089 §5: the dial is extracted into AlknetClient
(alknet-client), and keeping a QUIC convenience constructor on
CallClient would make alknet-call depend on alknet-client,
contradicting the dep graph (the protocol crates are parallel to the
dial, not downstream of it). Callers compose AlknetClient::dial_quic
spawn_dispatch— two lines, the dial then the take-over. Tests usespawn_dispatchdirectly to wire mock/loopback connections. The one-way-door surface isspawn_dispatch; the dial lives inalknet-client.
This mirrors ChannelClient::from_connection (ADR-080; its
connect_quic is likewise removed per ADR-089 §5) and is the
client-side analogue of the server-side generalization ADR-065 made.
The call protocol, like the channels protocol, is transport-agnostic —
Connection::from_stream / from_bidi (ADR-065) accept any
AsyncRead + AsyncWrite, and spawn_dispatch takes the resulting
Connection unchanged.
Peer-keyed composition env (ADR-029)
The composition env that aggregates multiple connections is peer-keyed
(ADR-029 §1). CompositeOperationEnv's singular
connection: Option<Arc<dyn OperationEnv>> is replaced by PeerCompositeEnv
with peer-keyed connections:
pub struct PeerCompositeEnv {
pub base: Arc<dyn OperationEnv + Send + Sync>, // Layer 0 curated
pub session: Option<Arc<dyn OperationEnv + Send + Sync>>, // Layer 1
pub connections: HashMap<PeerId, Arc<dyn OperationEnv + Send + Sync>>, // Layer 2, peer-keyed
connection_order: Vec<PeerId>, // insertion order for PeerRef::Any first-match
}
pub type PeerId = String; // = Identity.id from IdentityProvider resolution
// = PeerEntry.peer_id (stable, not crypto material — ADR-030)
OperationEnv gains a peer-routing method with a PeerRef selector
(Specific(PeerId) / Any), default-impl for back-compat. See
ADR-029 §2 for the full
invoke_peer signature and ScopedPeerEnv peer-qualified reachability. The
per-CallConnection overlay stays flat (one connection = one peer); the
peer-keying is at the aggregation layer (the head node's composition env).
services/list
services/list filters by AccessControl::check(calling_peer_identity) —
the calling peer sees only ops it is authorized to call. The
services_list_handler / services_list_handler_peer_scoped split collapses
to a single AccessControl-filtered handler (the peer_scoped variant and
the remote_safe filter are removed). services/list-peers is the opt-in for
peer-attributed re-export listing (each peer's sub-overlay listed with
attribution, filtered by the calling peer's authorization). See
ADR-029 §6.
Credential sources for connections
The credential dimensions are split across two layers (ADR-091, amended 2026-07-17):
ConnectionCredentials(inalknet-core, per ADR-091) — the transport-level credential bundle, consumed by the dial (AlknetClient). Carries the two transport-identity dimensions:local_identity(the local node'sTlsIdentity) andremote_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_identityreadspayload.get("auth_token")on eachcall.requestedpayload. Browsers send it directly in the WebSocket call payload; the HTTP gateway resolves the bearer token to anIdentityat its boundary (the call layer sees the identity, not the token).CallCredentialsis removed (itsauth_tokenfield had no reader —connect()read onlytls_identity+remote_identity;spawn_dispatchtakes no credentials; thefrom_callforwarding path'sauth_tokensource wasOpSummary.credentials_auth_token: Option<String>, alwaysNone, never connected toCallCredentials.auth_token). See ADR-091 (amended 2026-07-17) for the full trace.
Credentials come from Capabilities (ADR-014), never from environment
variables. The transport-identity dimensions (ADR-017 §7):
// Transport-level (alknet-core, consumed by the dial — ADR-091)
pub struct ConnectionCredentials {
pub local_identity: Option<TlsIdentity>, // RFC 7250 raw key or X.509
pub remote_identity: Option<RemoteIdentity>, // expected fingerprint (None = CA path / fail-closed)
}
// auth_token is a per-request payload field, not a credential struct.
// Browsers send it in the WebSocket call payload; the HTTP gateway
// resolves bearer → Identity at its boundary.
// Dispatcher::resolve_identity reads payload.get("auth_token").
RemoteIdentity (ADR-017 §7, extended by ADR-034 §2) carries a
fingerprint string the assembly layer derives from Capabilities when
the local node has a PeerEntry for the remote (the known-peer case →
fingerprint pin). remote_identity: None is the public X.509
endpoint case: the local node has no PeerEntry for the remote, so
there is no fingerprint to pin. Combined with an X.509 transport, None
selects CA verification (WebPkiServerVerifier) per the
verifier-selection rule in ADR-034 §3. Combined with an Ed25519
raw-key transport, None fails closed (raw-key remotes are always
known peers — no CA to fall back to). The Option is load-bearing, not
cosmetic: Some(fingerprint) means "pin this" (known peer), None
means "trust the CA or fail" (unknown remote). An implementer must not
default remote_identity to a placeholder value to "satisfy" the field
— None is a real state that drives verifier selection.
pub struct RemoteIdentity { pub fingerprint: String }
There is no call-protocol credential bundle. CallCredentials is
removed. The transport dimensions (local_identity, remote_identity)
are in ConnectionCredentials in alknet-core per ADR-091.
- TLS identity — the local node's Ed25519 raw key (RFC 7250) or X.509 cert, derived from the vault at startup (ADR-020, ADR-026, ADR-027).
- Auth token — an opaque call-protocol-level token, decrypted from the vault or derived from a shared secret.
- Remote identity verification — the expected fingerprint/cert of the
remote node, stored as a capability.
Some→ fingerprint pin (known peer with aPeerEntry);None→ CA verification for X.509 remotes, fail-closed for Ed25519 raw-key remotes (ADR-034 §2/§3). TheNonecase is the public-X.509-endpoint path, not a missing field.
These are populated by the assembly layer at CallClient construction time
from vault-derived Capabilities. The credential path is the no-env-vars
invariant (below). The concrete shapes of TlsIdentity, AuthToken, and
RemoteIdentity are implementation-detail two-way doors; the one-way
constraints are that they come from Capabilities, not env vars (ADR-014).
TLS client-auth presentation (OQ-29 #1, wired): the client presents
its Ed25519 key as an RFC 7250 raw public key client cert — the client-side
equivalent of the server's RawKeyCertResolver. This is wired now, not
additive: it is what activates the PeerEntry fingerprint → peer_id
resolution path on quinn connections (ADR-030 §5). Without it, the ADR-029
peer graph doesn't populate for quinn connections — PeerId resolution
fails because the server has no client cert to extract a fingerprint from.
The iroh path already works (iroh uses RFC 7250 raw keys and exchanges
Ed25519 public keys during the TLS handshake automatically); the gap was
quinn-only, and OQ-29 #1 resolves it by replacing with_no_client_auth()
with presenting the key. The one-way constraint (credentials from
Capabilities, not env vars, ADR-014) is unaffected — the auth_token
dimension flows through the call-protocol auth_token payload field, not
TLS, so the no-env-vars invariant holds independently of the TLS layer.
Remote-identity verification (OQ-29 #2, additive): verifying the
server's fingerprint against an expected value (credentials.remote_identity)
is additive — the server-side fingerprint extraction is what matters for
PeerId, not the client-side verification. The verifier for raw keys can
start as "accept any, extract fingerprint" and add fingerprint-pinning later.
This is a two-way-door remainder; the one-way constraint (credentials from
Capabilities, not env vars) is unaffected.
Server cert verifier selection (OQ-29 #2 + ADR-034 §3): the client-side
ServerCertVerifier is selected by whether the local node has a PeerEntry
for the remote, not by key type alone. A pure-client
connection to a public X.509 endpoint (no PeerEntry on the local
side — e.g., dialing api.alk.dev or a third-party API) uses
WebPkiServerVerifier (CA verification), gets no PeerId on the
client side, and is not added to PeerCompositeEnv — it is not in
the call-protocol peer graph (ADR-029). Ops discovered via from_call
on such a connection land in the connection's Layer 2 overlay
(ADR-024) and are invoked through the CallConnection handle directly,
not via PeerRef::Specific. A connection to a hub (a PeerEntry
with mixed Ed25519 + X.509 fingerprints) uses fingerprint pinning on
both cert paths and does enter the peer graph. An unknown Ed25519
raw-key remote fails closed (no CA to fall back to — raw-key remotes
are always known peers). See
ADR-034
for the verifier selection rule and the three-role naming.
from_call
from_call discovers the remote peer's External operations and registers
them in the connection's Layer 2 overlay as FromCall-provenance leaves with
forwarding handlers. The discovery mechanism (services/list +
services/schema) is already implemented in registry/discovery.rs;
from_call is the client-side consumer of that API.
pub struct FromCallConfig {
/// Namespace prefix applied to imported operation names. Optional —
/// default no prefix. Collision on import is an error (DC-3, OQ-28),
/// not last-wins.
pub namespace_prefix: Option<String>,
/// Optional filter — import only operations whose names match. None
/// imports all External ops discovered via services/list.
pub operation_filter: Option<HashSet<String>>,
}
/// Discover the remote peer's External ops and construct HandlerRegistration
/// bundles with FromCall provenance and forwarding handlers. The caller
/// registers the bundles in the connection's overlay via
/// CallConnection::register_imported_all().
pub async fn from_call(
connection: &CallConnection,
config: FromCallConfig,
) -> Result<Vec<HandlerRegistration>, AdapterError>;
The flow (ADR-017 §3):
- Call
services/liston the remote → list ofExternaloperations. - Call
services/schemafor each → input/output JSON Schemas and declarederror_schemas(ADR-023). - For each discovered op, construct a
HandlerRegistration:specmirrors the remote op's name (with optional prefix), namespace, type, schemas, access control.handleris a forwarding handler, branched onop_type(ADR-049):Query/Mutation→ aHandler(registered asHandlerKind::Once): sendscall.requestedviaCallConnection::call_with_payload(), awaits the singlecall.responded(orcall.error), returns theResponseEnvelope.Subscription→ aStreamingHandler(registered asHandlerKind::Stream): callsCallConnection::subscribe(), which returnsimpl Stream<Item = ResponseEnvelope>(the client-side streaming path, already implemented), maps it to aBoxStream<ResponseEnvelope>. The remote stream flows end-to-end: eachcall.respondedthe remote sends becomes a stream item; the remote'scall.completedends the stream (→ wirecall.completed);call.aborteddrops the stream (cascade per ADR-016). No truncation, no first-value fallback — afrom_call-imported subscription forwards the full remote stream.
provenance: FromCall,composition_authority: None,scoped_env: None(leaf — ADR-022).
- The caller registers the bundles via
CallConnection::register_imported_all().
Re-import on reconnection (DC-2, OQ-27): from_call is a free function;
the assembly layer calls it after connect(). The overlay is per-connection
(Layer 2, ADR-024), so a stale overlay dies with the connection; re-import on
reconnect is naturally scoped to the new connection. A
CallConnection::refresh() method for mid-connection re-discovery is a
genuine feature addition — non-breaking, additive — if a deployment needs
manual re-discovery without drop-and-reconnect. See
ADR-069.
Namespace collision (DC-3, OQ-28): under the peer-graph model (ADR-029),
cross-peer collision dissolves — same name on different peers is fine (they
live in separate peer sub-overlays, no prefix needed). Same-peer collision
stays an error (a peer shouldn't expose two ops with the same name).
FromCallConfig::namespace_prefix is optional local-naming sugar for when
the importing node wants to expose a peer's ops under a different name
locally — a local-naming concern, not a disambiguation concern. It defaults
to None.
Trust is transitive (recorded in operation-registry.md): a
from_call-imported operation executes the remote node's code, not yours.
The scoped env (ADR-015) bounds which operations are reachable, not what
they do. from_call means "I trust the remote node as much as my own
handlers." The abort cascade (ADR-016) crosses the node boundary transparently
through the forwarding handler's parent_request_id.
Forwarded-for identity (ADR-032): the from_call forwarding handler
populates forwarded_for on the call.requested payload it constructs to
send to the spoke. The hub reads its own OperationContext.identity (the
end user it authenticated) and sets forwarded_for to that identity when
forwarding. The spoke receives it as metadata on its OperationContext —
available for logging, auditing, per-user rate limiting, but never used by
AccessControl::check (the spoke authorizes the hub, its direct caller,
not the end user). The hub may set forwarded_for: None if it doesn't
want to disclose the originator. See ADR-032.
from_jsonschema
from_jsonschema was originally specified here (ADR-017 §5) as a
schema-only adapter in alknet-call — a placeholder handler returning
NOT_FOUND. That was broken: an op in the registry needs a real handler,
and the "schema-only, no handler" concept conflated schema validation
(a planning activity that doesn't need a registry entry) with operation
registration (which always needs a handler).
ADR-066 moved
from_jsonschema to alknet-http as an HTTP-backed single-endpoint
adapter: the caller supplies an OperationSpec + HttpServiceConfig +
path template + method, and the adapter builds one
HandlerRegistration with a real reqwest forwarding handler and
FromJsonSchema provenance. It is functionally similar to from_openapi
but one endpoint at a time, for non-standard / non-OpenAPI / basic REST
endpoints that don't have a full OpenAPI document. See
crates/http/http-adapters.md §"from_jsonschema".
The schema-validation-without-a-handler use case (the original stated
purpose) is served by consuming OperationSpec directly — the spec
already carries the input/output JSON Schemas. No adapter, no registry
entry, no handler is needed for that.
The FromJsonSchema provenance variant stays in alknet-call
(OperationProvenance in registry/registration.rs); only the adapter
implementation moved.
OperationAdapter trait
The shared shape across import-style adapters. The trait lives in
alknet-call (where the types live); the implementations live where their
transport dependencies live (see "Adapter Location Map" below).
#[async_trait]
pub trait OperationAdapter: Send + Sync {
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
}
The trait is async because from_call requires async discovery
(services/list + services/schema over a call-protocol connection,
which may be QUIC, TCP+TLS, or any other transport). Sync adapters
(from_openapi, from_mcp reading a static spec) trivially satisfy an async
trait — their import() bodies contain no .await points. This is locked by
ADR-017 §5.
The error type (DC-4, OQ-26) is Result<Vec<HandlerRegistration>, AdapterError> where AdapterError is a crate-level enum covering the
failure modes real implementations hit: discovery transport failure
(from_call remote unreachable), schema parse failure (from_openapi,
from_jsonschema), unauthorized (HTTP 401 for from_openapi,
from_mcp). The exact AdapterError variants are the two-way-door
remainder; the presence of an error type is filled in here. ADR-017 §5
showed async fn import(&self) -> Vec<HandlerRegistration> with no error
type; the spec omitted the error type as an implementation-detail two-way
door, recorded here.
Implementations:
FromCall— call-protocol-backed, transport-agnostic (inalknet-call).from_calldiscovers ops over aCallConnection, which may be QUIC, TCP+TLS, or any transportConnection::from_streamsupports (ADR-065).FromOpenAPI— HTTP-backed (inalknet-http).FromJsonSchema— HTTP-backed, single-endpoint (inalknet-httpper ADR-066; was a broken schema-only placeholder inalknet-call).FromMCP— MCP streamable-HTTP-backed (inalknet-http, feature-gated).
The to_* adapters (to_openapi, to_mcp) are outbound projections, not
OperationAdapter implementations — they consume the registry, they don't
produce entries for it (ADR-017 §5).
Adapter Location Map
The decomposition principle: the adapter trait lives where the types live
(alknet-call); the adapter implementations live where their transport
dependencies live.
alknet-call (lean — no HTTP client, no HTTP server)
├── OperationAdapter trait (the contract — async, per ADR-017 §5)
├── from_call (transport-agnostic — discovers remote ops via
│ call protocol over any Connection)
└── CallClient (outbound connection take-over — spawn_dispatch
transport-agnostic, connect QUIC convenience)
alknet-http (owns HTTP server + HTTP client — separate crate, separate Phase 0)
├── ProtocolHandler for h2/http1.1/h3 (axum server — inbound HTTP)
├── from_openapi (parse OpenAPI doc + reqwest forwarding handler)
├── from_jsonschema (single-endpoint reqwest forwarding handler — ADR-066)
├── to_openapi (generate OpenAPI doc from local registry)
├── from_mcp (feature-gated) (import remote MCP tools over streamable HTTP — reqwest)
└── to_mcp (feature-gated) (expose local ops as MCP tools over streamable HTTP — axum)
Not built: MCP stdio transport
— stdio = spawn arbitrary executable = built-in RCE ("download untrusted MCP servers")
— streamable HTTP is the only supported MCP transport in alknet
— recorded as an explicit security position, not a feature gap
alknet-call never sees the HTTP client. The from_openapi/from_mcp
forwarding handlers are opaque Arc<dyn Handler> from the registry's
perspective — constructed by alknet_http::from_openapi() at registration
time, stored in HandlerRegistration, dispatched by the CallAdapter which
doesn't know reqwest is involved. alknet-call stays lean (no reqwest, no
axum); alknet-http owns both HTTP directions.
ADR-003 dependency note: alknet-http implementing from_openapi/
from_mcp means alknet-http depends on alknet-call (for OperationSpec,
Handler, HandlerRegistration, OperationAdapter). ADR-003's rule is "no
handler crate depends on another handler crate" — but alknet-call is both
a handler and the protocol foundation that alknet-agent and alknet-napi
already consume. alknet-http depending on alknet-call is "HTTP uses the
call protocol types," not "HTTP depends on SSH." This is within the spirit of
ADR-003 (alknet-call is protocol-foundation, not a peer handler). The
alknet-http spec should note this explicitly; a one-line amendment to
ADR-003 clarifying that alknet-call is a protocol-foundation crate is
deferred to the alknet-http Phase 0.
No-Env-Vars Invariant
The architectural mechanism for the env-var problem in downstream consumers
(the Rust port of Vercel's AI SDK at /workspace/aisdk/, whose providers all
read std::env::var("OPENAI_API_KEY") in their Default impls). The fix is
not to modify those consumers — it's that the env-var path is never taken
because the assembly layer never calls Default::default().
The credential injection path:
vault (seed)
→ assembly layer (derive + decrypt at startup, per ADR-014/019/025)
→ Capabilities (non-serializable, zeroized, immutable — ADR-014)
→ HandlerRegistration.capabilities (ADR-022, the registration bundle)
→ OperationContext.capabilities (per-request, populated by dispatch
path from the bundle — ADR-022 §6)
→ from_openapi handler reads context.capabilities.get("openai")
→ injects into HTTP Authorization header
→ reqwest request goes out with vault-derived credential
The from_openapi/from_mcp forwarding handlers (in alknet-http) are the
credential injection point. They read from context.capabilities, not from
std::env::var. The downstream consumers' Default impls reading env vars
are simply never called — the assembly layer constructs providers with
vault-derived credentials through the builder API, or the provider's HTTP
calls are routed through from_openapi operations that carry the credential
in Capabilities.
This is a spec-level invariant in alknet-call, not a runtime convention.
The dispatch path (build_root_context and OperationEnv::invoke() per
ADR-022 §6) populates OperationContext.capabilities from the registration
bundle. The invariant is: no handler reads outbound credentials from any
source other than OperationContext.capabilities. This is already the
architectural intent of ADR-014; this document records it as an explicit
invariant that the from_openapi/from_mcp handler implementations (in
alknet-http) are verified against.
Exchange-of-Operations Pattern (Runner / Container Service)
The canonical downstream pattern this completion unblocks, recorded here so
Phase 1 specs can reference it. Concrete example: the container service at
/workspace/@alkdev/dispatch (axum + russh SSH client for "reverse git
runner" over Docker/vast.ai) gets rewritten as a call-protocol service.
Bilateral exchange:
Container service (runs on a vast.ai/docker instance):
Defines Local ops: /container/exec, /container/list, /container/logs...
(real handlers — calls bollard or vast.ai API)
Connects to hub as a CallClient (outbound connection — runner pattern)
Hub (central server):
Runs CallAdapter (server) on alknet/call (already implemented)
When the container service connects:
hub runs from_call → discovers /container/* via services/list + services/schema
registers them as FromCall provenance (leaf, forwarding handlers) in the
connection's Layer 2 overlay (ADR-024)
Now the hub (or anything connected to the hub) can call /container/exec
The from_call handler forwards over the connection back to the container service
Bilateral: the container service ALSO runs from_call against the hub,
discovers the hub's External ops, and can call them.
Connection direction (container → hub) is independent of call direction
(both can call each other) per ADR-017 §2.
What this requires:
CallClient— the container service uses it to open the outbound connection to the hub. The #1 gap.from_call— both sides run it to populate their Layer 2 overlays with the other side'sExternalops. The #2 gap.OperationAdaptertrait —from_callimplements it. The #3 gap (enabling, not blocking —from_callcan be built as a free function before the trait exists, but the trait is needed foralknet-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:
-
CallClient(critical) — outbound connection opener. Without it, no runner, no container service, no bilateral exchange. Reuses the existingCallConnectionfor the dispatch loop; adds only the connection-establishment + credential-handling half. The single highest-value piece of work in the entirealknet-callcompletion. -
from_call(critical, depends onCallClient) — consumes the already-implementedservices/list+services/schemadiscovery API. -
OperationAdaptertrait (enabling) — the async trait. Small, standalone, unblocksalknet-httpPhase 1 (includingfrom_jsonschemaper ADR-066). -
DC-1 resolution (peer-graph routing model, ADR-029) — the peer-keyed overlay +
AccessControl-based peer authorization model that replaces ADR-028'sremote_safe/trusted_peer. This is a structural change toCompositeOperationEnv(→PeerCompositeEnv), the dispatch path (retireRemoteFilter), andOperationEnv(gaininvoke_peer). See ADR-029 for the migration; the POC shapes in the research doc are the reference.
What This Completion Unblocks
| Downstream crate | What it needs from alknet-call | Status without completion |
|---|---|---|
| alknet-http | OperationAdapter trait (to implement from_openapi/from_mcp) |
Blocked — can't define HTTP-backed adapters without the trait |
| alknet-ssh | Stable alknet-call types (no adapter dependency) | Not blocked — ssh depends on alknet-core, not alknet-call's adapters. Proceeds in parallel. |
| alknet-agent | CallClient (tool dispatch), from_call (remote tool import), OperationAdapter (provider adapters) |
Blocked on CallClient + from_call |
| Container service (dispatch rewrite) | CallClient + from_call |
Blocked — this is the primary consumer |
| Runner pattern (dev runner, opencode runner) | CallClient + from_call |
Blocked — the runner IS a CallClient |
| alknet-napi | CallClient (Node.js calls remote ops) |
Blocked — NAPI projects CallClient to JS |
Constraints
- No HTTP in alknet-call.
from_openapi/from_mcp/from_jsonschema/to_openapi/to_mcplive inalknet-http. TheOperationAdaptertrait and the call-protocol-backed adapter (from_call, transport- agnostic) live inalknet-call.from_jsonschemawas originally (mis)placed inalknet-callas a schema-only placeholder; ADR-066 moved it toalknet-httpas a real HTTP-backed adapter. See Adapter Location Map. - No secret material on the wire.
ConnectionCredentialscarries vault-derived material for the outbound connection (TLS identity);auth_tokenis a per-request payload field (browsers send it in the WebSocket call payload; the HTTP gateway resolves bearer →Identityat its boundary). The call protocol's wire format carries no private keys, API keys, or decrypted credentials (ADR-014). The no-env-vars invariant (above) is the dispatch-side corollary. - Peer authorization via
AccessControl. A remote peer's call is authorized byAccessControl::check(peer_identity)against the op'sAccessControl— the same mechanism that gates every other call. Noremote_safeflag, notrusted_peerbypass (ADR-029 §3). An op withAccessControl::default()is callable by any peer; an op withrequired_scopesis callable only by peers whoseIdentity.scopessatisfy them; an op withVisibility::Internalis never callable from the wire. - Composition env is peer-keyed. A head node with N worker connections
holds a
PeerCompositeEnvwithconnections: HashMap<PeerId, Arc<dyn OperationEnv>>, not a singular connection overlay.invoke_peer()routes to the right peer viaPeerRef::Specific/PeerRef::Any(ADR-029 §1-2). from_callis a manual free function. The assembly layer calls it afterconnect(). The overlay is per-connection so re-import on reconnect is naturally scoped (DC-2, OQ-27). See ADR-069.from_callnamespace collision is same-peer only. Cross-peer collision dissolves (same name on different peers is fine — separate sub-overlays, ADR-029 §5). Same-peer collision stays an error.namespace_prefixis optional local-naming sugar, not the disambiguation mechanism (DC-3, OQ-28).OperationAdapter::import()returnsResult. Failures surface asAdapterError(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
CallClientconnection to a public X.509 endpoint with no localPeerEntryfor the remote gets noPeerId, is not added toPeerCompositeEnv, and is not addressable viaPeerRef::Specific. Ops discovered on it live in the connection's Layer 2 overlay and are invoked through theCallConnectionhandle. The client-sideServerCertVerifieruses CA verification (WebPkiServerVerifier) for such remotes; known peers (hub withPeerEntry) use fingerprint pinning. See ADR-034. ConnectionCredentials.remote_identity: Noneis load-bearing.Nonemeans "noPeerEntryfor this remote → use CA verification (X.509) or fail closed (Ed25519 raw key)" per the ADR-034 §3 verifier rule. The implementation must not defaultremote_identityto a placeholder to satisfy the field, and must not treatNoneas "skip verification" —None+ X.509 is CA verification,None+ raw key is a hard failure.Some(fingerprint)is the known-peer pin path.
Design Decisions
| Decision | ADR | Summary |
|---|---|---|
| Call protocol client and adapter contract | ADR-017 | CallClient opens connections; from_call imports remote ops; connection direction independent of call direction; trait is async; adapters produce HandlerRegistration bundles. from_jsonschema clause superseded by ADR-066 |
from_jsonschema as HTTP-backed single-endpoint adapter in alknet-http |
ADR-066 | Moved from_jsonschema from alknet-call (broken schema-only placeholder) to alknet-http as a real reqwest-backed single-endpoint adapter; FromJsonSchema provenance stays in alknet-call as a leaf |
| Peer-graph routing model (DC-1, supersedes ADR-028) | ADR-029 | Peer-keyed overlays + PeerRef routing; peer authorization via existing AccessControl::check(peer_identity); retires remote_safe/trusted_peer |
| PeerEntry and Identity.id decoupling | ADR-030 | PeerId source changes from UUID to Identity.id (= PeerEntry.peer_id, stable across key rotation); Identity.id decoupled from crypto material on the fingerprint path |
| Forwarded-for identity | ADR-032 | forwarded_for field on call.requested and OperationContext; the from_call handler populates it; metadata only, never used by AccessControl::check |
| Storage boundary and repo/adapter pattern | ADR-033 | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
remote_safe: bool; trusted-peer opt-inAccessControl) |
||
| Secret material flow and capability injection | ADR-014 | The no-env-vars invariant's foundation; capabilities injected at assembly layer |
| Handler registration, provenance, and composition authority | ADR-022 | The registration bundle adapters produce; composition_authority: None for leaves |
| Operation registry layering | ADR-024 | Layer 2 per-connection overlay where from_call imports land |
| Privilege model and authority context | ADR-015 | Adapter-registered ops are Internal by default; default-deny posture |
| Abort cascade for nested calls | ADR-016 | Cross-node abort through from_call forwarding handler's parent_request_id |
| Operation error schemas | ADR-023 | error_schemas mirrored by from_call from remote op's spec |
| Streaming handler for subscriptions | ADR-049 | from_call Subscription ops register a StreamingHandler (HandlerKind::Stream) that calls CallConnection::subscribe() and forwards the remote stream; Query/Mutation stay HandlerKind::Once |
| TLS identity redesign | ADR-027 | RFC 7250 raw key / X.509 cert dimensions of the local TlsIdentity (now carried by ConnectionCredentials.local_identity) |
| Outgoing-only X.509 and three peer roles | ADR-034 | Public X.509 endpoint is not a PeerEntry on the client side (no PeerId, not in peer graph); client-side verifier by PeerEntry presence (CA vs fingerprint pin); hub = mixed-fingerprint PeerEntry |
| HD derivation for encryption keys | ADR-020 | Vault-derived TLS identity material |
| Vault key model | ADR-026 | Vault-derived TLS identity material |
| Vault local-only dispatch | ADR-025 | Vault access at assembly layer only; the credential injection path's first hop |
| Crate decomposition | ADR-003 | alknet-http depends on alknet-call (protocol-foundation exception, noted in Adapter Location Map) |
| One-way door decision framework | ADR-009 | Door-type classification for DC-1..4 |
Open Questions
See open-questions.md for full details.
- OQ-25 (dissolved by ADR-029):
remote_safemarking shape — moot.remote_safe/trusted_peerare retired; peer authorization isAccessControl::check(peer_identity). No marking to shape. - OQ-26 (resolved):
AdapterErrorvariants —DiscoveryFailed,SchemaParse,Transport,Unauthorized,SamePeerCollision(replaces flatConflict).#[non_exhaustive]. - OQ-27 (resolved):
from_callre-import trigger —from_callis a manual free function; the assembly layer calls it afterconnect(). ACallConnection::refresh()method is a genuine feature addition — non-breaking, additive. See ADR-069. - OQ-28 (resolved):
from_callnamespace collision — same-peer collision = error; cross-peer dissolved by ADR-029 (separate sub-overlays).namespace_prefixis optional local-naming sugar. - OQ-29 (resolved):
CallClientTLS 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::Anyrouting policy — insertion-order first-match. A richerRoutingPolicyis a feature extension. - OQ-31 (resolved):
services/list-peers— opt-in;services/listis "own ops only." - OQ-32 (open, feature extension): Multi-hop federation — the one-hop model is the architectural commitment; multi-hop is a feature extension that doesn't break downstream. The peer-keyed model extends to multi-hop without redesign; petgraph is the candidate if path-finding becomes real (ADR-029 §3.7).
- OQ-33 (resolved by ADR-030):
PeerIdis a logical id. Source isIdentity.idfromIdentityProviderresolution (=PeerEntry.peer_id, stable across key rotation), not a connection-assigned UUID. The UUID workaround is removed. See OQ-33 in open-questions.md. - OQ-34 (resolved by ADR-030 + ADR-033): Persistent peer registry —
the storage boundary is
core trait + in-memory default(config-backedConfigIdentityProvidernow; persistence adapters additive in separate crates). See OQ-34 in open-questions.md. - OQ-35 (dissolved): the "API key asymmetry" framing was wrong;
PeerEntrysupports multiple credential paths (fingerprints + auth_token_hash),ApiKeyEntryis 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 (
IdentityStoreasync write trait extends the syncIdentityProviderread trait); SQLite adapter caches in memory and uses honker NOTIFY/LISTEN for no-restart cache invalidation;alknet-store-sqlitecrate implements bothIdentityStoreandCredentialStore. 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).
PeerEntryasymmetry is correct: a pure-client connection to a public X.509 endpoint is not in the call-protocol peer graph on the client side — noPeerEntry, noPeerId, noPeerRef::Specificrouting. Ops discovered viafrom_call/from_openapi/from_mcpland in the connection's Layer 2 overlay and are invoked through the connection handle. The client-sideServerCertVerifieris selected byPeerEntrypresence: known peer → fingerprint pin; unknown X.509 remote → CA verification (WebPkiServerVerifier). See ADR-034 and OQ-37 in open-questions.md.
References
- ADR-017: Call Protocol Client and Adapter Contract (the spec this document operationally fills)
- ADR-029: Peer-Graph Routing Model (supersedes ADR-028; resolves DC-1 with
peer-keyed overlays +
AccessControl-based peer authorization) ADR-028: Peer-Scoped Registry Filtering (superseded by ADR-029)call-protocol.md—CallAdapter,CallConnection, dispatch loop, stream model (the server-side complement to this document)operation-registry.md—HandlerRegistration, provenance, capability injection, service discovery (the discovery APIfrom_callconsumes)docs/research/alknet-call-completion/gap-analysis.md— DC-1..4, the implementation-state audit, the downstream unblock chaindocs/research/alknet-call-peer-routing/findings.md— the peer-graph routing research that identified ADR-028's structural gap and validated the ADR-029 design via POC/workspace/@alkdev/operations/— TypeScript prior art (from_openapi.ts,from_mcp.ts,from_schema.ts,scanner.ts)/workspace/@alkdev/dispatch/— concrete downstream consumer (container service / "reverse git runner") this completion unblocks/workspace/aisdk/— downstream consumer (Rust port of Vercel AI SDK); the no-env-vars invariant makes itsstd::env::varreads unreachable/workspace/rust-sdk/— MCP Rust SDK (rmcp); streamable HTTP transport foralknet-http'sfrom_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