diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 076f1d2..8b18d94 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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 | diff --git a/docs/architecture/crates/call/README.md b/docs/architecture/crates/call/README.md index 40b641e..89413f5 100644 --- a/docs/architecture/crates/call/README.md +++ b/docs/architecture/crates/call/README.md @@ -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). \ No newline at end of file diff --git a/docs/architecture/crates/call/call-protocol.md b/docs/architecture/crates/call/call-protocol.md index 3ca0537..71c98be 100644 --- a/docs/architecture/crates/call/call-protocol.md +++ b/docs/architecture/crates/call/call-protocol.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. diff --git a/docs/architecture/crates/call/client-and-adapters.md b/docs/architecture/crates/call/client-and-adapters.md index 34c4f91..1888325 100644 --- a/docs/architecture/crates/call/client-and-adapters.md +++ b/docs/architecture/crates/call/client-and-adapters.md @@ -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, @@ -102,12 +114,24 @@ pub struct CallClient { impl CallClient { pub fn new(registry: Arc, idp: Arc) -> 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 diff --git a/docs/architecture/crates/call/operation-registry.md b/docs/architecture/crates/call/operation-registry.md index c07756a..40e3148 100644 --- a/docs/architecture/crates/call/operation-registry.md +++ b/docs/architecture/crates/call/operation-registry.md @@ -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. diff --git a/docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md b/docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md index 44c8850..47e5617 100644 --- a/docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md +++ b/docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md @@ -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". \ No newline at end of file +[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. \ No newline at end of file diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index afea92c..97e2e44 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -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 | diff --git a/docs/architecture/questions/007-call-protocol-scope-within-a-connection.md b/docs/architecture/questions/007-call-protocol-scope-within-a-connection.md index 7b46e7d..740138a 100644 --- a/docs/architecture/questions/007-call-protocol-scope-within-a-connection.md +++ b/docs/architecture/questions/007-call-protocol-scope-within-a-connection.md @@ -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 diff --git a/docs/architecture/questions/015-call-protocol-client-and-adapter-contract.md b/docs/architecture/questions/015-call-protocol-client-and-adapter-contract.md index a339eba..1c65da3 100644 --- a/docs/architecture/questions/015-call-protocol-client-and-adapter-contract.md +++ b/docs/architecture/questions/015-call-protocol-client-and-adapter-contract.md @@ -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)