ADR-091 decided `ConnectionCredentials.local_identity`; the code implemented
`tls_identity` (tasks/core/connection-credentials.md deferred the rename as
"path of least resistance" during the extraction). The tangle that made the
rename hard no longer exists, so align the code with the decision.
Scope is the `ConnectionCredentials` field + builder only:
- alknet-core/credentials.rs: field, with_local_identity, doc, test
- alknet-tls/client.rs: field access in TlsClientConfig::new, test builders, docs
- alknet-client/dial/quinn.rs: test builder
NOT renamed (distinct concepts sharing the words):
- StaticConfig.tls_identity (server-side static config; ADR-082/027/083)
- TlsIdentity enum type name
- alknet-tls server fn params named tls_identity (&TlsIdentity value)
Also fixes dial_iroh.rs doc comments that claimed the local key is extracted
from creds.local_identity — the key is actually on the pre-built iroh endpoint
(set at with_iroh time); the dial reads only creds.remote_identity and ignores
creds.local_identity (per client/README.md §iroh).
Architecture specs updated to match (call/client-and-adapters.md, tls/README.md,
client/README.md). Historical ADR context describing the old CallCredentials
field stays as-is; tasks/ and docs/research/ are historical artifacts.
Resolves follow-up #2 from the post-extraction spec sync (c6eef73).
881 lines
51 KiB
Markdown
881 lines
51 KiB
Markdown
---
|
|
status: draft
|
|
last_updated: 2026-07-17
|
|
---
|
|
|
|
# alknet-call — Client and Adapters
|
|
|
|
The outbound half of the call protocol: opening connections, importing remote
|
|
operations, and the adapter contract that ties import-style adapters together.
|
|
This document covers what ADR-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`:
|
|
|
|
1. **`CallClient`** — takes over an established transport `Connection`
|
|
on ALPN `alknet/call`, spawns the shared dispatch loop, and produces
|
|
a `CallConnection`. Transport-agnostic (`spawn_dispatch` primary;
|
|
`connect` removed per ADR-089 §5 — dial extracted to `AlknetClient`);
|
|
the dispatch loop is shared with the server-side `CallAdapter`
|
|
(ADR-017 §1); `CallClient` is the connection-take-over half, not a
|
|
parallel protocol implementation.
|
|
2. **`from_call`** — discovers operations on a remote call-protocol endpoint
|
|
via `services/list` + `services/schema` (already implemented in
|
|
`registry/discovery.rs`) and registers them in the connection's Layer 2
|
|
overlay as `FromCall`-provenance leaves with forwarding handlers.
|
|
3. **`OperationAdapter` trait** — the async trait that `from_call`,
|
|
`from_openapi`, `from_mcp`, and `from_jsonschema` all implement.
|
|
|
|
> **`from_jsonschema` moved.** ADR-066 moved `from_jsonschema` from
|
|
> `alknet-call` to `alknet-http` and gave it a real reqwest-backed
|
|
> forwarding handler (it was a broken schema-only placeholder before).
|
|
> It is now an HTTP-backed single-endpoint adapter for non-standard /
|
|
> non-OpenAPI / basic REST endpoints, functionally similar to
|
|
> `from_openapi` but one endpoint at a time. See
|
|
> [`crates/http/http-adapters.md`](../http/http-adapters.md) §"from_jsonschema".
|
|
> The `FromJsonSchema` provenance variant stays in `alknet-call`
|
|
> (`OperationProvenance`); only the adapter implementation moved.
|
|
|
|
It also records two cross-cutting architectural mechanisms that the adapter
|
|
surface rests on:
|
|
|
|
- The **adapter location map** — which adapters live in `alknet-call` vs
|
|
`alknet-http`, and why.
|
|
- The **no-env-vars invariant** — the architectural mechanism by which
|
|
downstream consumers' `std::env::var` credential reads are made unreachable.
|
|
|
|
And one downstream pattern this completion unblocks:
|
|
|
|
- The **exchange-of-operations pattern** (runner / container service) — the
|
|
canonical bilateral composition this client surface enables.
|
|
|
|
## Why
|
|
|
|
The server-side `CallAdapter` (accept path) and `CallConnection` (dispatch
|
|
loop) are implemented and tested. The client side is the #1 gap blocking every
|
|
downstream consumer: the runner pattern (a process that connects outward to a
|
|
hub and exposes local ops), the container-service rewrite, the bilateral
|
|
exchange, the NAPI projection, and the agent's cross-node tool dispatch all
|
|
require a `CallClient`. `from_call` is the #2 gap; the `OperationAdapter`
|
|
trait is the enabling gap for `alknet-http`'s `from_openapi`/`from_mcp`.
|
|
|
|
ADR-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.
|
|
|
|
```rust
|
|
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](../../decisions/029-peer-graph-routing-model.md) §3 for the full
|
|
mapping of the three `remote_safe` cases to `AccessControl`/`Visibility`.
|
|
|
|
The connection is symmetric after establishment (ADR-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).
|
|
|
|
```rust
|
|
/// Shared dispatcher for an established CallConnection. Constructed by both
|
|
/// CallAdapter (accept path) and CallClient (connect path). Holds no
|
|
/// per-connection state; the CallConnection is passed into run_loop.
|
|
pub struct Dispatcher {
|
|
pub registry: Arc<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 use
|
|
`spawn_dispatch` directly to wire mock/loopback connections. The
|
|
one-way-door surface is `spawn_dispatch`; the dial lives in
|
|
`alknet-client`.
|
|
|
|
This mirrors `ChannelClient::from_connection` (ADR-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:
|
|
|
|
```rust
|
|
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](../../decisions/029-peer-graph-routing-model.md) §2 for the full
|
|
`invoke_peer` signature and `ScopedPeerEnv` peer-qualified reachability. The
|
|
per-`CallConnection` overlay stays flat (one connection = one peer); the
|
|
peer-keying is at the aggregation layer (the head node's composition env).
|
|
|
|
#### services/list
|
|
|
|
`services/list` filters by `AccessControl::check(calling_peer_identity)` —
|
|
the calling peer sees only ops it is authorized to call. 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](../../decisions/029-peer-graph-routing-model.md) §6.
|
|
|
|
### Credential sources for connections
|
|
|
|
The credential dimensions are split across two layers (ADR-091, amended
|
|
2026-07-17):
|
|
|
|
- **`ConnectionCredentials`** (in `alknet-core`, per ADR-091) — the
|
|
**transport-level** credential bundle, consumed by the dial
|
|
(`AlknetClient`). Carries the two transport-identity dimensions:
|
|
`local_identity` (the local node's `TlsIdentity`) and `remote_identity`
|
|
(the expected fingerprint). The dial does not depend on the call
|
|
protocol for this type.
|
|
- **`auth_token`** — a **per-request payload field**, not a
|
|
call-protocol credential bundle. `Dispatcher::resolve_identity`
|
|
reads `payload.get("auth_token")` on each `call.requested` payload.
|
|
Browsers send it directly in the WebSocket call payload; the HTTP
|
|
gateway resolves the bearer token to an `Identity` at its boundary
|
|
(the call layer sees the identity, not the token). `CallCredentials`
|
|
is **removed** (its `auth_token` field had no reader — `connect()`
|
|
read only `tls_identity` + `remote_identity`; `spawn_dispatch` takes
|
|
no credentials; the `from_call` forwarding path's `auth_token` source
|
|
was `OpSummary.credentials_auth_token: Option<String>`, always
|
|
`None`, never connected to `CallCredentials.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):
|
|
|
|
```rust
|
|
// Transport-level (alknet-core, consumed by the dial — ADR-091)
|
|
pub struct ConnectionCredentials {
|
|
pub local_identity: Option<TlsIdentity>, // RFC 7250 raw key or X.509
|
|
pub remote_identity: Option<RemoteIdentity>, // expected fingerprint (None = CA path / fail-closed)
|
|
}
|
|
|
|
// auth_token is a per-request payload field, not a credential struct.
|
|
// Browsers send it in the WebSocket call payload; the HTTP gateway
|
|
// resolves bearer → Identity at its boundary.
|
|
// Dispatcher::resolve_identity reads payload.get("auth_token").
|
|
```
|
|
|
|
`RemoteIdentity` (ADR-017 §7, extended by ADR-034 §2) carries a
|
|
fingerprint string the assembly layer derives from `Capabilities` when
|
|
the local node has a `PeerEntry` for the remote (the known-peer case →
|
|
fingerprint pin). `remote_identity: None` is the **public X.509
|
|
endpoint** case: the local node has no `PeerEntry` for the remote, so
|
|
there is no fingerprint to pin. Combined with an X.509 transport, `None`
|
|
selects CA verification (`WebPkiServerVerifier`) per the
|
|
verifier-selection rule in ADR-034 §3. Combined with an Ed25519
|
|
raw-key transport, `None` fails closed (raw-key remotes are always
|
|
known peers — no CA to fall back to). The `Option` is load-bearing, not
|
|
cosmetic: `Some(fingerprint)` means "pin this" (known peer), `None`
|
|
means "trust the CA or fail" (unknown remote). An implementer must not
|
|
default `remote_identity` to a placeholder value to "satisfy" the field
|
|
— `None` is a real state that drives verifier selection.
|
|
|
|
```rust
|
|
pub struct RemoteIdentity { pub fingerprint: String }
|
|
```
|
|
|
|
There is no call-protocol credential bundle. `CallCredentials` is
|
|
removed. The transport dimensions (`local_identity`, `remote_identity`)
|
|
are in `ConnectionCredentials` in `alknet-core` per ADR-091.
|
|
|
|
- **TLS identity** — the local node's Ed25519 raw key (RFC 7250) or X.509 cert,
|
|
derived from the vault at startup (ADR-020, ADR-026, ADR-027).
|
|
- **Auth token** — an opaque call-protocol-level token, decrypted from the
|
|
vault or derived from a shared secret.
|
|
- **Remote identity verification** — the expected fingerprint/cert of the
|
|
remote node, stored as a capability. `Some` → fingerprint pin (known
|
|
peer with 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):
|
|
|
|
1. Call `services/list` on the remote → list of `External` operations.
|
|
2. Call `services/schema` for each → input/output JSON Schemas and declared
|
|
`error_schemas` (ADR-023).
|
|
3. For each discovered op, construct a `HandlerRegistration`:
|
|
- `spec` mirrors the remote op's name (with optional prefix), namespace,
|
|
type, schemas, access control.
|
|
- `handler` is a forwarding handler, **branched on `op_type`** (ADR-049):
|
|
- `Query` / `Mutation` → a `Handler` (registered as `HandlerKind::Once`):
|
|
sends `call.requested` via `CallConnection::call_with_payload()`, awaits
|
|
the single `call.responded` (or `call.error`), returns the
|
|
`ResponseEnvelope`.
|
|
- `Subscription` → a `StreamingHandler` (registered as
|
|
`HandlerKind::Stream`): calls `CallConnection::subscribe()`, which
|
|
returns `impl Stream<Item = ResponseEnvelope>` (the client-side
|
|
streaming path, already implemented), maps it to a
|
|
`BoxStream<ResponseEnvelope>`. The remote stream flows end-to-end:
|
|
each `call.responded` the remote sends becomes a stream item; the
|
|
remote's `call.completed` ends the stream (→ wire `call.completed`);
|
|
`call.aborted` drops the stream (cascade per ADR-016). No truncation,
|
|
no first-value fallback — a `from_call`-imported subscription forwards
|
|
the full remote stream.
|
|
- `provenance: FromCall`, `composition_authority: None`, `scoped_env: None`
|
|
(leaf — ADR-022).
|
|
4. The caller registers the bundles via
|
|
`CallConnection::register_imported_all()`.
|
|
|
|
**Re-import on reconnection** (DC-2, OQ-27): `from_call` is a free function;
|
|
the assembly layer calls it after `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](../../decisions/069-from-call-manual-free-function.md).
|
|
|
|
**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](../../decisions/032-forwarded-for-identity.md).
|
|
|
|
### 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](../../decisions/066-from-jsonschema-as-http-adapter.md) moved
|
|
`from_jsonschema` to `alknet-http` as an HTTP-backed single-endpoint
|
|
adapter: the caller supplies an `OperationSpec` + `HttpServiceConfig` +
|
|
path template + method, and the adapter builds one
|
|
`HandlerRegistration` with a real reqwest forwarding handler and
|
|
`FromJsonSchema` provenance. It is functionally similar to `from_openapi`
|
|
but one endpoint at a time, for non-standard / non-OpenAPI / basic REST
|
|
endpoints that don't have a full OpenAPI document. See
|
|
[`crates/http/http-adapters.md`](../http/http-adapters.md) §"from_jsonschema".
|
|
|
|
The schema-validation-without-a-handler use case (the original stated
|
|
purpose) is served by consuming `OperationSpec` directly — the spec
|
|
already carries the input/output JSON Schemas. No adapter, no registry
|
|
entry, no handler is needed for that.
|
|
|
|
The `FromJsonSchema` provenance variant stays in `alknet-call`
|
|
(`OperationProvenance` in `registry/registration.rs`); only the adapter
|
|
implementation moved.
|
|
|
|
### OperationAdapter trait
|
|
|
|
The shared shape across import-style adapters. The trait lives in
|
|
`alknet-call` (where the types live); the implementations live where their
|
|
transport dependencies live (see "Adapter Location Map" below).
|
|
|
|
```rust
|
|
#[async_trait]
|
|
pub trait OperationAdapter: Send + Sync {
|
|
async fn import(&self) -> Result<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 (in
|
|
`alknet-call`). `from_call` discovers ops over a `CallConnection`,
|
|
which may be QUIC, TCP+TLS, or any transport `Connection::from_stream`
|
|
supports (ADR-065).
|
|
- `FromOpenAPI` — HTTP-backed (in `alknet-http`).
|
|
- `FromJsonSchema` — HTTP-backed, single-endpoint (in `alknet-http` per
|
|
ADR-066; was a broken schema-only placeholder in `alknet-call`).
|
|
- `FromMCP` — MCP streamable-HTTP-backed (in `alknet-http`, feature-gated).
|
|
|
|
The `to_*` adapters (`to_openapi`, `to_mcp`) are outbound projections, not
|
|
`OperationAdapter` implementations — they consume the registry, they don't
|
|
produce entries for it (ADR-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**:
|
|
1. `CallClient` — the container service uses it to open the outbound
|
|
connection to the hub. The #1 gap.
|
|
2. `from_call` — both sides run it to populate their Layer 2 overlays with
|
|
the other side's `External` ops. The #2 gap.
|
|
3. `OperationAdapter` trait — `from_call` implements it. The #3 gap (enabling,
|
|
not blocking — `from_call` can be built as a free function before the trait
|
|
exists, but the trait is needed for `alknet-http`'s adapters).
|
|
|
|
**Why the container service doesn't need alknet-ssh**: under the call
|
|
protocol, the container service is a `CallClient` that dials the hub's
|
|
`alknet/call` ALPN (over QUIC, TCP+TLS, or any transport) — no SSH in
|
|
the loop. SSH port
|
|
forwarding becomes the *transitional* mechanism for targets that can't run a
|
|
call-protocol client (the `alknet-ssh` phase-0 findings document this
|
|
transition). Once the container service runs a `CallClient`, SSH is out of
|
|
the path entirely.
|
|
|
|
This is the "dev runner" pattern: a call-protocol client that connects back
|
|
to a hub and exposes core dev tools (bash, fs, etc.) as operations. The agent
|
|
service (`alknet-agent`, downstream) is the consumer that orchestrates these
|
|
via `env.invoke()`.
|
|
|
|
## Implementation Priority Order
|
|
|
|
Based on the gap analysis and the downstream unblock chain:
|
|
|
|
1. **`CallClient`** (critical) — outbound connection opener. Without it, no
|
|
runner, no container service, no bilateral exchange. Reuses the existing
|
|
`CallConnection` for the dispatch loop; adds only the
|
|
connection-establishment + credential-handling half. The single
|
|
highest-value piece of work in the entire `alknet-call` completion.
|
|
|
|
2. **`from_call`** (critical, depends on `CallClient`) — consumes the
|
|
already-implemented `services/list` + `services/schema` discovery API.
|
|
|
|
3. **`OperationAdapter` trait** (enabling) — the async trait. Small,
|
|
standalone, unblocks `alknet-http` Phase 1 (including `from_jsonschema`
|
|
per ADR-066).
|
|
|
|
4. **DC-1 resolution** (peer-graph routing model, ADR-029) — the
|
|
peer-keyed overlay + `AccessControl`-based peer authorization model that
|
|
replaces ADR-028's `remote_safe`/`trusted_peer`. This is a structural
|
|
change to `CompositeOperationEnv` (→ `PeerCompositeEnv`), the dispatch
|
|
path (retire `RemoteFilter`), and `OperationEnv` (gain `invoke_peer`).
|
|
See ADR-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_mcp` live in `alknet-http`. The `OperationAdapter`
|
|
trait and the call-protocol-backed adapter (`from_call`, transport-
|
|
agnostic) live in `alknet-call`. `from_jsonschema` was originally
|
|
(mis)placed in `alknet-call` as a schema-only placeholder; ADR-066
|
|
moved it to `alknet-http` as a real HTTP-backed adapter. See Adapter
|
|
Location Map.
|
|
- **No secret material on the wire.** `ConnectionCredentials` carries vault-derived
|
|
material for the *outbound* connection (TLS identity); `auth_token` is a
|
|
per-request payload field (browsers send it in the WebSocket call payload;
|
|
the HTTP gateway resolves bearer → `Identity` at its boundary). The
|
|
call protocol's wire format carries no private keys, API keys, or decrypted
|
|
credentials (ADR-014). The no-env-vars invariant (above) is the dispatch-side
|
|
corollary.
|
|
- **Peer authorization via `AccessControl`.** A remote peer's call is
|
|
authorized by `AccessControl::check(peer_identity)` against the op's
|
|
`AccessControl` — the same mechanism that gates every other call. No
|
|
`remote_safe` flag, no `trusted_peer` bypass (ADR-029 §3). An op with
|
|
`AccessControl::default()` is callable by any peer; an op with
|
|
`required_scopes` is callable only by peers whose `Identity.scopes` satisfy
|
|
them; an op with `Visibility::Internal` is never callable from the wire.
|
|
- **Composition env is peer-keyed.** A head node with N worker connections
|
|
holds a `PeerCompositeEnv` with `connections: HashMap<PeerId, Arc<dyn OperationEnv>>`,
|
|
not a singular connection overlay. `invoke_peer()` routes to the right peer
|
|
via `PeerRef::Specific` / `PeerRef::Any` (ADR-029 §1-2).
|
|
- **`from_call` is a manual free function.** The assembly layer calls it after
|
|
`connect()`. The overlay is per-connection so re-import on reconnect is
|
|
naturally scoped (DC-2, OQ-27). See
|
|
[ADR-069](../../decisions/069-from-call-manual-free-function.md).
|
|
- **`from_call` namespace collision is same-peer only.** Cross-peer collision
|
|
dissolves (same name on different peers is fine — separate sub-overlays,
|
|
ADR-029 §5). Same-peer collision stays an error. `namespace_prefix` is
|
|
optional local-naming sugar, not the disambiguation mechanism (DC-3, OQ-28).
|
|
- **`OperationAdapter::import()` returns `Result`.** Failures surface as
|
|
`AdapterError` (DC-4, OQ-26).
|
|
- **MCP stdio transport is not built.** Streamable HTTP is the only supported
|
|
MCP transport in alknet. stdio = spawn arbitrary executable = built-in RCE.
|
|
Recorded as an explicit security position, not a feature gap.
|
|
- **Pure-client X.509 connections are not in the peer graph on the client
|
|
side.** A `CallClient` connection to a public X.509 endpoint with no
|
|
local `PeerEntry` for the remote gets no `PeerId`, is not added to
|
|
`PeerCompositeEnv`, and is not addressable via `PeerRef::Specific`.
|
|
Ops discovered on it live in the connection's Layer 2 overlay and are
|
|
invoked through the `CallConnection` handle. The client-side
|
|
`ServerCertVerifier` uses CA verification (`WebPkiServerVerifier`) for
|
|
such remotes; known peers (hub with `PeerEntry`) use fingerprint
|
|
pinning. See [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md).
|
|
- **`ConnectionCredentials.remote_identity: None` is load-bearing.** `None`
|
|
means "no `PeerEntry` for this remote → use CA verification (X.509)
|
|
or fail closed (Ed25519 raw key)" per the ADR-034 §3 verifier rule.
|
|
The implementation must not default `remote_identity` to a placeholder
|
|
to satisfy the field, and must not treat `None` as "skip verification"
|
|
— `None` + X.509 is CA verification, `None` + raw key is a hard
|
|
failure. `Some(fingerprint)` is the known-peer pin path.
|
|
|
|
## Design Decisions
|
|
|
|
| Decision | ADR | Summary |
|
|
|----------|-----|---------|
|
|
| Call protocol client and adapter contract | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction; trait is async; adapters produce `HandlerRegistration` bundles. ~~`from_jsonschema` clause superseded by ADR-066~~ |
|
|
| `from_jsonschema` as HTTP-backed single-endpoint adapter in alknet-http | [ADR-066](../../decisions/066-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf |
|
|
| Peer-graph routing model (DC-1, supersedes ADR-028) | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via existing `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` |
|
|
| PeerEntry and Identity.id decoupling | [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | `PeerId` source changes from UUID to `Identity.id` (= `PeerEntry.peer_id`, stable across key rotation); `Identity.id` decoupled from crypto material on the fingerprint path |
|
|
| Forwarded-for identity | [ADR-032](../../decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; the `from_call` handler populates it; metadata only, never used by `AccessControl::check` |
|
|
| Storage boundary and repo/adapter pattern | [ADR-033](../../decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
|
|
| ~~Peer-scoped registry filtering~~ (superseded) | ~~[ADR-028](../../decisions/028-callclient-peer-scoped-registry-filtering.md)~~ | ~~Default-deny; `remote_safe: bool`; trusted-peer opt-in~~ — superseded by ADR-029 (flat-namespace single-peer model couldn't express head→N-workers; parallel auth system duplicated existing `AccessControl`) |
|
|
| Secret material flow and capability injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | The no-env-vars invariant's foundation; capabilities injected at assembly layer |
|
|
| Handler registration, provenance, and composition authority | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | The registration bundle adapters produce; `composition_authority: None` for leaves |
|
|
| Operation registry layering | [ADR-024](../../decisions/024-operation-registry-layering.md) | Layer 2 per-connection overlay where `from_call` imports land |
|
|
| Privilege model and authority context | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | Adapter-registered ops are `Internal` by default; default-deny posture |
|
|
| Abort cascade for nested calls | [ADR-016](../../decisions/016-abort-cascade-for-nested-calls.md) | Cross-node abort through `from_call` forwarding handler's `parent_request_id` |
|
|
| Operation error schemas | [ADR-023](../../decisions/023-operation-error-schemas.md) | `error_schemas` mirrored by `from_call` from remote op's spec |
|
|
| Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `from_call` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`) that calls `CallConnection::subscribe()` and forwards the remote stream; `Query`/`Mutation` stay `HandlerKind::Once` |
|
|
| TLS identity redesign | [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | RFC 7250 raw key / X.509 cert dimensions of the local `TlsIdentity` (now carried by `ConnectionCredentials.local_identity`) |
|
|
| Outgoing-only X.509 and three peer roles | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Public X.509 endpoint is not a `PeerEntry` on the client side (no `PeerId`, not in peer graph); client-side verifier by `PeerEntry` presence (CA vs fingerprint pin); hub = mixed-fingerprint `PeerEntry` |
|
|
| HD derivation for encryption keys | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Vault-derived TLS identity material |
|
|
| Vault key model | [ADR-026](../../decisions/026-vault-key-model-hd-derivation.md) | Vault-derived TLS identity material |
|
|
| Vault local-only dispatch | [ADR-025](../../decisions/025-vault-local-only-dispatch.md) | Vault access at assembly layer only; the credential injection path's first hop |
|
|
| Crate decomposition | [ADR-003](../../decisions/003-crate-decomposition.md) | `alknet-http` depends on `alknet-call` (protocol-foundation exception, noted in Adapter Location Map) |
|
|
| One-way door decision framework | [ADR-009](../../decisions/009-one-way-door-decision-framework.md) | Door-type classification for DC-1..4 |
|
|
|
|
## Open Questions
|
|
|
|
See [open-questions.md](../../open-questions.md) for full details.
|
|
|
|
- **OQ-25** (dissolved by ADR-029): `remote_safe` marking shape — moot.
|
|
`remote_safe`/`trusted_peer` are retired; peer authorization is
|
|
`AccessControl::check(peer_identity)`. No marking to shape.
|
|
- **OQ-26** (resolved): `AdapterError` variants — `DiscoveryFailed`,
|
|
`SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`
|
|
(replaces flat `Conflict`). `#[non_exhaustive]`.
|
|
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a manual
|
|
free function; the assembly layer calls it after `connect()`. A
|
|
`CallConnection::refresh()` method is a genuine feature addition —
|
|
non-breaking, additive. See [ADR-069](../../decisions/069-from-call-manual-free-function.md).
|
|
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
|
|
collision = error; cross-peer dissolved by ADR-029 (separate sub-overlays).
|
|
`namespace_prefix` is optional local-naming sugar.
|
|
- **OQ-29** (resolved): `CallClient` TLS client-auth — wire quinn
|
|
client-auth (present Ed25519 key as raw public key client cert);
|
|
key-type-aware server cert verification (raw key = fingerprint match,
|
|
X.509 = CA verification); fingerprint normalization (`ed25519:` across
|
|
quinn/iroh). The iroh path already works; the gap was quinn-only.
|
|
See OQ-29 in open-questions.md.
|
|
- **OQ-30** (resolved): `PeerRef::Any` routing policy — insertion-order
|
|
first-match. A richer `RoutingPolicy` is a feature extension.
|
|
- **OQ-31** (resolved): `services/list-peers` — opt-in; `services/list`
|
|
is "own ops only."
|
|
- **OQ-32** (open, feature extension): Multi-hop federation — the one-hop
|
|
model is the architectural commitment; multi-hop is a feature extension
|
|
that doesn't break downstream. The peer-keyed model extends to multi-hop
|
|
without redesign; petgraph is the candidate if path-finding becomes real
|
|
(ADR-029 §3.7).
|
|
- **OQ-33** (resolved by ADR-030): `PeerId` is a logical id. Source is
|
|
`Identity.id` from `IdentityProvider` resolution (= `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-backed
|
|
`ConfigIdentityProvider` now; persistence adapters additive in separate
|
|
crates). See OQ-34 in open-questions.md.
|
|
- **OQ-35** (dissolved): the "API key asymmetry" framing was wrong;
|
|
`PeerEntry` supports multiple credential paths (fingerprints +
|
|
auth_token_hash), `ApiKeyEntry` is for tokens that ARE the identity.
|
|
See OQ-35 in open-questions.md.
|
|
- **OQ-36** (resolved by ADR-035): Concrete persistence adapter shapes —
|
|
read-sync / write-async split (`IdentityStore` async write trait
|
|
extends the sync `IdentityProvider` read trait); SQLite adapter caches
|
|
in memory and uses honker NOTIFY/LISTEN for no-restart cache
|
|
invalidation; `alknet-store-sqlite` crate implements both
|
|
`IdentityStore` and `CredentialStore`. See ADR-035 and OQ-36 in
|
|
open-questions.md.
|
|
- **OQ-37** (resolved by ADR-034): X.509 outgoing-only case — three
|
|
remote roles named (public X.509 endpoint, transport relay, hub).
|
|
`PeerEntry` asymmetry is correct: a pure-client connection to a public
|
|
X.509 endpoint is **not** in the call-protocol peer graph on the
|
|
client side — no `PeerEntry`, no `PeerId`, no `PeerRef::Specific`
|
|
routing. Ops discovered via `from_call`/`from_openapi`/`from_mcp`
|
|
land in the connection's Layer 2 overlay and are invoked through the
|
|
connection handle. The client-side `ServerCertVerifier` is selected
|
|
by `PeerEntry` presence: known peer → fingerprint pin; unknown X.509
|
|
remote → CA verification (`WebPkiServerVerifier`). See ADR-034 and
|
|
OQ-37 in open-questions.md.
|
|
|
|
## References
|
|
|
|
- ADR-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 API `from_call` consumes)
|
|
- `docs/research/alknet-call-completion/gap-analysis.md` — DC-1..4, the
|
|
implementation-state audit, the downstream unblock chain
|
|
- `docs/research/alknet-call-peer-routing/findings.md` — the peer-graph
|
|
routing research that identified ADR-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 its `std::env::var` reads unreachable
|
|
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp); streamable HTTP transport for
|
|
`alknet-http`'s `from_mcp`/`to_mcp` (separate crate, separate Phase 0)
|
|
- `docs/research/alknet-ssh/phase-0-findings.md` — alknet-ssh Phase 0;
|
|
confirms ssh depends on alknet-core not alknet-call's adapters, so it
|
|
proceeds in parallel with this completion |