diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 489fef5..6e8ee2b 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1,15 +1,15 @@ --- status: draft -last_updated: 2026-06-20 +last_updated: 2026-06-21 --- # Alknet Architecture ## Current State -**Pre-implementation.** The project has completed a pivot from a three-layer model to an ALPN-as-service model. The greenfield workspace contains only `alknet-vault` (stable) and research/reference material. Foundational ADRs (001–016) are in place, including the BiStream type definition (ADR-007), vault integration (ADR-008), ALPN router/endpoint (ADR-010), AuthContext structure (ADR-011), call protocol stream model (ADR-012), Rust as canonical implementation language (ADR-013), secret material flow with capability injection (ADR-014), privilege model with authority context (ADR-015), and abort cascade for nested calls (ADR-016). The alknet-core and alknet-call crate specs are in draft. +**Pre-implementation.** The project has completed a pivot from a three-layer model to an ALPN-as-service model. The greenfield workspace contains only `alknet-vault` (stable) and research/reference material. Foundational ADRs (001–017) are in place, including the BiStream type definition (ADR-007), vault integration (ADR-008), ALPN router/endpoint (ADR-010), AuthContext structure (ADR-011), call protocol stream model (ADR-012), Rust as canonical implementation language (ADR-013), secret material flow with capability injection (ADR-014), privilege model with authority context (ADR-015), abort cascade for nested calls (ADR-016), and call protocol client and adapter contract (ADR-017). The alknet-core and alknet-call crate specs are in draft. -**Next step**: Review alknet-call spec documents, then begin implementation. OQ-11 (handler-level auth resolution observability) and OQ-15 (call protocol client and adapter contract) will be resolved during or before implementation. +**Next step**: Review alknet-call spec documents, then begin implementation. OQ-11 (handler-level auth resolution observability) will be resolved during implementation. ## Architecture Documents @@ -46,6 +46,7 @@ last_updated: 2026-06-20 | [014](decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Accepted | | [015](decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | Accepted | | [016](decisions/016-abort-cascade-for-nested-calls.md) | Abort Cascade for Nested Calls | Accepted | +| [017](decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | Accepted | ## Open Questions @@ -61,6 +62,7 @@ See [open-questions.md](open-questions.md) for the full tracker. - **OQ-16**: Safe vault operations for call protocol exposure — none for now (ADR-014) - **OQ-18**: Privilege model — `internal` = authority switch, External/Internal visibility, handler identity + scoped env (ADR-015) - **OQ-17**: Abort cascade — `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in (ADR-016) +- **OQ-15**: Call protocol client and adapter contract — `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction (ADR-017) **Resolved two-way doors:** - **OQ-04**: Dynamic handler registration — static at startup (ADR-010) @@ -72,10 +74,6 @@ See [open-questions.md](open-questions.md) for the full tracker. **Open two-way doors (resolved during implementation):** - **OQ-11**: Handler-level auth resolution observability — decide during implementation -**Open one-way doors (need ADR before implementation):** -- **OQ-15**: Call protocol client and adapter contract — alknet-call needs both the server (CallAdapter) and client (call invocation over QUIC), plus the adapter contract traits (from_*, to_*) that enable composition. ADR-014 constrains the adapter contract: adapters take credential sources from the assembly layer, not static tokens. ADR-015 constrains: adapter-registered operations are `Internal` by default. -- **OQ-19**: Session-scoped operation registries — agent-written operations in a quickjs sandbox, overlaid on the global registry via `OperationEnv` trait layering. Protocol doesn't need changes; the one-way door is not closing the trait-based composition point. Promotion from session to core requires curation review. - **Deferred (not active):** - **OQ-09**: WASM target boundaries — design constraint, not deliverable - **OQ-10**: Git adapter scope — start with smart protocol, add ERC721 later diff --git a/docs/architecture/crates/call/README.md b/docs/architecture/crates/call/README.md index ea87563..319f2f7 100644 --- a/docs/architecture/crates/call/README.md +++ b/docs/architecture/crates/call/README.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-06-20 +last_updated: 2026-06-21 --- # alknet-call @@ -31,6 +31,7 @@ Structured RPC over QUIC: operations, request/response, streaming subscriptions, | [014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Call protocol carries no secret material; capabilities injected at assembly layer | | [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 | ## Relevant Open Questions @@ -39,7 +40,6 @@ Structured RPC over QUIC: operations, request/response, streaming subscriptions, | OQ-07 | Call protocol scope within a connection | resolved (ADR-012) | Stream model, multiplexing, scope | | OQ-13 | Operation path format and routing scope | resolved | `/{service}/{op}` is the correct design; remote dispatch is a separate layer | | OQ-14 | Batch operation semantics | resolved | Correlated `call.requested` events is the correct protocol design | -| OQ-15 | Call protocol client and adapter contract | open | ADR-014 constrains adapters: credential sources, not static tokens. ADR-015: adapter ops are Internal by default | | OQ-16 | Safe vault operations for call protocol exposure | resolved (ADR-014) | None exposed for now | | OQ-19 | Session-scoped operation registries | open | Agent-written operations overlaid on global registry via `OperationEnv` trait layering. Protocol doesn't need changes; one-way door is not closing the trait-based composition point | diff --git a/docs/architecture/crates/call/call-protocol.md b/docs/architecture/crates/call/call-protocol.md index 36af485..30f1af0 100644 --- a/docs/architecture/crates/call/call-protocol.md +++ b/docs/architecture/crates/call/call-protocol.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-06-20 +last_updated: 2026-06-21 --- # Call Protocol @@ -303,6 +303,7 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is | Secret material flow | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Call protocol carries no secret material; capabilities injected at assembly layer | | Privilege model and authority context | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env | | Abort cascade for nested calls | [ADR-016](../../decisions/016-abort-cascade-for-nested-calls.md) | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in | +| Call protocol client and adapter contract | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction | ## Open Questions @@ -310,7 +311,6 @@ See [open-questions.md](../../open-questions.md) for full details. - **OQ-13** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix. - **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive. -- **OQ-15** (open): Call protocol client and adapter contract. ADR-014 constrains the adapter contract: adapters take credential sources from the assembly layer, not static tokens. ADR-015 constrains: adapter-registered operations are `Internal` by default. - **OQ-16** (resolved by ADR-014): No vault operations are exposed over the call protocol for now. - **OQ-19** (open): Session-scoped operation registries — agent-written operations overlaid on global registry via `OperationEnv` trait layering. Protocol doesn't need changes. diff --git a/docs/architecture/crates/call/operation-registry.md b/docs/architecture/crates/call/operation-registry.md index e3f6cfa..8ba8a88 100644 --- a/docs/architecture/crates/call/operation-registry.md +++ b/docs/architecture/crates/call/operation-registry.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-06-20 +last_updated: 2026-06-21 --- # Operation Registry @@ -288,7 +288,9 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe **No vault operations are registered in the call protocol.** The vault is assembly-layer only (ADR-008, ADR-014). A handler that needs a child key for a specific operation (e.g., signing for GitHub auth) receives a scoped capability that performs the derivation in-process — it never holds the master seed and never calls a network-exposed vault operation. -**Adapters take credential sources.** The `from_openapi` and `from_jsonschema` adapter patterns (see OQ-15, constrained by ADR-014) register HTTP-backed operations. The credential the HTTP service needs (bearer token, API key) 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 HTTP-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. +**Adapters take credential sources.** The `from_openapi`, `from_jsonschema`, and `from_call` adapter patterns (see ADR-017, constrained by ADR-014) register HTTP-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. **Scoped composition env.** The `OperationEnv` given to a handler is scoped — it can only invoke a declared set of operations, set at registration by the assembly layer. This bounds the parameterized-dispatch attack surface: a handler (or an LLM picking tools, or a quickjs sandbox) can only reach declared operations, not the entire registry. The scoped env is the reachability control; the handler identity is the authority control. Both are needed for least privilege. See ADR-015. @@ -321,7 +323,6 @@ See [open-questions.md](../../open-questions.md) for full details. - **OQ-13** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix. - **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive. -- **OQ-15** (open): Call protocol client and adapter contract. ADR-014 constrains the adapter contract: adapters take credential sources from the assembly layer, not static tokens. ADR-015 constrains: adapter-registered operations are `Internal` by default. - **OQ-16** (resolved by ADR-014): No vault operations are exposed over the call protocol for now. - **OQ-19** (open): Session-scoped operation registries — agent-written operations overlaid on the global registry via `OperationEnv` trait layering. Protocol doesn't need changes; one-way door is not closing the trait-based composition point. 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 new file mode 100644 index 0000000..a8ffcbe --- /dev/null +++ b/docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md @@ -0,0 +1,301 @@ +# ADR-017: Call Protocol Client and Adapter Contract + +## Status + +Accepted + +## Context + +The call protocol spec (ADR-012) defined the stream model as bidirectional — +"both sides can initiate calls." But the spec only described the server side: +`CallAdapter` implements `ProtocolHandler`, accepts incoming QUIC connections, +and dispatches to the operation registry. The client side — who opens the +connection, how calls are sent, how remote operations are discovered and +imported — was left as OQ-15. + +The need for the client side is concrete and immediate: + +- **Head/worker dispatch**: a head node manages worker nodes (Vast.ai, RunPod, + local Docker). The head needs to call operations on workers (exec, sync, + status) and workers need to call back (report status, request work). The + POC at `/workspace/@alkdev/dispatch` demonstrated this over SSH+axum; under + the call protocol, it's cross-node composition. +- **NAPI/Python adapters**: Node.js and Python clients need to call operations + on an alknet node. They speak the EventEnvelope wire format over a QUIC + connection. +- **Agent tool dispatch**: an agent handler needs to call operations on remote + nodes (tools, services) the same way it calls local operations — through + `OperationEnv::invoke()`. The `from_call` adapter makes remote operations + appear in the local registry. +- **Cross-protocol interop**: external systems (HTTP APIs, MCP servers) are + imported via `from_openapi` and `from_mcp`. The reverse direction — + exposing local operations to external systems — needs `to_openapi` and + `to_mcp`. + +The `@alkdev/operations` TypeScript package demonstrated the adapter patterns +(`from_openapi`, `from_mcp`) and the `buildEnv` composition mechanism. The Rust +implementation defines the canonical traits (ADR-013). + +OQ-15 was constrained by ADR-014 (adapters take credential sources, not static +tokens) and ADR-015 (adapter-registered operations are `Internal` by default). +This ADR locks the remaining one-way door: the client/adapter contract +architecture. + +## Decision + +### 1. `CallClient` opens connections and shares the dispatch loop + +`CallClient` opens a QUIC connection to a remote node with ALPN `alknet/call`. +Once connected, the connection is symmetric — both sides can send and receive +`call.requested`. The `CallClient` is not just a caller; it is also a callee. +It has its own operation registry to dispatch incoming calls from the remote +side. + +```rust +pub struct CallClient { + registry: Arc, + identity_provider: Arc, +} + +impl CallClient { + pub async fn connect(&self, addr: SocketAddr, credentials: CallCredentials) -> Result; +} +``` + +The dispatch loop is shared between `CallAdapter` and `CallClient`. Once a +connection is established (whether accepted by the adapter or opened by the +client), the same logic applies: read `EventEnvelope` frames, dispatch to the +operation registry, write responses, and send outgoing `call.requested` events +for calls initiated on this side. The only difference is who opened the +connection. + +`CallConnection` provides: +- `call(operation_id, input) -> ResponseEnvelope` — send `call.requested`, + await `call.responded` (one result) +- `subscribe(operation_id, input) -> Stream` — send + `call.requested`, yield each `call.responded` until `call.completed` or + `call.aborted` +- `abort(request_id)` — send `call.aborted`, cascade to descendants (ADR-016) +- `services_list() -> Vec` — call `services/list` +- `services_schema(name) -> OperationSpec` — call `services/schema` + +### 2. Connection direction is independent of call direction + +Who opens the QUIC connection (who has the public IP, who uses a relay, who +connects out reverse-runner style) is a connection-layer concern, not a +protocol-layer concern. Once connected, both sides can call each other. + +| Topology | Who advertises | Who opens connection | Who can call whom | +|----------|---------------|----------------------|-------------------| +| Public service | Server (public IP/domain) | Client | Both directions | +| P2P (iroh relay) | Both (relay-assisted) | Either | Both directions | +| Reverse (runner pattern) | Head (public IP) | Worker connects out | Both directions | +| Reverse (dispatch pattern) | Worker (public SSH port) | Head connects out | Both directions | + +The protocol does not distinguish "server" and "client" after connection +establishment. The `CallAdapter` accepts connections; the `CallClient` opens +connections. Both dispatch incoming and outgoing calls through the same +mechanism. + +### 3. `from_call` adapter imports remote operations + +`from_call` does for call protocol endpoints what `from_openapi` does for HTTP +APIs: discovers operations and registers them in the local registry with +forwarding handlers. + +```rust +pub async fn from_call( + connection: &CallConnection, + config: FromCallConfig, +) -> Vec<(OperationSpec, Handler)> +``` + +The adapter: +1. Calls `services/list` on the remote node → gets the list of `External` + operations +2. Calls `services/schema` for each → gets the input/output JSON Schemas +3. For each discovered operation, constructs an `(OperationSpec, Handler)` pair: + - The spec mirrors the remote operation's name, namespace, type, schemas, + and access control + - The handler sends `call.requested` through the `CallConnection` and awaits + `call.responded` (or streams for subscriptions) +4. The caller registers these pairs in their local registry + +`from_call`-registered operations are `Internal` by default (ADR-015) — they +are composition material, not directly callable from the wire. The handler +that composes them is `External`. + +The `FromCallConfig` includes: +- The credential source for the outbound connection (ADR-014) — TLS identity, + auth token, or capability-provided credentials +- An optional namespace prefix (to avoid collisions when importing from + multiple remote nodes) +- An optional operation filter (to import only specific operations) + +### 4. `to_openapi` and `to_mcp` adapters export local operations + +The reverse direction — exposing local operations to external systems: + +- **`to_openapi`**: generates an OpenAPI spec from the local registry's + `External` operations. External systems (HTTP clients, API gateways) can + discover and call alknet operations through a standard HTTP interface. +- **`to_mcp`**: exposes local operations as MCP tools. MCP clients (editors, + AI tools) can discover and call alknet operations through the MCP protocol. + +These adapters are outbound bridges — they translate the call protocol's +operation model into external protocol formats. They do not modify the local +registry; they project it. + +### 5. The adapter contract trait + +The adapter patterns share a common shape: they produce `(OperationSpec, +Handler)` pairs that register in the local registry. The trait: + +```rust +pub trait OperationAdapter: Send + Sync { + fn import(&self) -> Vec<(OperationSpec, Handler)>; +} +``` + +Implementations: +- `FromOpenAPI` — imports from an OpenAPI spec (HTTP-backed handlers) +- `FromMCP` — imports from an MCP server (MCP-backed handlers) +- `FromCall` — imports from a remote call protocol endpoint + (call-protocol-backed handlers) +- `FromJsonSchema` — imports from a JSON Schema definition (schema-only, no + handler — used for validation or client generation) + +The `to_*` adapters are outbound projections, not `OperationAdapter` +implementations — they consume the registry, they don't produce entries for it. + +The specific trait signatures (async vs sync, error types, configuration +parameters) are two-way doors for implementation. The one-way door is the +architectural commitment that adapters produce `(OperationSpec, Handler)` +pairs and live in alknet-call. + +### 6. Cross-node call tree and abort cascade + +When a `from_call` handler sends `call.requested` to a remote node, the call +participates in the local call tree via `parent_request_id`. If the parent is +aborted, the cascade (ADR-016) reaches the `from_call` handler, which sends +`call.aborted` to the remote node. The remote node cascades to its own +descendants. The abort crosses the node boundary transparently. + +``` +Head node Worker node + r1: /dispatch/run_training + r1-a: worker/exec (from_call handler) + → call.requested { id: r1-a } ────────→ receives, dispatches to exec + r1-a-1: exec spawns child + user aborts r1 + cascade to r1-a + from_call handler sends: + call.aborted { id: r1-a } ───────────→ receives, cascades to r1-a-1 + aborts exec and children +``` + +### 7. Credential sources for connections + +The `CallClient` needs credentials to authenticate to the remote node. These +come from capabilities (ADR-014), not environment variables. The credential +types: + +- **TLS identity**: the local node's Ed25519 key (RFC 7250 raw key) or X.509 + cert, derived from the vault at startup +- **Auth token**: an opaque token for call-protocol-level authentication, + decrypted from the vault or derived from a shared secret +- **Remote identity verification**: the expected fingerprint or cert of the + remote node, stored as a capability (not an env var or config file) + +The `from_call` adapter receives these credentials at registration time, +same as `from_openapi` receives HTTP credentials. + +## Consequences + +**Positive:** +- Cross-node composition works the same as local composition. A handler calls + `env.invoke("worker", "exec", ...)` and doesn't know (or care) whether + `worker/exec` is a local operation or a `from_call`-imported remote + operation. The composition is transparent. +- The head/worker pattern (dispatch, runners) is a connection topology, not a + protocol feature. Workers can connect to heads (runner pattern) or heads can + connect to workers (dispatch pattern) — the protocol handles both. +- `from_call` is the same pattern as `from_openapi` and `from_mcp`: discover, + register, forward. The adapter contract is unified. +- `to_openapi` and `to_mcp` enable interop with non-alknet systems without + those systems needing to speak EventEnvelope. +- The abort cascade (ADR-016) crosses node boundaries transparently. No + consumer needs to implement cross-node abort propagation. +- The NAPI and Python adapters can use `CallClient` directly to call remote + operations — they don't need a separate client implementation. + +**Negative:** +- `CallClient` has its own operation registry (for dispatching incoming calls + from the remote side). This is a second registry instance, not the global + one — it needs to be populated with the operations this node wants to expose + to that specific remote peer. The specific mechanism (sharing the global + registry, a peer-scoped subset, or a separate registry) is a two-way door. +- `from_call`-registered operations have a latency cost: each invocation sends + a `call.requested` over QUIC and awaits a `call.responded`. This is + inherent to remote calls and not specific to the adapter pattern. Caching + or batching strategies are consumer concerns. +- The `to_*` adapters need to translate the call protocol's operation model + (JSON Schema, EventEnvelope, subscribe/stream) into external formats + (OpenAPI paths, MCP tools). Some semantics don't map cleanly (e.g., + subscriptions in OpenAPI, bidirectional calls in MCP). The adapters handle + these with best-effort mappings and document the gaps. +- The `CallConnection` abstraction adds a layer between the handler and the + raw QUIC stream. This is necessary for the `from_call` handler to be + transparent — it shouldn't know about QUIC streams, only about call/request + semantics. + +## Assumptions + +1. **The connection is symmetric after establishment.** Both sides can send + and receive `call.requested`. If a future use case requires one-directional + connections (e.g., a fire-and-forget notification where the receiver can't + call back), the model needs extension. The assumption is that bidirectional + is the correct default. + +2. **`services/list` and `services/schema` are the discovery mechanism for + `from_call`.** The remote node exposes its `External` operations through + these built-in operations. If a remote node doesn't support service + discovery (e.g., a minimal worker that only accepts specific calls), + `from_call` needs an alternative discovery mechanism (static config, manual + spec). The assumption is that nodes participating in cross-node composition + support service discovery. + +3. **The `from_call` handler is transparent to composition.** A handler that + calls `env.invoke("worker", "exec", ...)` doesn't know it's a remote call. + If the remote node is unreachable or the connection drops, the handler gets + a `call.error` (same as a local handler error). The assumption is that + remote call failures are handled the same as local handler failures. + +4. **`from_call`-registered operations mirror the remote spec.** The imported + `OperationSpec` has the same name, namespace, type, schemas, and access + control as the remote operation. If the remote operation changes (new + schema, renamed), the imported spec is stale until re-import. The + assumption is that re-import happens on reconnection or is triggered + explicitly. Hot-swapping imported specs is a two-way door. + +5. **The `to_*` adapters are projections, not live bridges.** `to_openapi` + generates a spec; it doesn't proxy HTTP requests. An external HTTP client + calling the generated OpenAPI endpoints needs an HTTP handler (alknet-http) + that translates HTTP requests into call protocol operations. The assumption + is that `to_*` generates specs/tools, and a separate HTTP/MCP handler + bridges the actual traffic. + +## References + +- ADR-005: irpc as call protocol foundation +- ADR-012: Call protocol stream model (bidirectional streams) +- ADR-013: Rust as canonical implementation language (adapter traits in Rust) +- ADR-014: Secret material flow (credential sources, not static tokens) +- ADR-015: Privilege model (adapter ops are Internal by default) +- ADR-016: Abort cascade (cross-node abort propagation) +- OQ-15: Call protocol client and adapter contract (resolved by this ADR) +- [call-protocol.md](../crates/call/call-protocol.md) +- [operation-registry.md](../crates/call/operation-registry.md) +- TypeScript `@alkdev/operations` — `from_openapi`, `from_mcp`, `buildEnv` + prior art +- POC at `/workspace/@alkdev/dispatch` — head/worker dispatch over SSH+axum \ No newline at end of file diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index ea52c4a..eeb8cb9 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -168,11 +168,11 @@ These questions are acknowledged but not active. They will be promoted to open w ### OQ-15: Call Protocol Client and Adapter Contract - **Origin**: [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md), ADR-013 -- **Status**: open +- **Status**: resolved - **Door type**: One-way - **Priority**: high -- **Resolution**: alknet-call currently specifies only the server side (CallAdapter receives connections and dispatches to the operation registry). A call protocol client is needed for: (1) alknet-napi to expose remote invocation to Node.js, (2) alknet-agent to dispatch tool calls (call, batch, search, schema) to remote nodes, (3) the `from_call` adapter pattern that creates operations whose handlers invoke remote services. The adapter contract (from_openapi, from_mcp, from_call, to_openapi, to_mcp) determines how external specifications and protocols compose with the operation registry. These traits belong in alknet-call because they define how operations are produced and consumed — the same contract that enables an agent to register call/batch/search/schema as tools also enables from_openapi to register HTTP-backed operations. The TypeScript `@alkdev/operations` library demonstrated these patterns; the Rust implementation defines the canonical traits (ADR-013). Two-way door for the specific trait signatures, one-way door for the architectural commitment that the adapter contract lives in alknet-call. ADR-014 constrains the adapter contract: adapters take credential sources from the assembly layer (wired to the vault), not static token strings — the `from_openapi` and `from_jsonschema` patterns receive credentials at registration time, not at call time. -- **Cross-references**: ADR-005, ADR-013, ADR-014, [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md) +- **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. +- **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) ### OQ-16: Safe Vault Operations for Call Protocol Exposure diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index b376d2c..1a7a69c 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -206,6 +206,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 | ## Open Questions