Port the call + channels architecture documentation from the alknet mono-repo into docs/architecture/, renumbered as alkcall ADR-001..045. Renumbering map (alknet -> alkcall): Core: 001,002,004,006,007,011,065,070,092,014,050,091 -> 001-012 Call: 005,064,012,023,015,022,024,016,049,017,028,029,030,032,066,069,067,068 -> 013-030 Shared: 003,009,013 -> 031-033 Channels: 071,093,072,073,074,075,076,094,079,080,081,089 -> 034-045 3 superseded/reversed ADRs kept for historical trail: - ADR-013 (irpc foundation, superseded by ADR-014) - ADR-023 (peer-scoped filtering, superseded by ADR-024) - ADR-077 (TTY inside channels, reversed by ADR-035 — not ported, TTY-only) Ported docs (11 spec files + README + open-questions): - call-README.md, call-protocol.md, operation-registry.md, client-and-adapters.md - channels-README.md, channels-overview.md, channels-wire.md, channels-connection.md, channels-adapter.md, channel-operations.md, channel-client.md - README.md (index with doc table, ADR table grouped by category, key principles) - open-questions.md (lean — 30 OQs, renumbered OQ-01..030; includes new OQ-22 for the pub/sub gap) Cross-reference rewriting: - All ADR-NNN references rewritten single-pass (no chaining bug) - Markdown link paths fixed - Title lines aligned with filenames - Non-ported ADR refs (052, 082, 086, etc.) left as-is with README note The open-questions.md includes OQ-22 (new): the call protocol pub/sub gap — subscribe exists but pub does not, needed for channels channel/resources/subscribe fan-out. This is the next ADR to write (alkcall ADR-046).
855 lines
49 KiB
Markdown
855 lines
49 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-022 specced but the server-side implementation
|
|
(`call-protocol.md`, `operation-registry.md`) did not include — the `CallClient`
|
|
that *opens* a connection, the `from_call` adapter, and the
|
|
`OperationAdapter` trait. (`from_jsonschema` was originally specced here
|
|
too, but ADR-027 moved it to `alknet-http` — see §"from_jsonschema" below.)
|
|
The server-side `CallAdapter` and `CallConnection`
|
|
dispatch loop are covered in `call-protocol.md`; this document covers the
|
|
client-side connection-establishment half and the adapter surface.
|
|
|
|
## What
|
|
|
|
This document specifies three components, all in `alknet-call`:
|
|
|
|
1. **`CallClient`** — takes over an established transport `Connection`
|
|
on ALPN `alknet/call`, spawns the shared dispatch loop, and produces
|
|
a `CallConnection`. Transport-agnostic (`spawn_dispatch` primary;
|
|
dial lives in `AlknetClient` per ADR-045); the dispatch loop is
|
|
shared with the server-side `CallAdapter`
|
|
(ADR-022 §1); `CallClient` is the connection-take-over half, not a
|
|
parallel protocol implementation.
|
|
2. **`from_call`** — discovers operations on a remote call-protocol endpoint
|
|
via `services/list` + `services/schema` (already implemented in
|
|
`registry/discovery.rs`) and registers them in the connection's Layer 2
|
|
overlay as `FromCall`-provenance leaves with forwarding handlers.
|
|
3. **`OperationAdapter` trait** — the async trait that `from_call`,
|
|
`from_openapi`, `from_mcp`, and `from_jsonschema` all implement.
|
|
|
|
> **`from_jsonschema` moved.** ADR-027 moved `from_jsonschema` from
|
|
> `alknet-call` to `alknet-http` and gave it a real reqwest-backed
|
|
> forwarding handler (it was a broken schema-only placeholder before).
|
|
> It is now an HTTP-backed single-endpoint adapter for non-standard /
|
|
> non-OpenAPI / basic REST endpoints, functionally similar to
|
|
> `from_openapi` but one endpoint at a time. See
|
|
> [`crates/http/http-adapters.md`](../http/http-adapters.md) §"from_jsonschema".
|
|
> The `FromJsonSchema` provenance variant stays in `alknet-call`
|
|
> (`OperationProvenance`); only the adapter implementation moved.
|
|
|
|
It also records two cross-cutting architectural mechanisms that the adapter
|
|
surface rests on:
|
|
|
|
- The **adapter location map** — which adapters live in `alknet-call` vs
|
|
`alknet-http`, and why.
|
|
- The **no-env-vars invariant** — the architectural mechanism by which
|
|
downstream consumers' `std::env::var` credential reads are made unreachable.
|
|
|
|
And one downstream pattern this completion unblocks:
|
|
|
|
- The **exchange-of-operations pattern** (runner / container service) — the
|
|
canonical bilateral composition this client surface enables.
|
|
|
|
## Why
|
|
|
|
The server-side `CallAdapter` (accept path) and `CallConnection` (dispatch
|
|
loop) are implemented and tested. The client side is the #1 gap blocking every
|
|
downstream consumer: the runner pattern (a process that connects outward to a
|
|
hub and exposes local ops), the container-service rewrite, the bilateral
|
|
exchange, the NAPI projection, and the agent's cross-node tool dispatch all
|
|
require a `CallClient`. `from_call` is the #2 gap; the `OperationAdapter`
|
|
trait is the enabling gap for `alknet-http`'s `from_openapi`/`from_mcp`.
|
|
|
|
ADR-022 specced this surface. This document is the spec that operationally
|
|
fills the gap ADR-022 left to implementation: the `CallClient` API, the
|
|
`from_call` flow, the trait signature, the adapter location, the credential
|
|
invariant, and the bilateral pattern. The gap
|
|
analysis (`docs/research/alknet-call-completion/gap-analysis.md`) identified
|
|
four decisions (DC-1..4) needed before implementation. DC-1 was initially
|
|
resolved by ADR-023 (`remote_safe`/`trusted_peer`), but a subsequent research
|
|
pass (`docs/research/alknet-call-peer-routing/findings.md`) found that
|
|
ADR-023's model was structurally broken for the head→N-workers pattern (the
|
|
primary use case) and that its parallel `remote_safe`/`trusted_peer`
|
|
authorization system duplicated the existing `AccessControl`/`Identity`
|
|
machinery. **ADR-024 supersedes ADR-023**: peer-keyed overlays + `PeerRef`
|
|
routing, and peer authorization through the existing `AccessControl::check(peer_identity)`.
|
|
DC-2/3/4 are two-way-door defaults recorded here (DC-2→OQ-27, DC-3→OQ-28
|
|
cross-peer dissolved / same-peer stays, DC-4→OQ-26).
|
|
|
|
## Architecture
|
|
|
|
### CallClient
|
|
|
|
`CallClient` takes over an established transport `Connection` on ALPN
|
|
`alknet/call`, spawns the shared dispatch loop, and produces a
|
|
`CallConnection`. The `CallConnection` type is already implemented
|
|
(`call-protocol.md` §"CallConnection") — it wraps an established
|
|
`Connection` and holds the Layer 2 imported-ops overlay. `CallClient`
|
|
is the producer on the outbound side; `CallAdapter`'s accept path is
|
|
the producer on the inbound side. Both produce the same
|
|
`CallConnection` and hand it to the same shared dispatch loop.
|
|
|
|
`CallClient` is transport-agnostic. The call protocol runs over any
|
|
ordered, reliable bidirectional stream — QUIC, TCP+TLS, WebTransport,
|
|
SSH `direct-tcpip`, a WebSocket (ADR-007 `Connection::from_stream` /
|
|
`from_bidi`). The primary constructor (`spawn_dispatch`) takes a
|
|
pre-established `Connection` from any transport; the dial lives in
|
|
`AlknetClient` (`alknet-client`, ADR-045). This mirrors
|
|
`ChannelClient::from_connection` (ADR-043) and is the client-side
|
|
analogue of the server-side generalization ADR-007 made.
|
|
|
|
```rust
|
|
pub struct CallClient {
|
|
registry: Arc<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-022 Am. 2026-07-13) — it must not be coupled to
|
|
/// a transport.
|
|
pub fn spawn_dispatch(&self, connection: Connection) -> CallConnection;
|
|
}
|
|
```
|
|
|
|
Peer authorization flows through the existing `AccessControl::check` against
|
|
the peer's resolved `Identity` (ADR-024 §3) — there is no `trusted_peer` flag
|
|
and no `remote_safe` marking. When a remote peer calls an op, the dispatch
|
|
path resolves the peer's `Identity` (from the connection's TLS fingerprint or
|
|
the `auth_token` payload, via the existing `IdentityProvider`) and runs
|
|
`AccessControl::check(peer_identity)` against the op's `AccessControl`. If
|
|
the op's required scopes/resources are satisfied, the call dispatches; if not,
|
|
`FORBIDDEN` before the handler runs (capabilities never populated — the
|
|
security property). An op that should never be callable from the wire uses
|
|
`Visibility::Internal` (existing mechanism, `NOT_FOUND` before ACL). See
|
|
[ADR-024](decisions/029-peer-graph-routing-model.md) §3 for the full
|
|
mapping of the three `remote_safe` cases to `AccessControl`/`Visibility`.
|
|
|
|
The connection is symmetric after establishment (ADR-022 §2): both sides can
|
|
send and receive `call.requested`. Connection direction (who opened it) is
|
|
independent of call direction (who calls whom). The `CallClient` is therefore
|
|
both a caller and a callee — it dispatches incoming calls from the remote
|
|
peer through the same `AccessControl`-gated path, and it initiates outgoing
|
|
calls through the `CallConnection::call()` / `subscribe()` / `abort()` API.
|
|
|
|
#### Shared Dispatcher
|
|
|
|
The shared dispatch loop lives in `protocol/dispatch.rs` as the `Dispatcher`
|
|
struct. This is the architectural mechanism that keeps `CallClient` from
|
|
becoming a parallel protocol implementation (ADR-022 §1): both `CallAdapter`'s
|
|
accept path and `CallClient`'s connect path construct a `Dispatcher` and call
|
|
`run_loop` — the dispatch half is one implementation, the
|
|
connection-establishment half differs (accept vs dial).
|
|
|
|
```rust
|
|
/// Shared dispatcher for an established CallConnection. Constructed by both
|
|
/// CallAdapter (accept path) and CallClient (connect path). Holds no
|
|
/// per-connection state; the CallConnection is passed into run_loop.
|
|
pub struct Dispatcher {
|
|
pub registry: Arc<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
|
|
`AlknetClient` (`alknet-client`, ADR-045): keeping a QUIC convenience
|
|
constructor on `CallClient` would make `alknet-call` depend on
|
|
`alknet-client`, contradicting the dep graph (the protocol crates are
|
|
parallel to the dial, not downstream of it). Callers compose
|
|
`AlknetClient::dial_quic` + `spawn_dispatch` — two lines, the dial then
|
|
the take-over. Tests use `spawn_dispatch` directly to wire mock/loopback
|
|
connections. The one-way-door surface is `spawn_dispatch`; the dial
|
|
lives in `alknet-client`.
|
|
|
|
This mirrors `ChannelClient::from_connection` (ADR-043) and is the
|
|
client-side analogue of the server-side generalization ADR-007 made.
|
|
The call protocol, like the channels protocol, is transport-agnostic —
|
|
`Connection::from_stream` / `from_bidi` (ADR-007) accept any
|
|
`AsyncRead + AsyncWrite`, and `spawn_dispatch` takes the resulting
|
|
`Connection` unchanged.
|
|
|
|
#### Peer-keyed composition env (ADR-024)
|
|
|
|
The composition env that aggregates multiple connections is **peer-keyed**
|
|
(ADR-024 §1). `CompositeOperationEnv`'s singular
|
|
`connection: Option<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-025)
|
|
```
|
|
|
|
`OperationEnv` gains a peer-routing method with a `PeerRef` selector
|
|
(`Specific(PeerId)` / `Any`), default-impl for back-compat. See
|
|
[ADR-024](decisions/029-peer-graph-routing-model.md) §2 for the full
|
|
`invoke_peer` signature and `ScopedPeerEnv` peer-qualified reachability. The
|
|
per-`CallConnection` overlay stays flat (one connection = one peer); the
|
|
peer-keying is at the aggregation layer (the head node's composition env).
|
|
|
|
#### services/list
|
|
|
|
`services/list` filters by `AccessControl::check(calling_peer_identity)` —
|
|
the calling peer sees only ops it is authorized to call. There is a
|
|
single `AccessControl`-filtered handler (no `peer_scoped` variant, no
|
|
`remote_safe` filter — both retired by ADR-024). `services/list-peers`
|
|
is the opt-in for peer-attributed re-export listing (each peer's
|
|
sub-overlay listed with attribution, filtered by the calling peer's
|
|
authorization). See [ADR-024](decisions/029-peer-graph-routing-model.md) §6.
|
|
|
|
### Credential sources for connections
|
|
|
|
The credential dimensions are split across two layers (ADR-012, amended
|
|
2026-07-17):
|
|
|
|
- **`ConnectionCredentials`** (in `alknet-core`, per ADR-012) — the
|
|
**transport-level** credential bundle, consumed by the dial
|
|
(`AlknetClient`). Carries the two transport-identity dimensions:
|
|
`local_identity` (the local node's `TlsIdentity`) and `remote_identity`
|
|
(the expected fingerprint). The dial does not depend on the call
|
|
protocol for this type.
|
|
- **`auth_token`** — a **per-request payload field**, not a
|
|
call-protocol credential bundle. `Dispatcher::resolve_identity`
|
|
reads `payload.get("auth_token")` on each `call.requested` payload.
|
|
Browsers send it directly in the WebSocket call payload; the HTTP
|
|
gateway resolves the bearer token to an `Identity` at its boundary
|
|
(the call layer sees the identity, not the token). See ADR-012 for
|
|
the credential-bundle decoupling.
|
|
|
|
Credentials come from `Capabilities` (ADR-010), never from environment
|
|
variables. The transport-identity dimensions (ADR-022 §7):
|
|
|
|
```rust
|
|
// Transport-level (alknet-core, consumed by the dial — ADR-012)
|
|
pub struct ConnectionCredentials {
|
|
pub local_identity: Option<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 ADR-034 §2) carries a
|
|
fingerprint string the assembly layer derives from `Capabilities` when
|
|
the local node has a `PeerEntry` for the remote (the known-peer case →
|
|
fingerprint pin). `remote_identity: None` is the **public X.509
|
|
endpoint** case: the local node has no `PeerEntry` for the remote, so
|
|
there is no fingerprint to pin. Combined with an X.509 transport, `None`
|
|
selects CA verification (`WebPkiServerVerifier`) per the
|
|
verifier-selection rule in ADR-034 §3. Combined with an Ed25519
|
|
raw-key transport, `None` fails closed (raw-key remotes are always
|
|
known peers — no CA to fall back to). The `Option` is load-bearing, not
|
|
cosmetic: `Some(fingerprint)` means "pin this" (known peer), `None`
|
|
means "trust the CA or fail" (unknown remote). An implementer must not
|
|
default `remote_identity` to a placeholder value to "satisfy" the field
|
|
— `None` is a real state that drives verifier selection.
|
|
|
|
```rust
|
|
pub struct RemoteIdentity { pub fingerprint: String }
|
|
```
|
|
|
|
There is no call-protocol credential bundle. The transport dimensions
|
|
(`local_identity`, `remote_identity`) are in `ConnectionCredentials` in
|
|
`alknet-core` per ADR-012.
|
|
|
|
- **TLS identity** — the local node's Ed25519 raw key (RFC 7250) or X.509 cert,
|
|
derived from the vault at startup (ADR-020, ADR-026, ADR-027).
|
|
- **Auth token** — an opaque call-protocol-level token, decrypted from the
|
|
vault or derived from a shared secret.
|
|
- **Remote identity verification** — the expected fingerprint/cert of the
|
|
remote node, stored as a capability. `Some` → fingerprint pin (known
|
|
peer with a `PeerEntry`); `None` → CA verification for X.509 remotes,
|
|
fail-closed for Ed25519 raw-key remotes (ADR-034 §2/§3). The `None`
|
|
case is the public-X.509-endpoint path, not a missing field.
|
|
|
|
These are populated by the assembly layer at `CallClient` construction time
|
|
from vault-derived `Capabilities`. The credential path is the no-env-vars
|
|
invariant (below). The concrete shapes of `TlsIdentity`, `AuthToken`, and
|
|
`RemoteIdentity` are implementation-detail two-way doors; the one-way
|
|
constraints are that they come from `Capabilities`, not env vars (ADR-010).
|
|
|
|
**TLS client-auth presentation** (OQ-29 #1, wired): the client presents
|
|
its Ed25519 key as an RFC 7250 raw public key client cert — the client-side
|
|
equivalent of the server's `RawKeyCertResolver`. This is **wired now**, not
|
|
additive: it is what activates the `PeerEntry` fingerprint → `peer_id`
|
|
resolution path on quinn connections (ADR-025 §5). Without it, the ADR-024
|
|
peer graph doesn't populate for quinn connections — `PeerId` resolution
|
|
fails because the server has no client cert to extract a fingerprint from.
|
|
The iroh path already works (iroh uses RFC 7250 raw keys and exchanges
|
|
Ed25519 public keys during the TLS handshake automatically); the gap was
|
|
quinn-only, and OQ-29 #1 resolves it by replacing `with_no_client_auth()`
|
|
with presenting the key. The one-way constraint (credentials from
|
|
`Capabilities`, not env vars, ADR-010) is unaffected — the `auth_token`
|
|
dimension flows through the call-protocol `auth_token` payload field, not
|
|
TLS, so the no-env-vars invariant holds independently of the TLS layer.
|
|
|
|
**Remote-identity verification** (OQ-29 #2, additive): verifying the
|
|
server's fingerprint against an expected value (`credentials.remote_identity`)
|
|
is **additive** — the server-side fingerprint extraction is what matters for
|
|
`PeerId`, not the client-side verification. The verifier for raw keys can
|
|
start as "accept any, extract fingerprint" and add fingerprint-pinning later.
|
|
This is a two-way-door remainder; the one-way constraint (credentials from
|
|
`Capabilities`, not env vars) is unaffected.
|
|
|
|
**Server cert verifier selection** (OQ-29 #2 + ADR-034 §3): the client-side
|
|
`ServerCertVerifier` is selected by whether the local node has a `PeerEntry`
|
|
for the remote, not by key type alone. A pure-client
|
|
connection to a **public X.509 endpoint** (no `PeerEntry` on the local
|
|
side — e.g., dialing `api.alk.dev` or a third-party API) uses
|
|
`WebPkiServerVerifier` (CA verification), gets **no `PeerId`** on the
|
|
client side, and is **not added to `PeerCompositeEnv`** — it is not in
|
|
the call-protocol peer graph (ADR-024). Ops discovered via `from_call`
|
|
on such a connection land in the connection's Layer 2 overlay
|
|
(ADR-019) and are invoked through the `CallConnection` handle directly,
|
|
not via `PeerRef::Specific`. A connection to a **hub** (a `PeerEntry`
|
|
with mixed Ed25519 + X.509 fingerprints) uses fingerprint pinning on
|
|
both cert paths and does enter the peer graph. An unknown Ed25519
|
|
raw-key remote fails closed (no CA to fall back to — raw-key remotes
|
|
are always known peers). See
|
|
[ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md)
|
|
for the verifier selection rule and the three-role naming.
|
|
|
|
### from_call
|
|
|
|
`from_call` discovers the remote peer's `External` operations and registers
|
|
them in the connection's Layer 2 overlay as `FromCall`-provenance leaves with
|
|
forwarding handlers. The discovery mechanism (`services/list` +
|
|
`services/schema`) is already implemented in `registry/discovery.rs`;
|
|
`from_call` is the client-side consumer of that API.
|
|
|
|
```rust
|
|
pub struct FromCallConfig {
|
|
/// Namespace prefix applied to imported operation names. Optional —
|
|
/// default no prefix. Collision on import is an error (DC-3, OQ-28),
|
|
/// not last-wins.
|
|
pub namespace_prefix: Option<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):
|
|
|
|
1. Call `services/list` on the remote → list of `External` operations.
|
|
2. Call `services/schema` for each → input/output JSON Schemas and declared
|
|
`error_schemas` (ADR-016).
|
|
3. For each discovered op, construct a `HandlerRegistration`:
|
|
- `spec` mirrors the remote op's name (with optional prefix), namespace,
|
|
type, schemas, access control.
|
|
- `handler` is a forwarding handler, **branched on `op_type`** (ADR-021):
|
|
- `Query` / `Mutation` → a `Handler` (registered as `HandlerKind::Once`):
|
|
sends `call.requested` via `CallConnection::call_with_payload()`, awaits
|
|
the single `call.responded` (or `call.error`), returns the
|
|
`ResponseEnvelope`.
|
|
- `Subscription` → a `StreamingHandler` (registered as
|
|
`HandlerKind::Stream`): calls `CallConnection::subscribe()`, which
|
|
returns `impl Stream<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-020). No truncation,
|
|
no first-value fallback — a `from_call`-imported subscription forwards
|
|
the full remote stream.
|
|
- `provenance: FromCall`, `composition_authority: None`, `scoped_env: None`
|
|
(leaf — ADR-018).
|
|
4. The caller registers the bundles via
|
|
`CallConnection::register_imported_all()`.
|
|
|
|
**Re-import on reconnection** (DC-2, OQ-27): `from_call` is a free function;
|
|
the assembly layer calls it after the dial (in `AlknetClient`). The overlay
|
|
is per-connection (Layer 2, ADR-019), so a stale overlay dies with the
|
|
connection; re-import on reconnect is naturally scoped to the new
|
|
connection. A `CallConnection::refresh()` method for mid-connection
|
|
re-discovery is a genuine feature addition — non-breaking, additive — if a
|
|
deployment needs manual re-discovery without drop-and-reconnect. See
|
|
[ADR-028](decisions/069-from-call-manual-free-function.md).
|
|
|
|
**Namespace collision** (DC-3, OQ-28): under the peer-graph model (ADR-024),
|
|
cross-peer collision dissolves — same name on different peers is fine (they
|
|
live in separate peer sub-overlays, no prefix needed). Same-peer collision
|
|
stays an error (a peer shouldn't expose two ops with the same name).
|
|
`FromCallConfig::namespace_prefix` is optional local-naming sugar for when
|
|
the importing node wants to expose a peer's ops under a different name
|
|
*locally* — a local-naming concern, not a disambiguation concern. It defaults
|
|
to `None`.
|
|
|
|
**Trust is transitive** (recorded in `operation-registry.md`): a
|
|
`from_call`-imported operation executes the remote node's code, not yours.
|
|
The scoped env (ADR-017) bounds *which* operations are reachable, not *what*
|
|
they do. `from_call` means "I trust the remote node as much as my own
|
|
handlers." The abort cascade (ADR-020) crosses the node boundary transparently
|
|
through the forwarding handler's `parent_request_id`.
|
|
|
|
**Forwarded-for identity** (ADR-026): the `from_call` forwarding handler
|
|
populates `forwarded_for` on the `call.requested` payload it constructs to
|
|
send to the spoke. The hub reads its own `OperationContext.identity` (the
|
|
end user it authenticated) and sets `forwarded_for` to that identity when
|
|
forwarding. The spoke receives it as metadata on its `OperationContext` —
|
|
available for logging, auditing, per-user rate limiting, but never used by
|
|
`AccessControl::check` (the spoke authorizes the hub, its direct caller,
|
|
not the end user). The hub may set `forwarded_for: None` if it doesn't
|
|
want to disclose the originator. See [ADR-026](decisions/032-forwarded-for-identity.md).
|
|
|
|
### from_jsonschema
|
|
|
|
`from_jsonschema` was originally specified here (ADR-022 §5) as a
|
|
schema-only adapter in `alknet-call` — a placeholder handler returning
|
|
`NOT_FOUND`. That was broken: an op in the registry needs a real handler,
|
|
and the "schema-only, no handler" concept conflated schema validation
|
|
(a planning activity that doesn't need a registry entry) with operation
|
|
registration (which always needs a handler).
|
|
|
|
[ADR-027](decisions/066-from-jsonschema-as-http-adapter.md) moved
|
|
`from_jsonschema` to `alknet-http` as an HTTP-backed single-endpoint
|
|
adapter: the caller supplies an `OperationSpec` + `HttpServiceConfig` +
|
|
path template + method, and the adapter builds one
|
|
`HandlerRegistration` with a real reqwest forwarding handler and
|
|
`FromJsonSchema` provenance. It is functionally similar to `from_openapi`
|
|
but one endpoint at a time, for non-standard / non-OpenAPI / basic REST
|
|
endpoints that don't have a full OpenAPI document. See
|
|
[`crates/http/http-adapters.md`](../http/http-adapters.md) §"from_jsonschema".
|
|
|
|
The schema-validation-without-a-handler use case (the original stated
|
|
purpose) is served by consuming `OperationSpec` directly — the spec
|
|
already carries the input/output JSON Schemas. No adapter, no registry
|
|
entry, no handler is needed for that.
|
|
|
|
The `FromJsonSchema` provenance variant stays in `alknet-call`
|
|
(`OperationProvenance` in `registry/registration.rs`); only the adapter
|
|
implementation moved.
|
|
|
|
### OperationAdapter trait
|
|
|
|
The shared shape across import-style adapters. The trait lives in
|
|
`alknet-call` (where the types live); the implementations live where their
|
|
transport dependencies live (see "Adapter Location Map" below).
|
|
|
|
```rust
|
|
#[async_trait]
|
|
pub trait OperationAdapter: Send + Sync {
|
|
async fn import(&self) -> Result<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-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-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
|
|
`alknet-call`). `from_call` discovers ops over a `CallConnection`,
|
|
which may be QUIC, TCP+TLS, or any transport `Connection::from_stream`
|
|
supports (ADR-007).
|
|
- `FromOpenAPI` — HTTP-backed (in `alknet-http`).
|
|
- `FromJsonSchema` — HTTP-backed, single-endpoint (in `alknet-http` per
|
|
ADR-027; was a broken schema-only placeholder in `alknet-call`).
|
|
- `FromMCP` — MCP streamable-HTTP-backed (in `alknet-http`, feature-gated).
|
|
|
|
The `to_*` adapters (`to_openapi`, `to_mcp`) are outbound projections, not
|
|
`OperationAdapter` implementations — they consume the registry, they don't
|
|
produce entries for it (ADR-022 §5).
|
|
|
|
### Adapter Location Map
|
|
|
|
The decomposition principle: **the adapter trait lives where the types live
|
|
(`alknet-call`); the adapter implementations live where their transport
|
|
dependencies live.**
|
|
|
|
```
|
|
alknet-call (lean — no HTTP client, no HTTP server)
|
|
├── OperationAdapter trait (the contract — async, per ADR-022 §5)
|
|
├── from_call (transport-agnostic — discovers remote ops via
|
|
│ call protocol over any Connection)
|
|
└── CallClient (outbound connection take-over —
|
|
spawn_dispatch, transport-agnostic; dial in AlknetClient)
|
|
|
|
alknet-http (owns HTTP server + HTTP client — separate crate, separate Phase 0)
|
|
├── ProtocolHandler for h2/http1.1/h3 (axum server — inbound HTTP)
|
|
├── from_openapi (parse OpenAPI doc + reqwest forwarding handler)
|
|
├── from_jsonschema (single-endpoint reqwest forwarding handler — ADR-027)
|
|
├── to_openapi (generate OpenAPI doc from local registry)
|
|
├── from_mcp (feature-gated) (import remote MCP tools over streamable HTTP — reqwest)
|
|
└── to_mcp (feature-gated) (expose local ops as MCP tools over streamable HTTP — axum)
|
|
|
|
Not built: MCP stdio transport
|
|
— stdio = spawn arbitrary executable = built-in RCE ("download untrusted MCP servers")
|
|
— streamable HTTP is the only supported MCP transport in alknet
|
|
— recorded as an explicit security position, not a feature gap
|
|
```
|
|
|
|
`alknet-call` never sees the HTTP client. The `from_openapi`/`from_mcp`
|
|
forwarding handlers are opaque `Arc<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-031 dependency note**: `alknet-http` implementing `from_openapi`/
|
|
`from_mcp` means `alknet-http` depends on `alknet-call` (for `OperationSpec`,
|
|
`Handler`, `HandlerRegistration`, `OperationAdapter`). ADR-031's rule is "no
|
|
handler crate depends on another handler crate" — but `alknet-call` is both
|
|
a handler *and* the protocol foundation that `alknet-agent` and `alknet-napi`
|
|
already consume. `alknet-http` depending on `alknet-call` is "HTTP uses the
|
|
call protocol types," not "HTTP depends on SSH." This is within the spirit of
|
|
ADR-031 (`alknet-call` is protocol-foundation, not a peer handler). The
|
|
`alknet-http` spec should note this explicitly; a one-line amendment to
|
|
ADR-031 clarifying that `alknet-call` is a protocol-foundation crate is
|
|
deferred to the `alknet-http` Phase 0.
|
|
|
|
### No-Env-Vars Invariant
|
|
|
|
The architectural mechanism for the env-var problem in downstream consumers
|
|
(the Rust port of Vercel's AI SDK at `/workspace/aisdk/`, whose providers all
|
|
read `std::env::var("OPENAI_API_KEY")` in their `Default` impls). The fix is
|
|
**not** to modify those consumers — it's that the env-var path is never taken
|
|
because the assembly layer never calls `Default::default()`.
|
|
|
|
The credential injection path:
|
|
|
|
```
|
|
vault (seed)
|
|
→ assembly layer (derive + decrypt at startup, per ADR-010/019/025)
|
|
→ Capabilities (non-serializable, zeroized, immutable — ADR-010)
|
|
→ HandlerRegistration.capabilities (ADR-018, the registration bundle)
|
|
→ OperationContext.capabilities (per-request, populated by dispatch
|
|
path from the bundle — ADR-018 §6)
|
|
→ from_openapi handler reads context.capabilities.get("openai")
|
|
→ injects into HTTP Authorization header
|
|
→ reqwest request goes out with vault-derived credential
|
|
```
|
|
|
|
The `from_openapi`/`from_mcp` forwarding handlers (in `alknet-http`) are the
|
|
credential injection point. They read from `context.capabilities`, not from
|
|
`std::env::var`. The downstream consumers' `Default` impls reading env vars
|
|
are simply never called — the assembly layer constructs providers with
|
|
vault-derived credentials through the builder API, or the provider's HTTP
|
|
calls are routed through `from_openapi` operations that carry the credential
|
|
in `Capabilities`.
|
|
|
|
**This is a spec-level invariant in `alknet-call`, not a runtime convention.**
|
|
The dispatch path (`build_root_context` and `OperationEnv::invoke()` per
|
|
ADR-018 §6) populates `OperationContext.capabilities` from the registration
|
|
bundle. The invariant is: *no handler reads outbound credentials from any
|
|
source other than `OperationContext.capabilities`.* This is already the
|
|
architectural intent of ADR-010; this document records it as an explicit
|
|
invariant that the `from_openapi`/`from_mcp` handler implementations (in
|
|
`alknet-http`) are verified against.
|
|
|
|
### Exchange-of-Operations Pattern (Runner / Container Service)
|
|
|
|
The canonical downstream pattern this completion unblocks, recorded here so
|
|
Phase 1 specs can reference it. Concrete example: the container service at
|
|
`/workspace/@alkdev/dispatch` (axum + russh SSH client for "reverse git
|
|
runner" over Docker/vast.ai) gets rewritten as a call-protocol service.
|
|
|
|
**Bilateral exchange**:
|
|
|
|
```
|
|
Container service (runs on a vast.ai/docker instance):
|
|
Defines Local ops: /container/exec, /container/list, /container/logs...
|
|
(real handlers — calls bollard or vast.ai API)
|
|
Connects to hub as a CallClient (outbound connection — runner pattern)
|
|
|
|
Hub (central server):
|
|
Runs CallAdapter (server) on alknet/call (already implemented)
|
|
When the container service connects:
|
|
hub runs from_call → discovers /container/* via services/list + services/schema
|
|
registers them as FromCall provenance (leaf, forwarding handlers) in the
|
|
connection's Layer 2 overlay (ADR-019)
|
|
Now the hub (or anything connected to the hub) can call /container/exec
|
|
The from_call handler forwards over the connection back to the container service
|
|
|
|
Bilateral: the container service ALSO runs from_call against the hub,
|
|
discovers the hub's External ops, and can call them.
|
|
Connection direction (container → hub) is independent of call direction
|
|
(both can call each other) per ADR-022 §2.
|
|
```
|
|
|
|
**What this requires**:
|
|
1. `CallClient` — the container service uses it to open the outbound
|
|
connection to the hub. The #1 gap.
|
|
2. `from_call` — both sides run it to populate their Layer 2 overlays with
|
|
the other side's `External` ops. The #2 gap.
|
|
3. `OperationAdapter` trait — `from_call` implements it. The #3 gap (enabling,
|
|
not blocking — `from_call` can be built as a free function before the trait
|
|
exists, but the trait is needed for `alknet-http`'s adapters).
|
|
|
|
**Why the container service doesn't need alknet-ssh**: under the call
|
|
protocol, the container service is a `CallClient` that dials the hub's
|
|
`alknet/call` ALPN (over QUIC, TCP+TLS, or any transport) — no SSH in
|
|
the loop. SSH port
|
|
forwarding becomes the *transitional* mechanism for targets that can't run a
|
|
call-protocol client (the `alknet-ssh` phase-0 findings document this
|
|
transition). Once the container service runs a `CallClient`, SSH is out of
|
|
the path entirely.
|
|
|
|
This is the "dev runner" pattern: a call-protocol client that connects back
|
|
to a hub and exposes core dev tools (bash, fs, etc.) as operations. The agent
|
|
service (`alknet-agent`, downstream) is the consumer that orchestrates these
|
|
via `env.invoke()`.
|
|
|
|
## Implementation Priority Order
|
|
|
|
Based on the gap analysis and the downstream unblock chain:
|
|
|
|
1. **`CallClient`** (critical) — outbound connection opener. Without it, no
|
|
runner, no container service, no bilateral exchange. Reuses the existing
|
|
`CallConnection` for the dispatch loop; adds only the
|
|
connection-establishment + credential-handling half. The single
|
|
highest-value piece of work in the entire `alknet-call` completion.
|
|
|
|
2. **`from_call`** (critical, depends on `CallClient`) — consumes the
|
|
already-implemented `services/list` + `services/schema` discovery API.
|
|
|
|
3. **`OperationAdapter` trait** (enabling) — the async trait. Small,
|
|
standalone, unblocks `alknet-http` Phase 1 (including `from_jsonschema`
|
|
per ADR-027).
|
|
|
|
4. **DC-1 resolution** (peer-graph routing model, ADR-024) — the
|
|
peer-keyed overlay + `AccessControl`-based peer authorization model that
|
|
replaces ADR-023's `remote_safe`/`trusted_peer`. This is a structural
|
|
change to `CompositeOperationEnv` (→ `PeerCompositeEnv`), the dispatch
|
|
path (retire `RemoteFilter`), and `OperationEnv` (gain `invoke_peer`).
|
|
See ADR-024 for the migration; the POC shapes in the research doc are the
|
|
reference.
|
|
|
|
## What This Completion Unblocks
|
|
|
|
| Downstream crate | What it needs from alknet-call | Status without completion |
|
|
|-------------------|-------------------------------|--------------------------|
|
|
| alknet-http | `OperationAdapter` trait (to implement `from_openapi`/`from_mcp`) | Blocked — can't define HTTP-backed adapters without the trait |
|
|
| alknet-ssh | Stable alknet-call types (no adapter dependency) | Not blocked — ssh depends on alknet-core, not alknet-call's adapters. Proceeds in parallel. |
|
|
| alknet-agent | `CallClient` (tool dispatch), `from_call` (remote tool import), `OperationAdapter` (provider adapters) | Blocked on `CallClient` + `from_call` |
|
|
| Container service (dispatch rewrite) | `CallClient` + `from_call` | Blocked — this is the primary consumer |
|
|
| Runner pattern (dev runner, opencode runner) | `CallClient` + `from_call` | Blocked — the runner IS a `CallClient` |
|
|
| alknet-napi | `CallClient` (Node.js calls remote ops) | Blocked — NAPI projects `CallClient` to JS |
|
|
|
|
## Constraints
|
|
|
|
- **No HTTP in alknet-call.** `from_openapi`/`from_mcp`/`from_jsonschema`/
|
|
`to_openapi`/`to_mcp` live in `alknet-http`. The `OperationAdapter`
|
|
trait and the call-protocol-backed adapter (`from_call`, transport-
|
|
agnostic) live in `alknet-call`. `from_jsonschema` was originally
|
|
(mis)placed in `alknet-call` as a schema-only placeholder; ADR-027
|
|
moved it to `alknet-http` as a real HTTP-backed adapter. See Adapter
|
|
Location Map.
|
|
- **No secret material on the wire.** `ConnectionCredentials` carries vault-derived
|
|
material for the *outbound* connection (TLS identity); `auth_token` is a
|
|
per-request payload field (browsers send it in the WebSocket call payload;
|
|
the HTTP gateway resolves bearer → `Identity` at its boundary). The
|
|
call protocol's wire format carries no private keys, API keys, or decrypted
|
|
credentials (ADR-010). The no-env-vars invariant (above) is the dispatch-side
|
|
corollary.
|
|
- **Peer authorization via `AccessControl`.** A remote peer's call is
|
|
authorized by `AccessControl::check(peer_identity)` against the op's
|
|
`AccessControl` — the same mechanism that gates every other call. No
|
|
`remote_safe` flag, no `trusted_peer` bypass (ADR-024 §3). An op with
|
|
`AccessControl::default()` is callable by any peer; an op with
|
|
`required_scopes` is callable only by peers whose `Identity.scopes` satisfy
|
|
them; an op with `Visibility::Internal` is never callable from the wire.
|
|
- **Composition env is peer-keyed.** A head node with N worker connections
|
|
holds a `PeerCompositeEnv` with `connections: HashMap<PeerId, Arc<dyn OperationEnv>>`,
|
|
not a singular connection overlay. `invoke_peer()` routes to the right peer
|
|
via `PeerRef::Specific` / `PeerRef::Any` (ADR-024 §1-2).
|
|
- **`from_call` is a manual free function.** The assembly layer calls it
|
|
after the dial (in `AlknetClient`). The overlay is per-connection so
|
|
re-import on reconnect is naturally scoped (DC-2, OQ-27). See
|
|
[ADR-028](decisions/069-from-call-manual-free-function.md).
|
|
- **`from_call` namespace collision is same-peer only.** Cross-peer collision
|
|
dissolves (same name on different peers is fine — separate sub-overlays,
|
|
ADR-024 §5). Same-peer collision stays an error. `namespace_prefix` is
|
|
optional local-naming sugar, not the disambiguation mechanism (DC-3, OQ-28).
|
|
- **`OperationAdapter::import()` returns `Result`.** Failures surface as
|
|
`AdapterError` (DC-4, OQ-26).
|
|
- **MCP stdio transport is not built.** Streamable HTTP is the only supported
|
|
MCP transport in alknet. stdio = spawn arbitrary executable = built-in RCE.
|
|
Recorded as an explicit security position, not a feature gap.
|
|
- **Pure-client X.509 connections are not in the peer graph on the client
|
|
side.** A `CallClient` connection to a public X.509 endpoint with no
|
|
local `PeerEntry` for the remote gets no `PeerId`, is not added to
|
|
`PeerCompositeEnv`, and is not addressable via `PeerRef::Specific`.
|
|
Ops discovered on it live in the connection's Layer 2 overlay and are
|
|
invoked through the `CallConnection` handle. The client-side
|
|
`ServerCertVerifier` uses CA verification (`WebPkiServerVerifier`) for
|
|
such remotes; known peers (hub with `PeerEntry`) use fingerprint
|
|
pinning. See [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md).
|
|
- **`ConnectionCredentials.remote_identity: None` is load-bearing.** `None`
|
|
means "no `PeerEntry` for this remote → use CA verification (X.509)
|
|
or fail closed (Ed25519 raw key)" per the ADR-034 §3 verifier rule.
|
|
The implementation must not default `remote_identity` to a placeholder
|
|
to satisfy the field, and must not treat `None` as "skip verification"
|
|
— `None` + X.509 is CA verification, `None` + raw key is a hard
|
|
failure. `Some(fingerprint)` is the known-peer pin path.
|
|
|
|
## Design Decisions
|
|
|
|
| Decision | ADR | Summary |
|
|
|----------|-----|---------|
|
|
| Call protocol client and adapter contract | [ADR-022](decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction; trait is async; adapters produce `HandlerRegistration` bundles |
|
|
| `from_jsonschema` as HTTP-backed single-endpoint adapter in alknet-http | [ADR-027](decisions/066-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf |
|
|
| Peer-graph routing model (DC-1, supersedes ADR-023) | [ADR-024](decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via existing `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` |
|
|
| PeerEntry and Identity.id decoupling | [ADR-025](decisions/030-peerentry-and-identity-id-decoupling.md) | `PeerId` source changes from UUID to `Identity.id` (= `PeerEntry.peer_id`, stable across key rotation); `Identity.id` decoupled from crypto material on the fingerprint path |
|
|
| Forwarded-for identity | [ADR-026](decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; the `from_call` handler populates it; metadata only, never used by `AccessControl::check` |
|
|
| Storage boundary and repo/adapter pattern | [ADR-033](decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
|
|
| Secret material flow and capability injection | [ADR-010](decisions/014-secret-material-flow-and-capability-injection.md) | The no-env-vars invariant's foundation; capabilities injected at assembly layer |
|
|
| Handler registration, provenance, and composition authority | [ADR-018](decisions/022-handler-registration-provenance-and-composition-authority.md) | The registration bundle adapters produce; `composition_authority: None` for leaves |
|
|
| Operation registry layering | [ADR-019](decisions/024-operation-registry-layering.md) | Layer 2 per-connection overlay where `from_call` imports land |
|
|
| Privilege model and authority context | [ADR-017](decisions/015-privilege-model-and-authority-context.md) | Adapter-registered ops are `Internal` by default; default-deny posture |
|
|
| Abort cascade for nested calls | [ADR-020](decisions/016-abort-cascade-for-nested-calls.md) | Cross-node abort through `from_call` forwarding handler's `parent_request_id` |
|
|
| Operation error schemas | [ADR-016](decisions/023-operation-error-schemas.md) | `error_schemas` mirrored by `from_call` from remote op's spec |
|
|
| Streaming handler for subscriptions | [ADR-021](decisions/049-streaming-handler-for-subscriptions.md) | `from_call` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`) that calls `CallConnection::subscribe()` and forwards the remote stream; `Query`/`Mutation` stay `HandlerKind::Once` |
|
|
| TLS identity redesign | [ADR-027](decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | RFC 7250 raw key / X.509 cert dimensions of the local `TlsIdentity` (now carried by `ConnectionCredentials.local_identity`) |
|
|
| Outgoing-only X.509 and three peer roles | [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Public X.509 endpoint is not a `PeerEntry` on the client side (no `PeerId`, not in peer graph); client-side verifier by `PeerEntry` presence (CA vs fingerprint pin); hub = mixed-fingerprint `PeerEntry` |
|
|
| HD derivation for encryption keys | [ADR-020](decisions/020-hd-derivation-for-encryption-keys.md) | Vault-derived TLS identity material |
|
|
| Vault key model | [ADR-026](decisions/026-vault-key-model-hd-derivation.md) | Vault-derived TLS identity material |
|
|
| Vault local-only dispatch | [ADR-025](decisions/025-vault-local-only-dispatch.md) | Vault access at assembly layer only; the credential injection path's first hop |
|
|
| Crate decomposition | [ADR-031](decisions/003-crate-decomposition.md) | `alknet-http` depends on `alknet-call` (protocol-foundation exception, noted in Adapter Location Map) |
|
|
| One-way door decision framework | [ADR-032](decisions/009-one-way-door-decision-framework.md) | Door-type classification for DC-1..4 |
|
|
|
|
## Open Questions
|
|
|
|
See [open-questions.md](open-questions.md) for full details.
|
|
|
|
- **OQ-25** (dissolved by ADR-024): `remote_safe` marking shape — moot.
|
|
`remote_safe`/`trusted_peer` are retired; peer authorization is
|
|
`AccessControl::check(peer_identity)`. No marking to shape.
|
|
- **OQ-26** (resolved): `AdapterError` variants — `DiscoveryFailed`,
|
|
`SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`
|
|
(replaces flat `Conflict`). `#[non_exhaustive]`.
|
|
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
|
|
manual free function; the assembly layer calls it after the dial (in
|
|
`AlknetClient`). A `CallConnection::refresh()` method is a genuine
|
|
feature addition — non-breaking, additive. See
|
|
[ADR-028](decisions/069-from-call-manual-free-function.md).
|
|
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
|
|
collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays).
|
|
`namespace_prefix` is optional local-naming sugar.
|
|
- **OQ-29** (resolved): `CallClient` TLS client-auth — wire quinn
|
|
client-auth (present Ed25519 key as raw public key client cert);
|
|
key-type-aware server cert verification (raw key = fingerprint match,
|
|
X.509 = CA verification); fingerprint normalization (`ed25519:` across
|
|
quinn/iroh). The iroh path already works; the gap was quinn-only.
|
|
See OQ-29 in open-questions.md.
|
|
- **OQ-30** (resolved): `PeerRef::Any` routing policy — insertion-order
|
|
first-match. A richer `RoutingPolicy` is a feature extension.
|
|
- **OQ-31** (resolved): `services/list-peers` — opt-in; `services/list`
|
|
is "own ops only."
|
|
- **OQ-32** (open, feature extension): Multi-hop federation — the one-hop
|
|
model is the architectural commitment; multi-hop is a feature extension
|
|
that doesn't break downstream. The peer-keyed model extends to multi-hop
|
|
without redesign; petgraph is the candidate if path-finding becomes real
|
|
(ADR-024 §3.7).
|
|
- **OQ-33** (resolved by ADR-025): `PeerId` is a logical id. Source is
|
|
`Identity.id` from `IdentityProvider` resolution (= `PeerEntry.peer_id`,
|
|
stable across key rotation). See OQ-33 in open-questions.md.
|
|
- **OQ-34** (resolved by ADR-025 + ADR-033): Persistent peer registry —
|
|
the storage boundary is `core trait + in-memory default` (config-backed
|
|
`ConfigIdentityProvider` now; persistence adapters additive in separate
|
|
crates). See OQ-34 in open-questions.md.
|
|
- **OQ-35** (dissolved): the "API key asymmetry" framing was wrong;
|
|
`PeerEntry` supports multiple credential paths (fingerprints +
|
|
auth_token_hash), `ApiKeyEntry` is for tokens that ARE the identity.
|
|
See OQ-35 in open-questions.md.
|
|
- **OQ-36** (resolved by ADR-035): Concrete persistence adapter shapes —
|
|
read-sync / write-async split (`IdentityStore` async write trait
|
|
extends the sync `IdentityProvider` read trait); SQLite adapter caches
|
|
in memory and uses honker NOTIFY/LISTEN for no-restart cache
|
|
invalidation; `alknet-store-sqlite` crate implements both
|
|
`IdentityStore` and `CredentialStore`. See ADR-035 and OQ-36 in
|
|
open-questions.md.
|
|
- **OQ-37** (resolved by ADR-034): X.509 outgoing-only case — three
|
|
remote roles named (public X.509 endpoint, transport relay, hub).
|
|
`PeerEntry` asymmetry is correct: a pure-client connection to a public
|
|
X.509 endpoint is **not** in the call-protocol peer graph on the
|
|
client side — no `PeerEntry`, no `PeerId`, no `PeerRef::Specific`
|
|
routing. Ops discovered via `from_call`/`from_openapi`/`from_mcp`
|
|
land in the connection's Layer 2 overlay and are invoked through the
|
|
connection handle. The client-side `ServerCertVerifier` is selected
|
|
by `PeerEntry` presence: known peer → fingerprint pin; unknown X.509
|
|
remote → CA verification (`WebPkiServerVerifier`). See ADR-034 and
|
|
OQ-37 in open-questions.md.
|
|
|
|
## References
|
|
|
|
- ADR-022: Call Protocol Client and Adapter Contract (the spec this document
|
|
operationally fills)
|
|
- ADR-024: Peer-Graph Routing Model (resolves DC-1 with peer-keyed overlays
|
|
+ `AccessControl`-based peer authorization)
|
|
- `call-protocol.md` — `CallAdapter`, `CallConnection`, dispatch loop, stream
|
|
model (the server-side complement to this document)
|
|
- `operation-registry.md` — `HandlerRegistration`, provenance, capability
|
|
injection, service discovery (the discovery API `from_call` consumes)
|
|
- `docs/research/alknet-call-completion/gap-analysis.md` — DC-1..4, the
|
|
implementation-state audit, the downstream unblock chain
|
|
- `docs/research/alknet-call-peer-routing/findings.md` — the peer-graph
|
|
routing research that identified ADR-023's structural gap and validated
|
|
the ADR-024 design via POC
|
|
- `/workspace/@alkdev/operations/` — TypeScript prior art (`from_openapi.ts`,
|
|
`from_mcp.ts`, `from_schema.ts`, `scanner.ts`)
|
|
- `/workspace/@alkdev/dispatch/` — concrete downstream consumer (container
|
|
service / "reverse git runner") this completion unblocks
|
|
- `/workspace/aisdk/` — downstream consumer (Rust port of Vercel AI SDK); the
|
|
no-env-vars invariant makes its `std::env::var` reads unreachable
|
|
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp); streamable HTTP transport for
|
|
`alknet-http`'s `from_mcp`/`to_mcp` (separate crate, separate Phase 0)
|
|
- `docs/research/alknet-ssh/phase-0-findings.md` — alknet-ssh Phase 0;
|
|
confirms ssh depends on alknet-core not alknet-call's adapters, so it
|
|
proceeds in parallel with this completion |