The ported docs still framed alknet siblings (alknet-core/-call/-channels/
-http/-tls/-client) as live load-bearing context of this crate. With the
extraction complete and alknet headed for glue-level replacement, the docs
now speak as alkcall and cite the real sibling crates (alkhttp, alktls,
alktty, alktunnels, alktrader).
- ADR-054 (new): re-own the verifier-selection rule in-crate — three
remote roles (public X.509 endpoint / transport relay / hub),
ServerCertVerifier by PeerEntry presence, RemoteIdentity None
load-bearing. Provenance: alknet ADR-034 (never ported), demoted to
footnote. Resolves OQ-21's cross-repo authority gap.
- OQ-43 (new, deferred(scope)): composition-layer ownership and shape
after the alknet replacement — the named receiver for the
downstream-side deferrals (OQ-33 endpoint layer, OQ-41 QUIC-native
substrate, OQ-42 hub broker, OQ-19 persistence adapters, ADR-045 dial
seam). README layering diagram redrawn with the composition layer as
TBD-ownership instead of "alknet / alknode" in concrete.
- Spec docs reframed: titles, self-references, dial/TLS/from_jsonschema
homes, malformed legacy cite rows removed; dead relative research
paths qualified as source-repo paths.
- ADR amendments (append-only): 027 (alknet-http -> alkhttp), 031 (post-
extraction state), 044 (split landed as modules in alkcall), 045
(name normalization; citation-space note).
- Citation hygiene: unprefixed/colliding source-repo cites prefixed
("alknet ADR-NNN (provenance)") across 009/011/022/034/035/038/040/
043/044/045; stale OQ cites corrected (session registries OQ-04, batch
semantics OQ-03, verifier rule ADR-054); stale anchors fixed.
- README provenance note consolidated; citation conventions documented.
Verification: markdown link check (all local links resolve, all in-crate
ADR cites point at existing decisions/ files); table integrity check
(uniform row widths across open-questions.md and edited tables); residual
alknet-mention sweep (only provenance/historical/intentional framing
remains); architecture review subagent pass with zero remaining criticals.
Docs-only change — no code paths touched.
51 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-10-13 (alkcall reframing) |
alkcall — Client and Adapters
The outbound half of the call protocol: opening connections, importing remote
operations, and the adapter contract that ties import-style adapters together.
This document covers what ADR-022 specced but the server-side implementation
(call-protocol.md, operation-registry.md) did not include — the CallClient
that opens a connection, the from_call adapter, and the
OperationAdapter trait. (from_jsonschema was originally specced here
too, but ADR-027 moved it to the HTTP host crate — today alkhttp
(then named alknet-http) — see §"from_jsonschema" below.)
The server-side CallAdapter and CallConnection
dispatch loop are covered in call-protocol.md; this document covers the
client-side connection-establishment half and the adapter surface.
What
This document specifies three components, all in alkcall:
CallClient— takes over an established transportConnectionon ALPNalk/call, spawns the shared dispatch loop, and produces aCallConnection. Transport-agnostic (spawn_dispatchprimary; dial lives inAlknetClientper ADR-045); the dispatch loop is shared with the server-sideCallAdapter(ADR-022 §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-027 movedfrom_jsonschemafrom the call protocol to the HTTP host crate and gave it a real reqwest-backed forwarding handler (it was a broken schema-only placeholder before). It is now an HTTP-backed single-endpoint adapter for non-standard / non-OpenAPI / basic REST endpoints, functionally similar tofrom_openapibut one endpoint at a time. It lives inalkhttp; its full spec isalkhttp'shttp-adapters.md(original source-repo copy at/workspace/@alkdev/alknet/docs/architecture/crates/http/http-adapters.md, provenance). TheFromJsonSchemaprovenance variant stays in alkcall (OperationProvenance); only the adapter implementation moved.
It also records two cross-cutting architectural mechanisms that the adapter surface rests on:
- The adapter location map — which adapters live in alkcall vs
alkhttp, and why. - The no-env-vars invariant — the architectural mechanism by which
downstream consumers'
std::env::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 alkhttp's from_openapi/from_mcp.
ADR-022 specced this surface. This document is the spec that operationally
fills the gap ADR-022 left to implementation: the CallClient API, the
from_call flow, the trait signature, the adapter location, the credential
invariant, and the bilateral pattern. The gap
analysis (source-repo research, now at
/workspace/@alkdev/alknet/docs/research/alknet-call-completion/gap-analysis.md)
identified
four decisions (DC-1..4) needed before implementation. DC-1 was initially
resolved by ADR-023 (remote_safe/trusted_peer), but a subsequent research
pass (source-repo research, now at
/workspace/@alkdev/alknet/docs/research/alknet-call-peer-routing/findings.md) found that
ADR-023's model was structurally broken for the head→N-workers pattern (the
primary use case) and that its parallel remote_safe/trusted_peer
authorization system duplicated the existing AccessControl/Identity
machinery. ADR-024 supersedes ADR-023: peer-keyed overlays + PeerRef
routing, and peer authorization through the existing AccessControl::check(peer_identity).
DC-2/3/4 are two-way-door defaults recorded here (DC-2→OQ-12, DC-3→OQ-13
cross-peer dissolved / same-peer stays, DC-4→OQ-11).
Architecture
CallClient
CallClient takes over an established transport Connection on ALPN
alk/call, spawns the shared dispatch loop, and produces a
CallConnection. The CallConnection type is already implemented
(call-protocol.md §"CallConnection") — it wraps an established
Connection and holds the Layer 2 imported-ops overlay. CallClient
is the producer on the outbound side; CallAdapter's accept path is
the producer on the inbound side. Both produce the same
CallConnection and hand it to the same shared dispatch loop.
CallClient is transport-agnostic. The call protocol runs over any
ordered, reliable bidirectional stream — QUIC, TCP+TLS, WebTransport,
SSH direct-tcpip, a WebSocket (ADR-007 Connection::from_stream /
from_bidi). The primary constructor (spawn_dispatch) takes a
pre-established Connection from any transport; the dial lives in
the composition layer's dial helper, AlknetClient (ADR-045). This mirrors
ChannelClient::from_connection (ADR-043) and is the client-side
analogue of the server-side generalization ADR-007 made.
pub struct CallClient {
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
}
impl CallClient {
pub fn new(registry: Arc<OperationRegistry>, idp: Arc<dyn IdentityProvider>) -> Self;
/// Transport-agnostic primary constructor. Takes a pre-established
/// `Connection` on ALPN `alk/call` (any transport — QUIC via
/// `from_quinn`, TCP+TLS via `from_bidi`, WebTransport, SSH
/// `direct-tcpip`, a WebSocket), spawns the shared dispatch loop,
/// and returns a live `CallConnection`. Mirrors the server-side
/// `CallAdapter::handle(Connection)`. This is the one-way-door
/// API surface (ADR-022 Am. 2026-07-13) — it must not be coupled to
/// a transport.
pub fn spawn_dispatch(&self, connection: Connection) -> CallConnection;
}
Peer authorization flows through the existing AccessControl::check against
the peer's resolved Identity (ADR-024 §3) — there is no trusted_peer flag
and no remote_safe marking. When a remote peer calls an op, the dispatch
path resolves the peer's Identity (from the connection's TLS fingerprint or
the auth_token payload, via the existing IdentityProvider) and runs
AccessControl::check(peer_identity) against the op's AccessControl. If
the op's required scopes/resources are satisfied, the call dispatches; if not,
FORBIDDEN before the handler runs (capabilities never populated — the
security property). An op that should never be callable from the wire uses
Visibility::Internal (existing mechanism, NOT_FOUND before ACL). See
ADR-024 §3 for the full
mapping of the three remote_safe cases to AccessControl/Visibility.
The connection is symmetric after establishment (ADR-022 §2): both sides can
send and receive call.requested. Connection direction (who opened it) is
independent of call direction (who calls whom). The CallClient is therefore
both a caller and a callee — it dispatches incoming calls from the remote
peer through the same AccessControl-gated path, and it initiates outgoing
calls through the CallConnection::call() / subscribe() / abort() API.
Shared Dispatcher
The shared dispatch loop lives in protocol/dispatch.rs as the Dispatcher
struct. This is the architectural mechanism that keeps CallClient from
becoming a parallel protocol implementation (ADR-022 §1): both CallAdapter's
accept path and CallClient's connect path construct a Dispatcher and call
run_loop — the dispatch half is one implementation, the
connection-establishment half differs (accept vs dial).
/// Shared dispatcher for an established CallConnection. Constructed by both
/// CallAdapter (accept path) and CallClient (connect path). Holds no
/// per-connection state; the CallConnection is passed into run_loop.
pub struct Dispatcher {
pub registry: Arc<OperationRegistry>,
pub identity_provider: Arc<dyn IdentityProvider>,
pub session_source: Option<Arc<dyn SessionOverlaySource + Send + Sync>>,
pub default_timeout: Duration,
}
The dispatch path resolves the peer's Identity, runs AccessControl::check
against the op's AccessControl, and dispatches if allowed — the same
authorization machinery that gates every other call. No RemoteFilter, no
remote_safe gate (ADR-024 §3 retires these).
CallClient::spawn_dispatch(connection) is the transport-agnostic
primary constructor — it takes a pre-established Connection,
constructs a CallConnection, builds a Dispatcher, spawns the
dispatch task, and returns the live CallConnection. The dial lives in
the composition layer's dial helper, AlknetClient (ADR-045): keeping a QUIC convenience
constructor on CallClient would make alkcall depend on
the dial crate, contradicting the dep graph (the protocol crates are
parallel to the dial, not downstream of it). Callers compose
AlknetClient::dial_quic + spawn_dispatch — two lines, the dial then
the take-over. Tests use spawn_dispatch directly to wire mock/loopback
connections. The one-way-door surface is spawn_dispatch; the dial
lives in the composition layer (AlknetClient today — future owner
OQ-43).
This mirrors ChannelClient::from_connection (ADR-043) and is the
client-side analogue of the server-side generalization ADR-007 made.
The call protocol, like the channels protocol, is transport-agnostic —
Connection::from_stream / from_bidi (ADR-007) accept any
AsyncRead + AsyncWrite, and spawn_dispatch takes the resulting
Connection unchanged.
Peer-keyed composition env (ADR-024)
The composition env that aggregates multiple connections is peer-keyed
(ADR-024 §1). CompositeOperationEnv's singular
connection: Option<Arc<dyn OperationEnv>> is replaced by PeerCompositeEnv
with peer-keyed connections:
pub struct PeerCompositeEnv {
pub base: Arc<dyn OperationEnv + Send + Sync>, // Layer 0 curated
pub session: Option<Arc<dyn OperationEnv + Send + Sync>>, // Layer 1
pub connections: HashMap<PeerId, Arc<dyn OperationEnv + Send + Sync>>, // Layer 2, peer-keyed
connection_order: Vec<PeerId>, // insertion order for PeerRef::Any first-match
}
pub type PeerId = String; // = Identity.id from IdentityProvider resolution
// = PeerEntry.peer_id (stable, not crypto material — ADR-025)
OperationEnv gains a peer-routing method with a PeerRef selector
(Specific(PeerId) / Any), default-impl for back-compat. See
ADR-024 §2 for the full
invoke_peer signature and ScopedPeerEnv peer-qualified reachability. The
per-CallConnection overlay stays flat (one connection = one peer); the
peer-keying is at the aggregation layer (the head node's composition env).
services/list
services/list filters by AccessControl::check(calling_peer_identity) —
the calling peer sees only ops it is authorized to call. There is a
single AccessControl-filtered handler (no peer_scoped variant, no
remote_safe filter — both retired by ADR-024). services/list-peers
is the opt-in for peer-attributed re-export listing (each peer's
sub-overlay listed with attribution, filtered by the calling peer's
authorization). See ADR-024 §6.
Credential sources for connections
The credential dimensions are split across two layers (ADR-012, amended 2026-07-17):
ConnectionCredentials(vendored core types in alkcall, per ADR-012) — the transport-level credential bundle, consumed by the dial (AlknetClient). Carries the two transport-identity dimensions:local_identity(the local node'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). See ADR-012 for the credential-bundle decoupling.
Credentials come from Capabilities (ADR-010), never from environment
variables. The transport-identity dimensions (ADR-022 §7):
// Transport-level (vendored core in alkcall, consumed by the dial — ADR-012)
pub struct ConnectionCredentials {
pub local_identity: Option<TlsIdentity>, // RFC 7250 raw key or X.509
pub remote_identity: Option<RemoteIdentity>, // expected fingerprint (None = CA path / fail-closed)
}
// auth_token is a per-request payload field, not a credential struct.
// Browsers send it in the WebSocket call payload; the HTTP gateway
// resolves bearer → Identity at its boundary.
// Dispatcher::resolve_identity reads payload.get("auth_token").
RemoteIdentity (ADR-022 §7, extended by alknet ADR-034 §2 — the
verifier-rule ADR, re-owned as ADR-054) carries a
fingerprint string the assembly layer derives from Capabilities when
the local node has a PeerEntry for the remote (the known-peer case →
fingerprint pin). remote_identity: None is the public X.509
endpoint case: the local node has no PeerEntry for the remote, so
there is no fingerprint to pin. Combined with an X.509 transport, None
selects CA verification (WebPkiServerVerifier) per the
verifier-selection rule in ADR-054. Combined with an Ed25519
raw-key transport, None fails closed (raw-key remotes are always
known peers — no CA to fall back to). The Option is load-bearing, not
cosmetic: Some(fingerprint) means "pin this" (known peer), None
means "trust the CA or fail" (unknown remote). An implementer must not
default remote_identity to a placeholder value to "satisfy" the field
— None is a real state that drives verifier selection.
pub struct RemoteIdentity { pub fingerprint: String }
There is no call-protocol credential bundle. The transport dimensions
(local_identity, remote_identity) are in ConnectionCredentials with
the vendored core types in alkcall, per ADR-012.
- TLS identity — the local node's Ed25519 raw key (RFC 7250) or X.509 cert, derived from the vault at startup (vault ADRs live in the source repo — alknet ADR-020/026/027, provenance).
- Auth token — an opaque call-protocol-level token, decrypted from the vault or derived from a shared secret.
- Remote identity verification — the expected fingerprint/cert of the
remote node, stored as a capability.
Some→ fingerprint pin (known peer with aPeerEntry);None→ CA verification for X.509 remotes, fail-closed for Ed25519 raw-key remotes (ADR-054). 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-010),
and that ConnectionCredentials lives with the vendored core types in
alkcall (ADR-012).
TLS client-auth presentation (OQ-14 #1, wired): the client presents
its Ed25519 key as an RFC 7250 raw public key client cert — the client-side
equivalent of the server's RawKeyCertResolver. This is wired now, not
additive: it is what activates the PeerEntry fingerprint → peer_id
resolution path on quinn connections (ADR-025 §5). Without it, the ADR-024
peer graph doesn't populate for quinn connections — PeerId resolution
fails because the server has no client cert to extract a fingerprint from.
The iroh path already works (iroh uses RFC 7250 raw keys and exchanges
Ed25519 public keys during the TLS handshake automatically); the gap was
quinn-only, and OQ-14 #1 resolves it by replacing with_no_client_auth()
with presenting the key. The one-way constraint (credentials from
Capabilities, not env vars, ADR-010) is unaffected — the auth_token
dimension flows through the call-protocol auth_token payload field, not
TLS, so the no-env-vars invariant holds independently of the TLS layer.
Remote-identity verification (OQ-14 #2, additive): verifying the
server's fingerprint against an expected value (credentials.remote_identity)
is additive — the server-side fingerprint extraction is what matters for
PeerId, not the client-side verification. The verifier for raw keys can
start as "accept any, extract fingerprint" and add fingerprint-pinning later.
This is a two-way-door remainder; the one-way constraint (credentials from
Capabilities, not env vars) is unaffected.
Server cert verifier selection (OQ-14 #2 + ADR-054 §2, re-owned
from the source repo's ADR-034): the client-side
ServerCertVerifier is selected by whether the local node has a PeerEntry
for the remote, not by key type alone. A pure-client
connection to a public X.509 endpoint (no PeerEntry on the local
side — e.g., dialing api.alk.dev or a third-party API) uses
WebPkiServerVerifier (CA verification), gets no PeerId on the
client side, and is not added to PeerCompositeEnv — it is not in
the call-protocol peer graph (ADR-024). Ops discovered via from_call
on such a connection land in the connection's Layer 2 overlay
(ADR-019) and are invoked through the CallConnection handle directly,
not via PeerRef::Specific. A connection to a hub (a PeerEntry
with mixed Ed25519 + X.509 fingerprints) uses fingerprint pinning on
both cert paths and does enter the peer graph. An unknown Ed25519
raw-key remote fails closed (no CA to fall back to — raw-key remotes
are always known peers). See
ADR-054
for the verifier selection rule and the three-role naming.
from_call
from_call discovers the remote peer's External operations and registers
them in the connection's Layer 2 overlay as FromCall-provenance leaves with
forwarding handlers. The discovery mechanism (services/list +
services/schema) is already implemented in registry/discovery.rs;
from_call is the client-side consumer of that API.
pub struct FromCallConfig {
/// Namespace prefix applied to imported operation names. Optional —
/// default no prefix. Collision on import is an error (DC-3, OQ-13),
/// not last-wins.
pub namespace_prefix: Option<String>,
/// Optional filter — import only operations whose names match. None
/// imports all External ops discovered via services/list.
pub operation_filter: Option<HashSet<String>>,
}
/// Discover the remote peer's External ops and construct HandlerRegistration
/// bundles with FromCall provenance and forwarding handlers. The caller
/// registers the bundles in the connection's overlay via
/// CallConnection::register_imported_all().
pub async fn from_call(
connection: &CallConnection,
config: FromCallConfig,
) -> Result<Vec<HandlerRegistration>, AdapterError>;
The flow (ADR-022 §3):
- Call
services/liston the remote → list ofExternaloperations. - Call
services/schemafor each → input/output JSON Schemas and declarederror_schemas(ADR-016). - 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-021):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-020). No truncation, no first-value fallback — afrom_call-imported subscription forwards the full remote stream.Pub→ the sink forwarding shape (registered asHandlerKind::Sink, ADR-046 + ADR-022 amendment 2026-10-13): a real forwarding body, not an advertised-always-failing stub. A localpublish()on the imported op streams thecall.publishedchunks across the connection; the remote peer'sSinkHandlerconsumes them.
provenance: FromCall,composition_authority: None,scoped_env: None(leaf — ADR-018).
- The caller registers the bundles via
CallConnection::register_imported_all().
Marked ops are not importable (ADR-047 amendment 4 §4.8):
from_call's discovery reconstructs the channel_open marker (ADR-047
Gap F) so the importing side can report the remote channel-machinery
ops, but register_imported / register_imported_all refuse marked
specs — the marker's data plane is the allocated channel's BiStream,
which has no overlay path (OperationEnv::invoke is request/response
only, ADR-046 §6). A hub that needs a marked op composes it with the
channel machinery instead: ChannelRelay::register_relay_openable
(ADR-051) on the relaying side, or direct consumption via ChannelClient
(ADR-043). Likewise op/register refuses a marked announcement with
MARKED_SPEC_UNSUPPORTED (ADR-022 amendment 2026-10-13). This resolves
OQ-37: there is no from_call relay wrapper for marked ops — the relay
composition is the sanctioned path.
Re-import on reconnection (DC-2, OQ-12): from_call is a free function;
the assembly layer calls it after the dial (in the composition layer's dial helper, AlknetClient — today the alknet glue; OQ-43). The overlay
is per-connection (Layer 2, ADR-019), so a stale overlay dies with the
connection; re-import on reconnect is naturally scoped to the new
connection. A CallConnection::refresh() method for mid-connection
re-discovery is a genuine feature addition — non-breaking, additive — if a
deployment needs manual re-discovery without drop-and-reconnect. See
ADR-028.
Namespace collision (DC-3, OQ-13): under the peer-graph model (ADR-024),
cross-peer collision dissolves — same name on different peers is fine (they
live in separate peer sub-overlays, no prefix needed). Same-peer collision
stays an error (a peer shouldn't expose two ops with the same name).
FromCallConfig::namespace_prefix is optional local-naming sugar for when
the importing node wants to expose a peer's ops under a different name
locally — a local-naming concern, not a disambiguation concern. It defaults
to None.
Trust is transitive (recorded in operation-registry.md): a
from_call-imported operation executes the remote node's code, not yours.
The scoped env (ADR-017) bounds which operations are reachable, not what
they do. from_call means "I trust the remote node as much as my own
handlers." The abort cascade (ADR-020) crosses the node boundary transparently
through the forwarding handler's parent_request_id.
Forwarded-for identity (ADR-026): the from_call forwarding handler
populates forwarded_for on the call.requested payload it constructs to
send to the spoke. The hub reads its own OperationContext.identity (the
end user it authenticated) and sets forwarded_for to that identity when
forwarding. The spoke receives it as metadata on its OperationContext —
available for logging, auditing, per-user rate limiting, but never used by
AccessControl::check (the spoke authorizes the hub, its direct caller,
not the end user). The hub may set forwarded_for: None if it doesn't
want to disclose the originator. See ADR-026.
from_jsonschema
from_jsonschema was originally specified here (ADR-022 §5) as a
schema-only adapter in the call protocol — a placeholder handler returning
NOT_FOUND. That was broken: an op in the registry needs a real handler,
and the "schema-only, no handler" concept conflated schema validation
(a planning activity that doesn't need a registry entry) with operation
registration (which always needs a handler).
ADR-027 moved
from_jsonschema to the HTTP host crate — today alkhttp
(ex-alknet-http) — as an HTTP-backed single-endpoint
adapter: the caller supplies an OperationSpec + HttpServiceConfig +
path template + method, and the adapter builds one
HandlerRegistration with a real reqwest forwarding handler and
FromJsonSchema provenance. It is functionally similar to from_openapi
but one endpoint at a time, for non-standard / non-OpenAPI / basic REST
endpoints that don't have a full OpenAPI document. See alkhttp's
http-adapters.md §"from_jsonschema" (its original source-repo copy at
/workspace/@alkdev/alknet/docs/architecture/crates/http/http-adapters.md,
provenance).
The schema-validation-without-a-handler use case (the original stated
purpose) is served by consuming OperationSpec directly — the spec
already carries the input/output JSON Schemas. No adapter, no registry
entry, no handler is needed for that.
The FromJsonSchema provenance variant stays in alkcall
(OperationProvenance in registry/registration.rs); only the adapter
implementation moved.
OperationAdapter trait
The shared shape across import-style adapters. The trait lives in alkcall (where the types live); the implementations live where their transport dependencies live (see "Adapter Location Map" below).
#[async_trait]
pub trait OperationAdapter: Send + Sync {
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
}
The trait is async because from_call requires async discovery
(services/list + services/schema over a call-protocol connection,
which may be QUIC, TCP+TLS, or any other transport). Sync adapters
(from_openapi, from_mcp reading a static spec) trivially satisfy an async
trait — their import() bodies contain no .await points. This is locked by
ADR-022 §5.
The error type (DC-4, OQ-11) is Result<Vec<HandlerRegistration>, AdapterError> where AdapterError is a crate-level enum covering the
failure modes real implementations hit: discovery transport failure
(from_call remote unreachable), schema parse failure (from_openapi,
from_jsonschema), unauthorized (HTTP 401 for from_openapi,
from_mcp). The exact AdapterError variants are the two-way-door
remainder; the presence of an error type is filled in here. ADR-022 §5
showed async fn import(&self) -> Vec<HandlerRegistration> with no error
type; the spec omitted the error type as an implementation-detail two-way
door, recorded here.
Implementations:
FromCall— call-protocol-backed, transport-agnostic (in alkcall).from_calldiscovers ops over aCallConnection, which may be QUIC, TCP+TLS, or any transportConnection::from_streamsupports (ADR-007).FromOpenAPI— HTTP-backed (inalkhttp).FromJsonSchema— HTTP-backed, single-endpoint (inalkhttpper ADR-027; was a broken schema-only placeholder in the call protocol).FromMCP— MCP streamable-HTTP-backed (inalkhttp, feature-gated).
The to_* adapters (to_openapi, to_mcp) are outbound projections, not
OperationAdapter implementations — they consume the registry, they don't
produce entries for it (ADR-022 §5).
Adapter Location Map
The decomposition principle: the adapter trait lives where the types live (alkcall); the adapter implementations live where their transport dependencies live.
alkcall (lean — no HTTP client, no HTTP server)
├── OperationAdapter trait (the contract — async, per ADR-022 §5)
├── from_call (transport-agnostic — discovers remote ops via
│ call protocol over any Connection)
└── CallClient (outbound connection take-over —
spawn_dispatch, transport-agnostic; dial in
the composition layer's helper, AlknetClient)
alkhttp (owns HTTP server + HTTP client — sibling host crate)
├── ProtocolHandler for h2/http1.1/h3 (axum server — inbound HTTP)
├── from_openapi (parse OpenAPI doc + reqwest forwarding handler)
├── from_jsonschema (single-endpoint reqwest forwarding handler — ADR-027)
├── to_openapi (generate OpenAPI doc from local registry)
├── from_mcp (feature-gated) (import remote MCP tools over streamable HTTP — reqwest)
└── to_mcp (feature-gated) (expose local ops as MCP tools over streamable HTTP — axum)
Not built: MCP stdio transport
— stdio = spawn arbitrary executable = built-in RCE ("download untrusted MCP servers")
— streamable HTTP is the only supported MCP transport in the alk stack
— recorded as an explicit security position, not a feature gap
alkcall never sees the HTTP client. The from_openapi/from_mcp
forwarding handlers are opaque Arc<dyn Handler> from the registry's
perspective — constructed by alkhttp::from_openapi() at registration
time, stored in HandlerRegistration, dispatched by the CallAdapter which
doesn't know reqwest is involved. alkcall stays lean (no reqwest, no
axum); alkhttp owns both HTTP directions.
ADR-031 dependency note: alkhttp implementing from_openapi/
from_mcp means alkhttp depends on alkcall (for OperationSpec,
Handler, HandlerRegistration, OperationAdapter). ADR-031's rule is "no
handler crate depends on another handler crate" — but alkcall is the
protocol foundation that every downstream consumer
(including the HTTP host and any agent crate) builds on.
alkhttp depending on alkcall is "HTTP uses the
call protocol types," not "HTTP depends on a sibling handler." This is within the spirit of
ADR-031 (alkcall is protocol-foundation, not a peer handler).
No-Env-Vars Invariant
The architectural mechanism for the env-var problem in downstream consumers
(the Rust port of Vercel's AI SDK at /workspace/aisdk/, whose providers all
read std::env::var("OPENAI_API_KEY") in their Default impls). The fix is
not to modify those consumers — it's that the env-var path is never taken
because the assembly layer never calls Default::default().
The credential injection path:
vault (seed)
→ assembly layer (derive + decrypt at startup, per ADR-010/019/025)
→ Capabilities (non-serializable, zeroized, immutable — ADR-010)
→ HandlerRegistration.capabilities (ADR-018, the registration bundle)
→ OperationContext.capabilities (per-request, populated by dispatch
path from the bundle — ADR-018 §6)
→ from_openapi handler reads context.capabilities.get("openai")
→ injects into HTTP Authorization header
→ reqwest request goes out with vault-derived credential
The from_openapi/from_mcp forwarding handlers (in alkhttp) are the
credential injection point. They read from context.capabilities, not from
std::env::var. The downstream consumers' Default impls reading env vars
are simply never called — the assembly layer constructs providers with
vault-derived credentials through the builder API, or the provider's HTTP
calls are routed through from_openapi operations that carry the credential
in Capabilities.
This is a spec-level invariant in alkcall, not a runtime convention.
The dispatch path (build_root_context and OperationEnv::invoke() per
ADR-018 §6) populates OperationContext.capabilities from the registration
bundle. The invariant is: no handler reads outbound credentials from any
source other than OperationContext.capabilities. This is already the
architectural intent of ADR-010; this document records it as an explicit
invariant that the from_openapi/from_mcp handler implementations (in
alkhttp) are verified against.
Exchange-of-Operations Pattern (Runner / Container Service)
The canonical downstream pattern this completion unblocks, recorded here so
Phase 1 specs can reference it. Concrete example: the container service at
/workspace/@alkdev/dispatch (axum + russh SSH client for "reverse git
runner" over Docker/vast.ai) gets rewritten as a call-protocol service.
Bilateral exchange:
Container service (runs on a vast.ai/docker instance):
Defines Local ops: /container/exec, /container/list, /container/logs...
(real handlers — calls bollard or vast.ai API)
Connects to hub as a CallClient (outbound connection — runner pattern)
Hub (central server):
Runs CallAdapter (server) on alk/call (already implemented)
When the container service connects:
hub runs from_call → discovers /container/* via services/list + services/schema
registers them as FromCall provenance (leaf, forwarding handlers) in the
connection's Layer 2 overlay (ADR-019)
Now the hub (or anything connected to the hub) can call /container/exec
The from_call handler forwards over the connection back to the container service
Bilateral: the container service ALSO runs from_call against the hub,
discovers the hub's External ops, and can call them.
Connection direction (container → hub) is independent of call direction
(both can call each other) per ADR-022 §2.
What this requires:
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 foralkhttp's adapters).
Why the container service doesn't need SSH: under the call
protocol, the container service is a CallClient that dials the hub's
alk/call ALPN (over QUIC, TCP+TLS, or any transport) — no SSH in
the loop. SSH port
forwarding becomes the transitional mechanism for targets that can't run a
call-protocol client (the source repo's SSH phase-0 findings — alknet,
provenance — document this
transition). Once the container service runs a CallClient, SSH is out of
the path entirely.
This is the "dev runner" pattern: a call-protocol client that connects back
to a hub and exposes core dev tools (bash, fs, etc.) as operations. The agent
crate (downstream) is the consumer that orchestrates these
via env.invoke().
Implementation Priority Order
Based on the gap analysis and the downstream unblock chain:
-
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 entire call-completion effort. -
from_call(critical, depends onCallClient) — consumes the already-implementedservices/list+services/schemadiscovery API. -
OperationAdaptertrait (enabling) — the async trait. Small, standalone, unblocksalkhttp's adapter work (includingfrom_jsonschemaper ADR-027). -
DC-1 resolution (peer-graph routing model, ADR-024) — the peer-keyed overlay +
AccessControl-based peer authorization model that replaces ADR-023'sremote_safe/trusted_peer. This is a structural change toCompositeOperationEnv(→PeerCompositeEnv), the dispatch path (retireRemoteFilter), andOperationEnv(gaininvoke_peer). See ADR-024 for the migration; the POC shapes in the research doc are the reference.
What This Completion Unblocks
Historical planning table (source-repo era; crate names as they were).
The completion has since landed in this crate — alknet-http is now
alkhttp.
| Downstream crate | What it needs from alkcall | Status without completion |
|---|---|---|
| alknet-http (→ alkhttp) | OperationAdapter trait (to implement from_openapi/from_mcp) |
Blocked — can't define HTTP-backed adapters without the trait |
| alknet-ssh (historical) | Stable alkcall types (no adapter dependency) | Not blocked — ssh depends on the vendored core types, not alkcall's adapters. Proceeds in parallel. |
| alknet-agent (historical) | CallClient (tool dispatch), from_call (remote tool import), OperationAdapter (provider adapters) |
Blocked on CallClient + from_call |
| Container service (dispatch rewrite) | CallClient + from_call |
Blocked — this is the primary consumer |
| Runner pattern (dev runner, opencode runner) | CallClient + from_call |
Blocked — the runner IS a CallClient |
| alknet-napi (historical) | CallClient (Node.js calls remote ops) |
Blocked — NAPI projects CallClient to JS |
Constraints
- No HTTP in alkcall.
from_openapi/from_mcp/from_jsonschema/to_openapi/to_mcplive inalkhttp. TheOperationAdaptertrait and the call-protocol-backed adapter (from_call, transport- agnostic) live in alkcall.from_jsonschemawas originally (mis)placed in the call protocol as a schema-only placeholder; ADR-027 moved it to the HTTP host crate as a real HTTP-backed adapter. See Adapter Location Map. - No secret material on the wire.
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-010). 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-024 §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-024 §1-2). from_callis a manual free function. The assembly layer calls it after the dial (in the composition layer's dial helper,AlknetClient— today the alknet glue; OQ-43). The overlay is per-connection so re-import on reconnect is naturally scoped (DC-2, OQ-12). See ADR-028.from_callnamespace collision is same-peer only. Cross-peer collision dissolves (same name on different peers is fine — separate sub-overlays, ADR-024 §5). Same-peer collision stays an error.namespace_prefixis optional local-naming sugar, not the disambiguation mechanism (DC-3, OQ-13).OperationAdapter::import()returnsResult. Failures surface asAdapterError(DC-4, OQ-11).- MCP stdio transport is not built. Streamable HTTP is the only supported MCP transport in the alk stack. stdio = spawn arbitrary executable = built-in RCE. Recorded as an explicit security position, not a feature gap.
- Pure-client X.509 connections are not in the peer graph on the client
side. A
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-054. ConnectionCredentials.remote_identity: Noneis load-bearing.Nonemeans "noPeerEntryfor this remote → use CA verification (X.509) or fail closed (Ed25519 raw key)" per the verifier rule (ADR-054). 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-022 | CallClient opens connections; from_call imports remote ops; connection direction independent of call direction; trait is async; adapters produce HandlerRegistration bundles |
from_jsonschema as HTTP-backed single-endpoint adapter |
ADR-027 | Moved from_jsonschema from the call protocol (broken schema-only placeholder) to the HTTP host crate as a real reqwest-backed single-endpoint adapter — today alkhttp (ex-alknet-http); FromJsonSchema provenance stays in alkcall as a leaf |
| Peer-graph routing model (DC-1, supersedes ADR-023) | ADR-024 | Peer-keyed overlays + PeerRef routing; peer authorization via existing AccessControl::check(peer_identity); retires remote_safe/trusted_peer |
| PeerEntry and Identity.id decoupling | ADR-025 | PeerId source changes from UUID to Identity.id (= PeerEntry.peer_id, stable across key rotation); Identity.id decoupled from crypto material on the fingerprint path |
| Forwarded-for identity | ADR-026 | forwarded_for field on call.requested and OperationContext; the from_call handler populates it; metadata only, never used by AccessControl::check |
| Storage boundary and repo/adapter pattern | alknet ADR-033 (provenance) | Core defines repo traits + in-memory defaults; persistence adapters are separate crates (composition-layer work — OQ-43) |
| Secret material flow and capability injection | ADR-010 | The no-env-vars invariant's foundation; capabilities injected at assembly layer |
| Handler registration, provenance, and composition authority | ADR-018 | The registration bundle adapters produce; composition_authority: None for leaves |
| Operation registry layering | ADR-019 | Layer 2 per-connection overlay where from_call imports land |
| Privilege model and authority context | ADR-017 | Adapter-registered ops are Internal by default; default-deny posture |
| Abort cascade for nested calls | ADR-020 | Cross-node abort through from_call forwarding handler's parent_request_id |
| Operation error schemas | ADR-016 | error_schemas mirrored by from_call from remote op's spec |
| Streaming handler for subscriptions | ADR-021 | from_call Subscription ops register a StreamingHandler (HandlerKind::Stream) that calls CallConnection::subscribe() and forwards the remote stream; Query/Mutation stay HandlerKind::Once |
| TLS identity redesign | alknet ADR-027 | RFC 7250 raw key / X.509 cert dimensions of the local TlsIdentity (now carried by ConnectionCredentials.local_identity) |
| Outgoing-only X.509 and three remote roles | ADR-054 (re-owned; provenance: alknet ADR-034) | Public X.509 endpoint is not a PeerEntry on the client side (no PeerId, not in peer graph); client-side verifier by PeerEntry presence (CA vs fingerprint pin); hub = mixed-fingerprint PeerEntry |
| HD derivation for encryption keys | alknet ADR-020 (provenance — vault ADRs live in the source repo) | Vault-derived TLS identity material |
| Vault key model | alknet ADR-026 (provenance) | Vault-derived TLS identity material |
| Vault local-only dispatch | alknet ADR-025 (provenance) | Vault access at assembly layer only; the credential injection path's first hop |
| Crate decomposition | ADR-031 | alkhttp depends on alkcall (protocol-foundation exception, noted in Adapter Location Map) |
| One-way door decision framework | ADR-032 | Door-type classification for DC-1..4 |
Open Questions
See open-questions.md for full details.
- OQ-10 (dissolved by ADR-024):
remote_safemarking shape — moot.remote_safe/trusted_peerare retired; peer authorization isAccessControl::check(peer_identity). No marking to shape. - OQ-11 (resolved):
AdapterErrorvariants —DiscoveryFailed,SchemaParse,Transport,Unauthorized,SamePeerCollision(replaces flatConflict).#[non_exhaustive]. - OQ-12 (resolved):
from_callre-import trigger —from_callis a manual free function; the assembly layer calls it after the dial (inAlknetClient). ACallConnection::refresh()method is a genuine feature addition — non-breaking, additive. See ADR-028. - OQ-13 (resolved):
from_callnamespace collision — same-peer collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays).namespace_prefixis optional local-naming sugar. - OQ-14 (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-14 in open-questions.md. - OQ-15 (resolved):
PeerRef::Anyrouting policy — insertion-order first-match. A richerRoutingPolicyis a feature extension. - OQ-16 (resolved):
services/list-peers— opt-in;services/listis "own ops only." - OQ-17 (deferred(scope), feature extension): Multi-hop federation — the one-hop model is the architectural commitment; multi-hop is a feature extension that doesn't break downstream. The peer-keyed model extends to multi-hop without redesign; petgraph is the candidate if path-finding becomes real (ADR-024 §3.7).
- OQ-18 (resolved by ADR-025):
PeerIdis a logical id. Source isIdentity.idfromIdentityProviderresolution (=PeerEntry.peer_id, stable across key rotation). See OQ-18 in open-questions.md. - OQ-19 (resolved by ADR-025): Persistent peer registry —
the storage boundary is
core trait + in-memory default(config-backedConfigIdentityProvidernow; persistence adapters additive in separate crates). See OQ-19 in open-questions.md. - OQ-20 (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-20 in open-questions.md. - OQ-21 (resolved by ADR-054): X.509 outgoing-only
case — three
remote roles named (public X.509 endpoint, transport relay, hub).
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). The rule is re-owned in-crate by ADR-054 (provenance: alknet ADR-034, "Outgoing-Only X.509 and Three Peer Roles", not ported); ADR-025'sPeerEntryasymmetry is the registry surface. See OQ-21 in open-questions.md.
References
- ADR-022: Call Protocol Client and Adapter Contract (the spec this document operationally fills)
- ADR-024: Peer-Graph Routing Model (resolves DC-1 with peer-keyed overlays
AccessControl-based peer authorization)
call-protocol.md—CallAdapter,CallConnection, dispatch loop, stream model (the server-side complement to this document)operation-registry.md—HandlerRegistration, provenance, capability injection, service discovery (the discovery APIfrom_callconsumes)- Source-repo research (alknet, provenance — these paths live in
/workspace/@alkdev/alknet/docs/research/, not this repo):alknet-call-completion/gap-analysis.md— DC-1..4, the implementation-state audit, the downstream unblock chainalknet-call-peer-routing/findings.md— the peer-graph routing research that identified ADR-023's structural gap and validated the ADR-024 design via POCalknet-ssh/phase-0-findings.md— SSH Phase 0; confirms ssh depends on the vendored core types, not alkcall's adapters, so it proceeds in parallel with the completion
/workspace/@alkdev/operations/— TypeScript prior art (from_openapi.ts,from_mcp.ts,from_schema.ts,scanner.ts)/workspace/@alkdev/dispatch/— concrete downstream consumer (container service / "reverse git runner") this completion unblocks/workspace/aisdk/— downstream consumer (Rust port of Vercel AI SDK); the no-env-vars invariant makes itsstd::env::varreads unreachable/workspace/rust-sdk/— MCP Rust SDK (rmcp); streamable HTTP transport foralkhttp'sfrom_mcp/to_mcp