docs(arch): unweld CallClient from QUIC — spawn_dispatch primary, connect convenience (ADR-017 Am. 2026-07-13)

Same fix as ADR-080 (ChannelClient) applied to the call crate. The
existing code already had the right structure — spawn_dispatch is not
feature-gated (transport-agnostic), connect is #[cfg(feature = "quinn")]
— but the docs inverted the framing: connect was 'primary,' spawn_dispatch
was 'lower-level.' That welding masked the call protocol's
transport-agnosticism (ADR-012 EventEnvelope; ADR-065 from_stream/from_bidi
accept any AsyncRead + AsyncWrite).

Changes:
- client-and-adapters.md: reframe spawn_dispatch as the transport-
  agnostic primary constructor (one-way door), connect as the QUIC
  convenience (two-way door, feature-gated on quinn). Mirror the
  from_connection / connect_quic pattern from ADR-080/channel-client.md.
  Fix all 'QUIC-backed' / 'over QUIC' / 'opens a QUIC connection' framing
  to be transport-agnostic. Fix from_call description, adapter location
  map, exchange-of-operations example, Constraints section.
- call-protocol.md: 'runs over QUIC bidirectional streams' → 'runs over
  any ordered, reliable bidirectional stream.' Fix stream model, stream
  lifecycle (connection drop / stream reset now list all transports,
  not just QUIC). Fix CallConnection.connection doc comment.
- operation-registry.md: FromCall provenance comment 'QUIC forwarding
  stub' → 'call-protocol forwarding stub.' Fix from_call description.
- call README.md: fix client-and-adapters.md description, fix design
  principle #11 framing.
- ADR-017: add Amendment (2026-07-13) documenting the spawn_dispatch /
  connect reframe, mirroring ADR-080's amendment.
- overview.md, architecture README: update ADR-017 and
  client-and-adapters.md table summaries.
- OQ-015, OQ-007: fix 'opens QUIC connections' / 'bidirectional QUIC
  streams' framing in resolution text.

No code changes — spawn_dispatch and connect already exist with the
right feature-gate structure. This is a documentation reframe.
This commit is contained in:
glm-5.2 committed 2026-07-13 09:53:43 +00:00
1 parent a26401aadd
commit b0cc0a01b7
9 files changed
+135 -46

No files matched your search

+1 -1
View File
@@ -148,7 +148,7 @@ adapter location map is now consistent: all HTTP-backed adapters
| [crates/call/README.md](crates/call/README.md) | draft | alknet-call crate index |
| [crates/call/call-protocol.md](crates/call/call-protocol.md) | draft | CallAdapter, hand-rolled EventEnvelope framing (no irpc — ADR-064), stream model, PendingRequestMap, bidirectional calls, streaming subscribe example |
| [crates/call/operation-registry.md](crates/call/operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, capability injection, service discovery (hand-rolled, no irpc) |
| [crates/call/client-and-adapters.md](crates/call/client-and-adapters.md) | draft | CallClient (outbound connection opener), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (from_jsonschema moved to alknet-http per ADR-066) |
| [crates/call/client-and-adapters.md](crates/call/client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary, `connect` QUIC convenience — ADR-017 Am. 2026-07-13), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (from_jsonschema moved to alknet-http per ADR-066) |
| [crates/http/README.md](crates/http/README.md) | draft | alknet-http crate index |
| [crates/http/overview.md](crates/http/overview.md) | draft | Crate purpose, two roles (server + client host), dependencies, adapter location map |
| [crates/http/http-server.md](crates/http/http-server.md) | draft | HttpAdapter for h2/http1.1 + WebSocket upgrade route, axum over QUIC, Bearer auth, stealth, /healthz |
+2 -2
View File
@@ -14,7 +14,7 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
|----------|--------|-------------|
| [call-protocol.md](call-protocol.md) | draft | CallAdapter, hand-rolled EventEnvelope framing (no irpc — ADR-064), stream model, PendingRequestMap, bidirectional calls |
| [operation-registry.md](operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, hand-rolled framing (no irpc — ADR-064) |
| [client-and-adapters.md](client-and-adapters.md) | draft | CallClient (outbound connection opener), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (from_jsonschema moved to alknet-http per ADR-066) |
| [client-and-adapters.md](client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary, `connect` QUIC convenience — ADR-017 Am. 2026-07-13), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (from_jsonschema moved to alknet-http per ADR-066) |
## Applicable ADRs
@@ -81,7 +81,7 @@ Structured RPC: operations, request/response, streaming subscriptions, and servi
8. **Abort cascades to descendants**: `call.aborted` for a parent request cascades to all non-terminal descendants. Default `abort-dependents`; `continue-running` opt-in. See ADR-016.
9. **Internal calls switch authority context, not skip ACL**: The `internal` flag marks composition-originated calls. ACL runs against the handler's composition authority, not the caller's and not as a blanket skip. Operations have External/Internal visibility. Scoped composition env bounds reachability. See ADR-015, ADR-022.
10. **Provenance determines composition capability**: Only `Local` and `Session` ops can compose. Leaves (`FromOpenAPI`, `FromMCP`, `FromCall`, `FromJsonSchema`) are forwarding stubs — they don't get composition authority or a scoped env. The assembly layer is the sole grantor of composition authority. See ADR-022. (`FromJsonSchema` is now a real HTTP-forwarding leaf per ADR-066, not a schema-only placeholder.)
11. **Connection direction is independent of call direction**: Who opens the QUIC connection is a connection-layer concern, not a protocol-layer concern. Both sides can call each other once connected. The `CallAdapter` accepts connections; the `CallClient` opens them; both produce the same `CallConnection` and dispatch through the same loop. See ADR-017, [client-and-adapters.md](client-and-adapters.md).
11. **Connection direction is independent of call direction**: Who opens the connection is a connection-layer concern, not a protocol-layer concern. Both sides can call each other once connected. The `CallAdapter` accepts connections; the `CallClient` takes them over (`spawn_dispatch` primary, `connect` QUIC convenience — ADR-017 Am. 2026-07-13); both produce the same `CallConnection` and dispatch through the same loop. See ADR-017, [client-and-adapters.md](client-and-adapters.md).
12. **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. 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. See ADR-029.
13. **Adapter trait lives with the types; implementations live with their transport**: `OperationAdapter` is in `alknet-call`; `from_call` is in `alknet-call` (QUIC); `from_jsonschema`/`from_openapi`/`from_mcp`/`to_openapi`/`to_mcp` are in `alknet-http` (reqwest / axum). `alknet-call` stays lean — no HTTP client, no HTTP server. (`from_jsonschema` was originally in `alknet-call` as a schema-only placeholder; ADR-066 moved it to `alknet-http` as a real HTTP-backed adapter.) See [client-and-adapters.md](client-and-adapters.md).
14. **No handler reads outbound credentials from any source other than `OperationContext.capabilities`** (no-env-vars invariant): the credential injection path is vault → assembly layer → `Capabilities` → `HandlerRegistration.capabilities` → `OperationContext.capabilities` → handler. Downstream consumers' `std::env::var` reads are unreachable because the assembly layer never calls `Default::default()`. See ADR-014, [client-and-adapters.md](client-and-adapters.md).
+11 -8
View File
@@ -9,9 +9,9 @@ The wire protocol, stream model, framing, and adapter that alknet-call implement
## What
The call protocol is a bidirectional, stream-agnostic RPC protocol that runs over QUIC bidirectional streams within a single `alknet/call` connection. It supports request/response calls, streaming subscriptions, batch operations, and service discovery — all using the same EventEnvelope wire format.
The call protocol is a bidirectional, transport-agnostic RPC protocol that runs over any ordered, reliable bidirectional stream within a single `alknet/call` connection. It supports request/response calls, streaming subscriptions, batch operations, and service discovery — all using the same EventEnvelope wire format.
The `CallAdapter` implements `ProtocolHandler` for ALPN `alknet/call`. It receives a `Connection` from the endpoint, accepts bidirectional streams, and dispatches incoming `EventEnvelope` messages to the operation registry.
The `CallAdapter` implements `ProtocolHandler` for ALPN `alknet/call`. It receives a `Connection` from the endpoint (QUIC-native, or TCP+TLS/WebTransport/SSH via `Connection::from_stream` — ADR-065), accepts bidirectional streams, and dispatches incoming `EventEnvelope` messages to the operation registry.
## Why
@@ -20,7 +20,7 @@ The call protocol is the primary programmatic interface to an alknet node. While
The protocol must be:
- **Cross-language**: JSON wire format consumable from TypeScript, Python, any language
- **Bidirectional**: Both sides can initiate calls (server-to-client is as natural as client-to-server)
- **Stream-agnostic**: QUIC provides stream multiplexing; the protocol shouldn't impose additional constraints
- **Stream-agnostic**: the protocol runs over any `AsyncRead + AsyncWrite` pair (QUIC streams, TCP+TLS, WebTransport, SSH channels, WebSocket — ADR-065); the transport provides the stream, the protocol provides the framing
- **Discoverable**: Clients can query what operations exist and their schemas
See ADR-064 for the decision that the call protocol uses hand-rolled
@@ -113,7 +113,10 @@ operations discovered when the connection was established.
/// An established alknet/call connection (either direction — accepted or
/// opened). Holds the connection's Layer 2 overlay (imported ops).
pub struct CallConnection {
/// The underlying QUIC connection (from endpoint.accept or CallClient.connect).
/// The underlying transport Connection (from endpoint.accept,
/// CallClient::spawn_dispatch, or CallClient::connect). May be QUIC,
/// TCP+TLS, WebTransport, SSH, or any Connection::from_stream source
/// (ADR-065).
connection: Connection,
/// Layer 2 — this connection's imported-ops overlay. Populated by
/// `from_call` discovery when the connection is established. Each
@@ -188,7 +191,7 @@ peer-graph routing model.
See ADR-012 for the full rationale.
The call protocol uses bidirectional QUIC streams with EventEnvelope framing. Key properties:
The call protocol uses bidirectional streams with EventEnvelope framing (transport-agnostic — QUIC streams, TCP+TLS, WebTransport, SSH channels, WebSocket — ADR-065). Key properties:
- **Either side can open streams**: The client opens a stream to call a server operation. The server opens a stream to call a client operation. Both use `open_bi()` and `accept_bi()`.
- **Correlation by request ID**: The `id` field in `EventEnvelope` correlates requests with responses. A response arriving on stream N can fulfill a request sent on stream M. The `PendingRequestMap` is keyed by ID, not by stream.
@@ -516,9 +519,9 @@ Local dispatch produces `ResponseEnvelope` with no serialization overhead. The `
### Connection and Stream Lifecycle
**Connection drop**: When the QUIC connection closes, all pending requests in the `PendingRequestMap` are failed with `call.error` code `INTERNAL` and message `"connection closed"`. All subscription channels are closed. The `CallAdapter::handle()` method returns `Ok(())` (clean shutdown) or `Err(HandlerError::ConnectionClosed)` (unexpected).
**Connection drop**: When the transport connection closes (QUIC close, TCP FIN, WebSocket close — the `Connection` reports `ConnectionClosed`), all pending requests in the `PendingRequestMap` are failed with `call.error` code `INTERNAL` and message `"connection closed"`. All subscription channels are closed. The `CallAdapter::handle()` method returns `Ok(())` (clean shutdown) or `Err(HandlerError::ConnectionClosed)` (unexpected).
**Stream reset**: When a QUIC stream is reset mid-operation, the `FrameFramedReader` returns an error. If the stream was carrying a subscription, the `PendingRequestMap` entry is removed and the mpsc channel is closed. If the stream was carrying a call, the oneshot is resolved with an error. No `call.aborted` is sent — the stream is gone.
**Stream reset**: When a stream is reset mid-operation (QUIC stream reset, TCP connection drop, or any transport-level error — the `FrameFramedReader` returns an error), the `PendingRequestMap` entry is removed and the mpsc channel is closed. If the stream was carrying a call, the oneshot is resolved with an error. No `call.aborted` is sent — the stream is gone.
**Timeouts**: Default timeout for wire calls is 30 seconds, configurable via
`CallAdapter::with_timeout()`. The `build_root_context` sets
@@ -551,7 +554,7 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
- The call protocol does not depend on any database. `PendingRequestMap` is in-memory. Durable session storage is a consumer concern.
- Operation specs use JSON Schema. The envelope is always JSON. Binary payloads may be base64-encoded in the `payload` field.
- Batch is not a protocol primitive — multiple `call.requested` events with correlated IDs provide equivalent semantics. See OQ-14.
- The call protocol is transport-agnostic at the envelope level. The `EventEnvelope` framing can run over QUIC streams, WebSocket frames, or Worker `postMessage`. The `CallAdapter` is the QUIC-specific implementation. **The `EventEnvelope` shape (`{ type, id, payload }`) was derived from the `@alkdev/pubsub` `EventEnvelope` (`/workspace/@alkdev/pubsub/src/types.ts`), which already has a working WebSocket client/server implementation (`event-target-websocket-client.ts` / `event-target-websocket-server.ts`) and a generalized "event target" abstraction. The call protocol refined the envelope with typed event names (`call.requested`, `call.responded`, etc.) and structured payloads; the delta is small and well-defined, making a browser (and Node) WebSocket client straightforward to derive from the pubsub prior art. See ADR-044, [ADR-048](../../decisions/048-websocket-native-session-not-gateway.md), and [websocket.md](../http/websocket.md).
- The call protocol is transport-agnostic at the envelope level. The `EventEnvelope` framing can run over QUIC streams, WebSocket frames, or Worker `postMessage`. The `CallAdapter` is the `ProtocolHandler` implementation that receives a `Connection` (any transport — ADR-065) and dispatches `EventEnvelope` frames. **The `EventEnvelope` shape (`{ type, id, payload }`) was derived from the `@alkdev/pubsub` `EventEnvelope` (`/workspace/@alkdev/pubsub/src/types.ts`), which already has a working WebSocket client/server implementation (`event-target-websocket-client.ts` / `event-target-websocket-server.ts`) and a generalized "event target" abstraction. The call protocol refined the envelope with typed event names (`call.requested`, `call.responded`, etc.) and structured payloads; the delta is small and well-defined, making a browser (and Node) WebSocket client straightforward to derive from the pubsub prior art. See ADR-044, [ADR-048](../../decisions/048-websocket-native-session-not-gateway.md), and [websocket.md](../http/websocket.md).
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer. See ADR-064 and OQ-13.
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials, raw tokens) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. The wire format carries `serde_json::Value` and cannot enforce this at the type level — the constraint is architectural, enforced by the operation registry and by convention. Operations that need to share public key material use a dedicated operation that returns only the public component. See ADR-014.
- **Abort cascades to descendants.** `call.aborted` for a parent request cascades to all non-terminal descendants in the call tree. Default policy is `abort-dependents`; `continue-running` is an opt-in. See ADR-016.
@@ -20,8 +20,10 @@ client-side connection-establishment half and the adapter surface.
This document specifies three components, all in `alknet-call`:
1. **`CallClient`** — opens an outbound `alknet/call` QUIC connection and
produces a `CallConnection`. The dispatch loop is shared with the
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` QUIC convenience); the dispatch loop is shared with the
server-side `CallAdapter` (ADR-017 §1); `CallClient` is the
connection-establishment + credential-handling half, not a parallel
protocol implementation.
@@ -85,14 +87,24 @@ cross-peer dissolved / same-peer stays, DC-4→OQ-26).
### CallClient
`CallClient` opens a QUIC connection to a remote node on ALPN `alknet/call`,
performs credential setup, 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
`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>,
@@ -102,12 +114,24 @@ pub struct CallClient {
impl CallClient {
pub fn new(registry: Arc<OperationRegistry>, idp: Arc<dyn IdentityProvider>) -> Self;
/// Open a QUIC connection to `addr` on ALPN `alknet/call`, perform
/// credential handshake, and return a CallConnection running the shared
/// dispatch loop. Credentials come from capabilities (ADR-014), not env
/// vars — see "No-Env-Vars Invariant" below. The dispatch loop runs on a
/// spawned task; the returned `CallConnection` is live until the remote
/// closes the connection or the caller drops it.
/// 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;
/// QUIC convenience constructor. Dials a QUIC connection to `addr`
/// on ALPN `alknet/call` (using `credentials` for the TLS handshake
/// — ADR-034 verifier selection), then calls `spawn_dispatch`.
/// Feature-gated on `quinn` (the dial is QUIC-specific). Additive
/// and two-way-door — `connect_tcp_tls`, `connect_webtransport`,
/// etc. join it as transports are added, without touching the
/// `spawn_dispatch` contract.
#[cfg(feature = "quinn")]
pub async fn connect(
&self,
addr: SocketAddr,
@@ -162,11 +186,23 @@ 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 lower-level API that takes a
pre-established `Connection`, constructs a `CallConnection`, builds a
`Dispatcher`, spawns the dispatch task, and returns the live `CallConnection`.
`connect()` uses it after the QUIC dial completes; tests use it to wire
mock/loopback connections directly.
`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
the QUIC convenience over it: dial QUIC (feature-gated on `quinn`),
then `spawn_dispatch`. Tests use `spawn_dispatch` directly to wire
mock/loopback connections. A future `connect_tcp_tls` /
`connect_webtransport` would dial their transport and call
`spawn_dispatch` the same way. The one-way-door surface is
`spawn_dispatch`; the dial helpers are two-way-door conveniences.
This mirrors `ChannelClient::from_connection` / `connect_quic`
(ADR-080) 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)
@@ -429,7 +465,8 @@ pub trait OperationAdapter: Send + Sync {
```
The trait is **async** because `from_call` requires async discovery
(`services/list` + `services/schema` over a QUIC connection). Sync adapters
(`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.
@@ -446,7 +483,10 @@ type; the spec omitted the error type as an implementation-detail two-way
door, recorded here.
Implementations:
- `FromCall` — QUIC-backed (in `alknet-call`).
- `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`).
@@ -465,8 +505,10 @@ dependencies live.**
```
alknet-call (lean — no HTTP client, no HTTP server)
├── OperationAdapter trait (the contract — async, per ADR-017 §5)
├── from_call (QUIC — discovers remote ops via call protocol)
└── CallClient (outbound connection opener — the #1 gap)
├── 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)
@@ -581,7 +623,8 @@ Bilateral: the container service ALSO runs from_call against the hub,
**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 directly over QUIC — no SSH in the loop. SSH port
`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
@@ -632,10 +675,11 @@ Based on the gap analysis and the downstream unblock chain:
- **No HTTP in alknet-call.** `from_openapi`/`from_mcp`/`from_jsonschema`/
`to_openapi`/`to_mcp` live in `alknet-http`. The `OperationAdapter`
trait and the QUIC-backed adapter (`from_call`) 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.
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.** `CallCredentials` carries vault-derived
material for the *outbound* connection (TLS identity, auth token); the
call protocol's wire format carries no private keys, API keys, or decrypted
@@ -382,7 +382,7 @@ pub enum OperationProvenance {
Local, // Assembly-written, trusted, can compose
FromOpenAPI, // HTTP forwarding stub (from_openapi), leaf
FromMCP, // MCP forwarding stub (from_mcp), leaf
FromCall, // QUIC forwarding stub (from_call), leaf locally
FromCall, // call-protocol forwarding stub (from_call), leaf locally
FromJsonSchema, // HTTP forwarding stub (from_jsonschema, single endpoint), leaf
Session, // Agent-written, sandboxed, can compose within sandbox
}
@@ -893,7 +893,7 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
**Adapters take credential sources.** All import adapters (`from_openapi`, `from_mcp`, `from_jsonschema`, `from_call` — see ADR-017, constrained by ADR-014) register HTTP-backed, MCP-backed, or remote-call-backed operations. The credential each service needs (bearer token, API key, TLS identity for the remote connection) is provided by the assembly layer at registration time — the adapter receives a credential source, not a static token string. This is the integration point where the vault feeds credentials into backed operations, including LLM providers that expose OpenAPI-compatible endpoints. Adapter-registered operations are `Internal` by default (ADR-015) — they're composition material, not directly callable from the wire.
**`from_call` imports remote operations.** The `from_call` adapter (ADR-017) discovers operations on a remote call protocol endpoint via `services/list` and `services/schema`, then registers them with handlers that forward calls over the QUIC connection. This makes cross-node composition transparent — a handler calling `env.invoke("worker", "exec", ...)` doesn't know whether the operation is local or remote. Connection direction (who opened the QUIC connection) is independent of call direction (who calls whom) — both sides can call each other once connected.
**`from_call` imports remote operations.** The `from_call` adapter (ADR-017) discovers operations on a remote call protocol endpoint via `services/list` and `services/schema`, then registers them with handlers that forward calls over the call-protocol connection (transport-agnostic — QUIC, TCP+TLS, or any `Connection::from_stream` source, ADR-065). This makes cross-node composition transparent — a handler calling `env.invoke("worker", "exec", ...)` doesn't know whether the operation is local or remote. Connection direction (who opened the connection) is independent of call direction (who calls whom) — both sides can call each other once connected.
**`from_call` trust is transitive.** A `from_call`-imported operation executes the remote node's code, not yours. The scoped env (ADR-015) bounds *which* operations are reachable, but not *what* they do. A compromised remote node can do anything its operations are declared to do (and anything its handler bugs allow). This is inherent to remote composition — same as trusting any RPC endpoint — but it must be explicit in the threat model. `from_call` means "I trust the remote node as much as my own handlers." The scoping protects the caller from reaching arbitrary ops; it does not protect against what the reached op does.
@@ -2,7 +2,7 @@
## Status
Accepted (amended 2026-06-26 — see "Amendments" below)
Accepted (amended 2026-06-26 and 2026-07-13 — see "Amendments" below)
## Context
@@ -439,4 +439,46 @@ served by consuming `OperationSpec` directly. The §5 "adapters live in
alknet-call" one-way-door statement is corrected above to "the adapter
trait lives in `alknet-call`; implementations live with their transport."
See [ADR-066](066-from-jsonschema-as-http-adapter.md) and
[client-and-adapters.md](../crates/call/client-and-adapters.md) §"from_jsonschema".
[client-and-adapters.md](../crates/call/client-and-adapters.md) §"from_jsonschema".
## Amendments (2026-07-13)
### `CallClient` transport-agnostic API (mirrors ADR-080's amendment)
The §1 Decision framed `CallClient::connect(addr: SocketAddr,
credentials)` as the primary constructor and described it as "opens a
QUIC connection." The operational spec
([client-and-adapters.md](../crates/call/client-and-adapters.md))
framed `spawn_dispatch(connection)` as the "lower-level API" that
`connect()` uses after the QUIC dial. That framing welded the
client-side one-way-door API to QUIC — the same welding ADR-065
unwound on the server side and ADR-080 corrected for `ChannelClient`.
The call protocol is transport-agnostic (ADR-012 EventEnvelope framing;
ADR-065 `Connection::from_stream`/`from_bidi` accept any
`AsyncRead + AsyncWrite`). The client side is half of that protocol
and must not be coupled to a transport. This amendment reframes the
existing code (which already has the right structure —
`spawn_dispatch` is not feature-gated, `connect` is
`#[cfg(feature = "quinn")]`):
- **`CallClient::spawn_dispatch(connection: Connection)`** — the
transport-agnostic primary constructor and the one-way-door API.
Takes a pre-established `Connection` (any transport), spawns the
shared dispatch loop, returns a live `CallConnection`. Mirrors the
server-side `CallAdapter::handle(Connection)` and
`ChannelClient::from_connection` (ADR-080).
- **`CallClient::connect(addr, credentials)`** — a QUIC convenience
constructor: dial QUIC (feature-gated on `quinn`), then
`spawn_dispatch`. Additive and two-way-door. `connect_tcp_tls`,
`connect_webtransport`, etc. join it as transports are added,
without touching the `spawn_dispatch` contract.
The door-type classification is unchanged: `spawn_dispatch` is one-way
(the handler-facing surface), `connect` is two-way (additive
convenience). The `AlknetClient` extraction (OQ-55 — the shared
dial+TLS seam) remains deferred; what is deferred is the shared *dial*,
not a QUIC-welded client API. `spawn_dispatch` is decided now.
See [client-and-adapters.md](../crates/call/client-and-adapters.md)
§"CallClient" for the reframed operational spec.
+1 -1
View File
@@ -208,7 +208,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [014](decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Capabilities carry outbound credentials; call protocol carries no secret material |
| [015](decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
| [016](decisions/016-abort-cascade-for-nested-calls.md) | Abort Cascade for Nested Calls | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
| [017](decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction |
| [017](decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` takes over connections (`spawn_dispatch` transport-agnostic primary, `connect` QUIC convenience); `from_call` imports remote ops; connection direction independent of call direction |
| [018](decisions/018-vault-standalone-crate.md) | Vault as Standalone Crate | Zero alknet crate dependencies; vault defines own types and errors |
| [019](decisions/019-vault-assembly-layer-only.md) | Vault Assembly-Layer-Only Access | The assembly layer (CLI binary) is the sole direct caller; handlers never hold a vault reference |
| [020](decisions/020-hd-derivation-for-encryption-keys.md) | HD Derivation for Encryption Keys | SLIP-0010 derivation from seed, not PBKDF2; salt field unused in v2 |
@@ -4,5 +4,5 @@
- **Status**: resolved
- **Door type**: Two-way
- **Priority**: medium
- **Resolution**: The call protocol uses bidirectional QUIC streams with EventEnvelope framing and ID-based correlation via PendingRequestMap. The protocol is stream-agnostic — the client can open one stream per operation, multiplex on one stream, or any mix. Correlation is by request ID, not by stream. Both sides can initiate calls. One `alknet/call` connection gives access to the full operation registry (call, subscribe, batch, schema). No multiplexing layer is needed inside the connection. See ADR-012.
- **Resolution**: The call protocol uses bidirectional streams with EventEnvelope framing and ID-based correlation via PendingRequestMap. The protocol is transport-agnostic (ADR-065) and stream-agnostic — the client can open one stream per operation, multiplex on one stream, or any mix. Correlation is by request ID, not by stream. Both sides can initiate calls. One `alknet/call` connection gives access to the full operation registry (call, subscribe, batch, schema). No multiplexing layer is needed inside the connection. See ADR-012.
- **Cross-references**: ADR-005, ADR-012
@@ -4,5 +4,5 @@
- **Status**: resolved
- **Door type**: One-way
- **Priority**: high
- **Resolution**: `CallClient` opens QUIC connections and shares the dispatch loop with `CallAdapter` — both sides can send and receive `call.requested` once connected. Connection direction (who opened the connection) is independent of call direction (who calls whom). `from_call` adapter discovers remote operations via `services/list` + `services/schema` and registers them with forwarding handlers — same pattern as `from_openapi` and `from_mcp`. `to_openapi` and `to_mcp` project local operations to external protocols. Adapter contract trait (`OperationAdapter`) produces `(OperationSpec, Handler)` pairs. Cross-node call tree: abort cascade (ADR-016) propagates across node boundaries through `from_call` handlers. Credentials for connections come from capabilities (ADR-014). Adapter-registered operations are `Internal` by default (ADR-015). See ADR-017.
- **Resolution**: `CallClient` takes over transport connections (`spawn_dispatch` transport-agnostic primary, `connect` QUIC convenience — ADR-017 Am. 2026-07-13) and shares the dispatch loop with `CallAdapter` — both sides can send and receive `call.requested` once connected. Connection direction (who opened the connection) is independent of call direction (who calls whom). `from_call` adapter discovers remote operations via `services/list` + `services/schema` and registers them with forwarding handlers — same pattern as `from_openapi` and `from_mcp`. `to_openapi` and `to_mcp` project local operations to external protocols. Adapter contract trait (`OperationAdapter`) produces `HandlerRegistration` bundles. Cross-node call tree: abort cascade (ADR-016) propagates across node boundaries through `from_call` handlers. Credentials for connections come from capabilities (ADR-014). Adapter-registered operations are `Internal` by default (ADR-015). See ADR-017.
- **Cross-references**: ADR-005, ADR-013, ADR-014, ADR-015, ADR-016, ADR-017, [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md)