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:
1 parent
a26401aadd
commit
b0cc0a01b7
9 files changed
+135
-46
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user