ADR-091 amended 2026-07-17: CallCredentials removed (not retained in alknet-call). Trace showed CallCredentials.auth_token had no reader (connect() read only tls_identity + remote_identity; spawn_dispatch takes no credentials; from_call's credentials_auth_token was a different type, always None, never connected). auth_token is a per-request payload field — browsers send it in the WS payload; the HTTP gateway resolves bearer → Identity at its boundary. from_call's credentials_auth_token dead path removed in the same pass (OpSummary field, handler params, build_forwarded_payload param, and the two tests asserting the never-exercised Some path). ADR-089 §5 further amended, ADR-080 noted, all spec READMEs and overview updated. Migration plan (findings.md) corrected: Phase 5 prune now includes CallCredentials removal + from_call dead-path removal; test audit corrected (4 unchanged + 2 move to core, not 6 unchanged); integration-test split documented; all 'or' hedges resolved.
51 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-07-09 |
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, moved fromalknet-callper 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").
There is no call-protocol credential bundle. CallCredentials is
removed. The transport dimensions (local_identity, remote_identity)
moved to ConnectionCredentials in alknet-core per ADR-091.
/// Expected identity of the remote node (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 therefore 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 }
- **TLS identity** — the local node's Ed25519 raw key (RFC 7250) or X.509 cert,
derived from the vault at startup (ADR-020, ADR-026, ADR-027).
- **Auth token** — an opaque call-protocol-level token, decrypted from the
vault or derived from a shared secret.
- **Remote identity verification** — the expected fingerprint/cert of the
remote node, stored as a capability. `Some` → fingerprint pin (known
peer with a `PeerEntry`); `None` → CA verification for X.509 remotes,
fail-closed for Ed25519 raw-key remotes (ADR-034 §2/§3). The `None`
case is the public-X.509-endpoint path, not a missing field.
These are populated by the assembly layer at `CallClient` construction time
from vault-derived `Capabilities`. The credential path is the no-env-vars
invariant (below). The concrete shapes of `TlsIdentity`, `AuthToken`, and
`RemoteIdentity` are implementation-detail two-way doors; the one-way
constraints are that they come from `Capabilities`, not env vars (ADR-014).
**TLS client-auth presentation** (OQ-29 #1, wired): the client presents
its Ed25519 key as an RFC 7250 raw public key client cert — the client-side
equivalent of the server's `RawKeyCertResolver`. This is **wired now**, not
additive: it is what activates the `PeerEntry` fingerprint → `peer_id`
resolution path on quinn connections (ADR-030 §5). Without it, the ADR-029
peer graph doesn't populate for quinn connections — `PeerId` resolution
fails because the server has no client cert to extract a fingerprint from.
The iroh path already works (iroh uses RFC 7250 raw keys and exchanges
Ed25519 public keys during the TLS handshake automatically); the gap was
quinn-only, and OQ-29 #1 resolves it by replacing `with_no_client_auth()`
with presenting the key. The one-way constraint (credentials from
`Capabilities`, not env vars, ADR-014) is unaffected — the `auth_token`
dimension flows through the call-protocol `auth_token` payload field, not
TLS, so the no-env-vars invariant holds independently of the TLS layer.
**Remote-identity verification** (OQ-29 #2, additive): verifying the
server's fingerprint against an expected value (`credentials.remote_identity`)
is **additive** — the server-side fingerprint extraction is what matters for
`PeerId`, not the client-side verification. The verifier for raw keys can
start as "accept any, extract fingerprint" and add fingerprint-pinning later.
This is a two-way-door remainder; the one-way constraint (credentials from
`Capabilities`, not env vars) is unaffected.
**Server cert verifier selection** (OQ-29 #2 + ADR-034 §3): the client-side
`ServerCertVerifier` is selected by whether the local node has a `PeerEntry`
for the remote, not by key type alone. A pure-client
connection to a **public X.509 endpoint** (no `PeerEntry` on the local
side — e.g., dialing `api.alk.dev` or a third-party API) uses
`WebPkiServerVerifier` (CA verification), gets **no `PeerId`** on the
client side, and is **not added to `PeerCompositeEnv`** — it is not in
the call-protocol peer graph (ADR-029). Ops discovered via `from_call`
on such a connection land in the connection's Layer 2 overlay
(ADR-024) and are invoked through the `CallConnection` handle directly,
not via `PeerRef::Specific`. A connection to a **hub** (a `PeerEntry`
with mixed Ed25519 + X.509 fingerprints) uses fingerprint pinning on
both cert paths and does enter the peer graph. An unknown Ed25519
raw-key remote fails closed (no CA to fall back to — raw-key remotes
are always known peers). See
[ADR-034](../../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<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 CallCredentials |
| 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