docs: port architecture specs and ADRs from alknet-http; write new alkhttp ADRs 067-070
Phase 1 (SDD) — architecture documentation: Ported specs (adapted for alkcall, producer/consumer terms, 6-endpoint gateway, channels-over-WS, Sub/Pub operation types): - overview.md, http-server.md, http-adapters.md, http-mcp.md - README.md index (rewritten for alkhttp) New ADRs: - 067: WebSocket carries the channels protocol (8-byte chunk demux, channel 0 = alk/call, upgrade path /alk/channels) - 068: gateway /publish endpoint for Pub operations (NDJSON body) - 069: WebTransport out of scope in alkhttp (alknet concern) - 070: from_wss consumer adapter (wss feature, tokio-tungstenite) Ported ADRs (25, same numbers, port notes + amendments where the extraction changed facts): 001-004, 010, 014, 015, 017, 022, 023, 027, 034, 036, 037, 039, 041, 042, 044, 045, 046, 047, 048, 049, 051, 066. websocket.md rewritten for the channels session; open-questions.md seeded (OQ-01 WS byte-stream adapter, OQ-02 /publish framing, OQ-03 from_wss reconnect, OQ-04 browser client ownership). Verified: cargo test, clippy -D warnings, fmt, doc --no-deps.
This commit is contained in:
@@ -0,0 +1,587 @@
|
||||
# ADR-017: Call Protocol Client and Adapter Contract
|
||||
|
||||
*Ported from alknet ADR-017 (Call Protocol Client and Adapter Contract); re-targeted to alkhttp.*
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (amended 2026-06-26, 2026-07-13, and 2026-07-16 — see "Amendments" below; the 2026-07-16 amendment per alknet ADR-089 §5 removes `CallClient::connect`)
|
||||
|
||||
## Context
|
||||
|
||||
The call protocol spec (alknet ADR-012, now alkcall ADR-015) defined the
|
||||
stream model as bidirectional — "both sides can initiate calls." But the spec
|
||||
only described the accept side: `CallAdapter` implements `ProtocolHandler`,
|
||||
accepts incoming connections, and dispatches to the operation registry. The
|
||||
connect side — who opens the connection, how calls are sent, how remote
|
||||
operations are discovered and imported — was left as alknet OQ-15.
|
||||
|
||||
The need for the connect 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 consumers need to call operations
|
||||
on an alk node. They speak the EventEnvelope wire format over a 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 (alknet ADR-013, now alkcall
|
||||
ADR-033).
|
||||
|
||||
alknet OQ-15 was constrained by
|
||||
[ADR-014](014-secret-material-flow-and-capability-injection.md) (adapters take
|
||||
credential sources, not static tokens) and
|
||||
[ADR-015](015-privilege-model-and-authority-context.md) (adapter-registered
|
||||
operations are `Internal` by default). This ADR locks the remaining one-way
|
||||
door: the client/adapter contract architecture. The decision record now lives
|
||||
in the alkcall crate (alkcall ADR-022); this port exists because alkhttp
|
||||
implements the adapter contract — the `OperationAdapter` trait consumes this
|
||||
crate's HTTP-backed adapter implementations, and the gateway `/search` and
|
||||
`/schema` endpoints are the discovery surface `from_call` mirrors.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. `CallClient` opens connections and shares the dispatch loop
|
||||
|
||||
`CallClient` opens a connection to a remote node with ALPN `alk/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<OperationRegistry>,
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
}
|
||||
|
||||
impl CallClient {
|
||||
pub async fn connect(&self, addr: SocketAddr, credentials: CallCredentials) -> Result<CallConnection>;
|
||||
}
|
||||
```
|
||||
|
||||
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<ResponseEnvelope>` — send
|
||||
`call.requested`, yield each `call.responded` until `call.completed` or
|
||||
`call.aborted`
|
||||
- `abort(request_id)` — send `call.aborted`, cascade to descendants (alkcall ADR-020)
|
||||
- `services_list() -> Vec<OperationSpec>` — call `services/list`
|
||||
- `services_schema(name) -> OperationSpec` — call `services/schema`
|
||||
|
||||
### 2. Connection direction is independent of call direction
|
||||
|
||||
Who opens the 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 | Producer (public IP/domain) | Consumer | 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 producer and consumer 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<HandlerRegistration>
|
||||
```
|
||||
|
||||
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 and
|
||||
declared error_schemas ([ADR-023](023-operation-error-schemas.md))
|
||||
3. For each discovered operation, constructs a `HandlerRegistration` bundle:
|
||||
- The spec mirrors the remote operation's name, namespace, type, schemas
|
||||
(input, output, and error_schemas — ADR-023), and access control
|
||||
- The handler sends `call.requested` through the `CallConnection` and awaits
|
||||
`call.responded` (or streams for `Sub` operations)
|
||||
- `provenance: FromCall`, `composition_authority: None`, `scoped_env: None`
|
||||
(leaves — [ADR-022](022-handler-registration-provenance-and-composition-authority.md))
|
||||
4. The caller registers these bundles in their local registry (into the
|
||||
connection's overlay — alkcall ADR-024/ADR-019)
|
||||
|
||||
`from_call`-registered operations are `Internal` by default
|
||||
([ADR-015](015-privilege-model-and-authority-context.md)) — 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](014-secret-material-flow-and-capability-injection.md)) — 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 alk operations through a standard HTTP interface. This is
|
||||
the gateway pattern — the five fixed gateway endpoints
|
||||
(`/search`/`/schema`/`/call`/`/batch`/`/subscribe`), not one path per
|
||||
operation (ADR-042) — implemented in alkhttp.
|
||||
- **`to_mcp`**: exposes local operations as MCP tools. MCP clients (editors,
|
||||
AI tools) can discover and call alk operations through the MCP protocol —
|
||||
the four fixed gateway tools (ADR-041), implemented in alkhttp.
|
||||
|
||||
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
|
||||
`HandlerRegistration` bundles that register in the local registry. The
|
||||
trait:
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait OperationAdapter: Send + Sync {
|
||||
async fn import(&self) -> Vec<HandlerRegistration>;
|
||||
}
|
||||
```
|
||||
|
||||
The return type is `Vec<HandlerRegistration>` (not `(OperationSpec,
|
||||
Handler)` pairs) — [ADR-022](022-handler-registration-provenance-and-composition-authority.md)
|
||||
changed the registration API to the bundle
|
||||
shape, and adapters must produce bundles. Adapter convenience methods
|
||||
construct bundles with `composition_authority: None` and `scoped_env: None`
|
||||
for the leaf ops they produce.
|
||||
|
||||
The trait is **async** because `from_call` requires async discovery
|
||||
(`services/list` + `services/schema` over a connection). A synchronous
|
||||
trait cannot accommodate `from_call` without a separate async pre-step that
|
||||
populates a cache. The sync adapters (`from_openapi`, `from_mcp` reading a
|
||||
static spec) trivially satisfy an async trait — their `import()` bodies
|
||||
contain no `.await` points. The async/sync question is decided: the trait
|
||||
is async.
|
||||
|
||||
Implementations:
|
||||
- `FromOpenAPI` — imports from an OpenAPI spec (HTTP-backed handlers;
|
||||
implemented in alkhttp)
|
||||
- `FromMCP` — imports from an MCP server (MCP-backed handlers; implemented in
|
||||
alkhttp)
|
||||
- `FromCall` — imports from a remote call protocol endpoint
|
||||
(call-protocol-backed handlers; implemented in alkcall)
|
||||
- ~~`FromJsonSchema` — imports from a JSON Schema definition (schema-only, no
|
||||
handler — used for validation or client generation)~~ — **superseded by
|
||||
[ADR-066](066-from-jsonschema-as-http-adapter.md)**: `from_jsonschema` is
|
||||
now an HTTP-backed single-endpoint adapter in alkhttp (reqwest
|
||||
forwarding handler, not a schema-only placeholder); `FromJsonSchema`
|
||||
provenance stays in alkcall as a handler-bearing leaf.
|
||||
|
||||
The `to_*` adapters are outbound projections, not `OperationAdapter`
|
||||
implementations — they consume the registry, they don't produce entries for it.
|
||||
|
||||
The specific trait signatures (error types, configuration parameters) are
|
||||
two-way doors for implementation. The one-way doors are the architectural
|
||||
commitments: adapters produce `HandlerRegistration` bundles
|
||||
([ADR-022](022-handler-registration-provenance-and-composition-authority.md)), the
|
||||
trait is async (required by `from_call`), and the adapter *trait* lives in
|
||||
alkcall while adapter *implementations* live with their transport
|
||||
(HTTP-backed adapters in alkhttp per ADR-066; connection-backed `from_call`
|
||||
in alkcall). See alkcall's `client-and-adapters.md` §"Adapter Location Map."
|
||||
|
||||
### 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 (alkcall ADR-020) 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](014-secret-material-flow-and-capability-injection.md)), 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-alk systems without
|
||||
those systems needing to speak EventEnvelope.
|
||||
- The abort cascade (alkcall ADR-020) 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` 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.,
|
||||
`Sub` streaming in OpenAPI, bidirectional calls in MCP). The adapters handle
|
||||
these with best-effort mappings and document the gaps.
|
||||
- **Published `to_*` specs are compatibility contracts.** The "best-effort"
|
||||
mapping label is internal framing. Once a generated spec is published and
|
||||
external clients build against it, the mapping semantics (e.g.,
|
||||
`Sub` streaming → SSE long-poll) become a de facto contract. Changing the
|
||||
mapping later breaks every client. `to_*` mapping choices are two-way
|
||||
*before* first publication but one-way *after*. Version the generated
|
||||
specs (e.g., OpenAPI spec version tied to the registry's External
|
||||
operation set version) and emit a spec version marker so consumers can
|
||||
detect mapping changes. This is the "published artifact is a contract"
|
||||
blind spot in alknet ADR-009's framework: it classifies doors by reversal cost
|
||||
in the codebase, not by compatibility cost for external consumers. (The
|
||||
versioning was subsequently specced — alknet ADR-045, ported here as
|
||||
[ADR-045](045-to-openapi-gateway-spec-versioning.md).)
|
||||
- **Sharing the global registry with a `CallClient` exposes local
|
||||
capabilities to the remote peer.** Each `HandlerRegistration` carries
|
||||
`Capabilities` with secret material. If the `CallClient` shares the
|
||||
global registry, a remote peer calling an External operation triggers
|
||||
dispatch that populates `OperationContext.capabilities` from the local
|
||||
registration bundle — meaning the local node's API keys and signing keys
|
||||
are used for the remote peer's call. A peer-scoped subset must filter by
|
||||
capability remote-safety (is this operation's capability safe to expose
|
||||
to this peer?), not just operation name. The registry-mechanism choice
|
||||
(share global vs subset vs separate) is two-way mechanically but has a
|
||||
security dimension post-[ADR-022](022-handler-registration-provenance-and-composition-authority.md): the
|
||||
"share global" option is a
|
||||
capability-exposure decision, not just a dispatch decision.
|
||||
- The `CallConnection` abstraction adds a layer between the handler and the
|
||||
raw transport stream. This is necessary for the `from_call` handler to be
|
||||
transparent — it shouldn't know about the underlying transport 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 (input, output,
|
||||
and error_schemas per [ADR-023](023-operation-error-schemas.md)), 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 host (alkhttp) that
|
||||
translates HTTP requests into call protocol operations — the gateway
|
||||
dispatch. The assumption is that `to_*` generates specs/tools, and a
|
||||
separate HTTP/MCP handler bridges the actual traffic.
|
||||
|
||||
## References
|
||||
|
||||
- alknet ADR-005: irpc as call protocol foundation (superseded — see alkcall
|
||||
ADR-014, irpc was never integrated; framing is hand-rolled)
|
||||
- alkcall ADR-015: Call Protocol Stream Model (alknet ADR-012; bidirectional
|
||||
streams)
|
||||
- alkcall ADR-033: Rust as canonical implementation language (alknet ADR-013;
|
||||
adapter traits in Rust)
|
||||
- [ADR-014](014-secret-material-flow-and-capability-injection.md): Secret
|
||||
material flow (credential sources, not static tokens)
|
||||
- [ADR-015](015-privilege-model-and-authority-context.md): Privilege model
|
||||
(adapter ops are Internal by default)
|
||||
- alkcall ADR-020: Abort cascade for nested calls (alknet ADR-016; cross-node
|
||||
abort propagation)
|
||||
- alkcall ADR-023: Peer-Scoped Registry Filtering for CallClient Inbound
|
||||
Dispatch (alknet ADR-028; resolves the §1 Consequences security dimension
|
||||
flagged as a two-way door)
|
||||
- alkcall ADR-024: Peer-Graph Routing Model (alknet ADR-029; supersedes
|
||||
alkcall ADR-023's `remote_safe`/`trusted_peer` gate)
|
||||
- alknet OQ-15: Call protocol client and adapter contract (resolved by this
|
||||
ADR — the decision record is alkcall ADR-022)
|
||||
- alknet OQ-25..28: Two-way-door remainders from the call-completion gap
|
||||
analysis (DC-1 shape, DC-4 error type, DC-2 re-import trigger, DC-3
|
||||
namespace collision — see alknet mono-repo `open-questions.md`; the OQ
|
||||
numbers are alknet-record citations)
|
||||
- The alkcall crate's `call-protocol.md` — the wire protocol spec (alknet
|
||||
mono-repo: `crates/call/call-protocol.md`)
|
||||
- The alkcall crate's `operation-registry.md` — the registry spec (alknet
|
||||
mono-repo: `crates/call/operation-registry.md`)
|
||||
- The alkcall crate's `client-and-adapters.md` — the spec that operationally
|
||||
fills the gap this ADR left to implementation (alknet mono-repo:
|
||||
`crates/call/client-and-adapters.md`)
|
||||
- alknet mono-repo `docs/research/alknet-call-completion/gap-analysis.md` —
|
||||
DC-1..4, the decisions that needed resolution before implementation
|
||||
- TypeScript `@alkdev/operations` — `from_openapi`, `from_mcp`, `buildEnv`
|
||||
prior art
|
||||
- POC at `/workspace/@alkdev/dispatch` — head/worker dispatch over SSH+axum
|
||||
|
||||
## Amendments (2026-06-26)
|
||||
|
||||
This ADR left four decisions as two-way doors (§1 Consequences flagged DC-1's
|
||||
security dimension; §5 noted trait signatures are two-way doors; Assumption 4
|
||||
noted re-import hot-swap is a two-way door; §3 mentioned the namespace prefix).
|
||||
The call-completion gap analysis (alknet mono-repo
|
||||
`docs/research/alknet-call-completion/gap-analysis.md`
|
||||
DC-1..4) resolved them. The resolutions:
|
||||
|
||||
### DC-1 — CallClient registry scope: resolved by alkcall ADR-023, superseded by alkcall ADR-024
|
||||
|
||||
The §1 Consequences security dimension was originally resolved by alknet
|
||||
ADR-028 (default-deny `remote_safe: bool` + `trusted_peer` opt-in; now
|
||||
alkcall ADR-023). **alknet ADR-028 is now superseded by alknet ADR-029**
|
||||
(2026-06-27; now alkcall ADR-024): the flat-namespace single-peer model
|
||||
alknet ADR-028 built on cannot express the head→N-workers pattern, and the
|
||||
`remote_safe`/`trusted_peer` gate duplicates the existing
|
||||
`AccessControl`/`Identity` machinery while reintroducing the blanket-bypass
|
||||
anti-pattern [ADR-015](015-privilege-model-and-authority-context.md) killed.
|
||||
alkcall ADR-024 replaces the flat overlay with peer-keyed overlays +
|
||||
`PeerRef` routing, and retires `remote_safe`/
|
||||
`trusted_peer` in favor of `AccessControl::check(peer_identity)` — the
|
||||
existing authorization path that was already in the dispatch path. The peer-
|
||||
scoping question this section flagged is now answered structurally (peer-keyed
|
||||
overlays), not by a parallel boolean gate.
|
||||
|
||||
### DC-4 — OperationAdapter trait error type: resolved
|
||||
|
||||
§5 showed `async fn import(&self) -> Vec<HandlerRegistration>` with no error
|
||||
type. The trait returns `Result<Vec<HandlerRegistration>, AdapterError>`
|
||||
where `AdapterError` is a crate-level enum. The *presence* of the error type
|
||||
is recorded in the alkcall crate's `client-and-adapters.md`;
|
||||
the exact variants are the two-way-door remainder, tracked as alknet OQ-26.
|
||||
|
||||
### DC-2 — from_call re-import on reconnection: manual free function
|
||||
|
||||
Assumption 4 noted re-import "happens on reconnection or is triggered
|
||||
explicitly." The decision is **manual**: `from_call` is a free function; the
|
||||
assembly layer calls it after establishing the connection. The overlay is
|
||||
per-connection (Layer 2, alkcall ADR-024/ADR-019), so re-import on reconnect is
|
||||
naturally scoped; a stale overlay dies with the connection. A
|
||||
`CallConnection::refresh()` method for mid-connection re-discovery is a
|
||||
genuine feature addition — non-breaking, additive — if a deployment needs
|
||||
manual re-discovery without drop-and-reconnect. Two-way door; recorded in
|
||||
the alkcall crate's `client-and-adapters.md`; tracked as alknet OQ-27. See
|
||||
alknet ADR-069 (from_call is a manual free function; alkcall ADR-028) for the
|
||||
full rationale.
|
||||
|
||||
### DC-3 — from_call namespace collision: default set
|
||||
|
||||
§3's `FromCallConfig` namespace prefix is **optional, default no prefix,
|
||||
collision = error**. A node importing from two remotes that both expose the
|
||||
same unprefixed op name should fail loudly. The operator adds prefixes when
|
||||
importing from multiple sources. Two-way door; recorded in the alkcall
|
||||
crate's `client-and-adapters.md`; tracked as alknet OQ-28.
|
||||
|
||||
### Operational spec
|
||||
|
||||
The gap this ADR left to implementation — the `CallClient` API, the
|
||||
`from_call` flow, the trait signature, the adapter location map, the
|
||||
no-env-vars invariant, and the exchange-of-operations pattern — is
|
||||
specified in the alkcall crate's `client-and-adapters.md` (alknet mono-repo:
|
||||
`crates/call/client-and-adapters.md`). That document
|
||||
is the operational complement to this ADR; this ADR remains the architectural
|
||||
authority.
|
||||
|
||||
## Amendments (2026-07-09)
|
||||
|
||||
### `from_jsonschema` clause superseded by ADR-066
|
||||
|
||||
The §5 `FromJsonSchema` implementation listing ("schema-only, no handler")
|
||||
is **superseded by [ADR-066](066-from-jsonschema-as-http-adapter.md)**.
|
||||
`from_jsonschema` is now an HTTP-backed single-endpoint adapter in
|
||||
alkhttp (reqwest forwarding handler, same shape as `from_openapi`),
|
||||
not a schema-only placeholder in alkcall. The `FromJsonSchema`
|
||||
provenance variant stays in alkcall (`OperationProvenance`) but is
|
||||
now a handler-bearing leaf, not a "no handler" entry. The "schema-only,
|
||||
no handler" concept is removed — schema validation without a handler is
|
||||
served by consuming `OperationSpec` directly. The §5 "adapters live in
|
||||
alkcall" one-way-door statement is corrected above to "the adapter
|
||||
trait lives in alkcall; implementations live with their transport."
|
||||
See [ADR-066](066-from-jsonschema-as-http-adapter.md) and the alkcall
|
||||
crate's `client-and-adapters.md` §"from_jsonschema".
|
||||
|
||||
## Amendments (2026-07-13)
|
||||
|
||||
### `CallClient` transport-agnostic API (mirrors alknet 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
|
||||
(the alkcall crate's `client-and-adapters.md`)
|
||||
framed `spawn_dispatch(connection)` as the "lower-level API" that
|
||||
`connect()` uses after the dial. That framing welded the
|
||||
client-side one-way-door API to QUIC — the same welding alknet ADR-065
|
||||
unwound on the accept side (alkcall ADR-007) and alknet ADR-080 corrected for
|
||||
`ChannelClient` (alkcall ADR-043).
|
||||
|
||||
The call protocol is transport-agnostic (alkcall ADR-015 EventEnvelope
|
||||
framing; alknet ADR-065 `Connection::from_stream`/`from_bidi` — alkcall
|
||||
ADR-007 — accept any `AsyncRead + AsyncWrite`). The connect 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
|
||||
accept-side `CallAdapter::handle(Connection)` and
|
||||
`ChannelClient::from_connection` (alkcall ADR-043).
|
||||
- **`CallClient::connect(addr, credentials)`** — ~~a QUIC convenience
|
||||
constructor~~ **REMOVED per alknet ADR-089 §5 (2026-07-16; alkcall
|
||||
ADR-045)**. The dial is centralized in `AlknetClient` (the native client
|
||||
dial seam, alkcall ADR-045); `connect` is
|
||||
deleted, not retained as a two-way-door convenience, to avoid
|
||||
alkcall depending on the client-dial crate and to let alkcall
|
||||
shed its TLS/transport deps. Callers compose
|
||||
`AlknetClient::dial_quic(...).await?` +
|
||||
`CallClient::new(...).spawn_dispatch(conn)`.
|
||||
|
||||
The door-type classification is updated: `spawn_dispatch` is one-way
|
||||
(the handler-facing surface); ~~`connect` is two-way (additive
|
||||
convenience)~~ `connect` is **removed** (alknet ADR-089 §5). The
|
||||
`AlknetClient` extraction (alknet OQ-55) is **resolved** by alknet ADR-089 —
|
||||
the shared dial seam is the client-dial crate; `spawn_dispatch` is the
|
||||
protocol-crate take-over that consumes the dial's `Connection`.
|
||||
|
||||
See the alkcall crate's `client-and-adapters.md`
|
||||
§"CallClient" for the reframed operational spec.
|
||||
|
||||
## Port notes
|
||||
|
||||
- Renames: "alknet-http" → alkhttp; "alknet-core"/"alknet-call" → alkcall;
|
||||
"alknet node"/"non-alknet systems" → "alk node"/"non-alk systems".
|
||||
- ALPN correction: `alknet/call` → `alk/call` (alkcall ADR-004 renamed the
|
||||
ALPN convention to the `alk/` namespace).
|
||||
- Producer/consumer terminology: "server side"/"client side" (§Context,
|
||||
§1, §6) → "accept side"/"connect side" for the connection-establishment
|
||||
half; "who advertises"/"who opens connection" table retained (it was
|
||||
already direction-of-establishment language); §2's closing sentence now
|
||||
says the protocol does not distinguish producer and consumer after
|
||||
connection establishment. "Node.js and Python clients" → "consumers".
|
||||
HTTP/MCP inherent directionality is untouched ("HTTP clients", "MCP
|
||||
servers", "MCP clients" keep their names — those are protocol roles of the
|
||||
external systems, not call-protocol roles).
|
||||
- `Subscription` → `Sub` (alkcall renamed `OperationType::Subscription` to
|
||||
`Sub`; alkcall ADR-046 added `OperationType::Pub` — producer→consumer
|
||||
streaming via `call.published`, `HandlerKind::Sink`). §1's
|
||||
`subscribe()` description, §3's "or streams for subscriptions", and the
|
||||
Consequences "subscriptions → SSE long-poll" example now say `Sub`.
|
||||
- §4 corrected to name the concrete alkhttp realizations: `to_openapi` is the
|
||||
OpenAPI gateway pattern (ADR-042 — five fixed gateway endpoints, not one
|
||||
path per operation) and `to_mcp` is the MCP tool-gateway pattern (ADR-041 —
|
||||
four fixed gateway tools). The original predates those ADRs; the decision
|
||||
content (outbound projections of `External` ops) is unchanged.
|
||||
- Assumption 5 correction: "a separate HTTP handler (alknet-http)" → "an HTTP
|
||||
host (alkhttp) ... the gateway dispatch" — the direct-call per-operation
|
||||
HTTP surface was removed by ADR-047; the gateway is the sole invoke path.
|
||||
- Amendment remappings (verified alknet→alkcall ADR mapping): alknet
|
||||
ADR-028 → alkcall ADR-023; alknet ADR-029 → alkcall ADR-024; alknet ADR-016
|
||||
(abort cascade) → alkcall ADR-020; alknet ADR-024 (registry layering) →
|
||||
alkcall ADR-019; alknet ADR-065 (`Connection::from_stream`) → alkcall
|
||||
ADR-007; alknet ADR-080 (`ChannelClient`) → alkcall ADR-043; alknet ADR-089
|
||||
(dial seam) → alkcall ADR-045; alknet ADR-012 (stream model) → alkcall
|
||||
ADR-015; alknet ADR-069 (from_call manual free function) → alkcall ADR-028;
|
||||
alknet ADR-013 (Rust canonical) → alkcall ADR-033; alknet ADR-009 (one-way
|
||||
door framework) → alkcall ADR-032. The 2026-07-13 amendment title's
|
||||
"mirrors ADR-080's amendment" cites the alknet number (the amendment
|
||||
mirrored the alknet-record ADR-080).
|
||||
- All `client-and-adapters.md` relative links converted to textual
|
||||
"the alkcall crate's `client-and-adapters.md`" references (the document
|
||||
lives in the alkcall crate's docs/architecture/).
|
||||
- alknet OQ numbers (OQ-15, OQ-25..28, OQ-55) are alknet-record citations,
|
||||
annotated as such — alkcall's OQ numbering differs (e.g., alkcall OQ-25 is
|
||||
BiStream type definition, not the DC remainders).
|
||||
- The `QuicCallCredentials`-shaped `connect(addr, credentials)` signature in
|
||||
§1 is retained as decision history (the 2026-07-16 amendment removes
|
||||
`connect`); the QUIC-specific wording "opens a QUIC connection" is
|
||||
softened to "opens a connection" in the §1 prose per the transport-agnostic
|
||||
amendment, with the amendment block recording the original framing.
|
||||
- Status line: "per ADR-089 §5" annotated as alknet ADR-089 (alkcall
|
||||
ADR-045) — the amendment citation refers to the alknet-record number.
|
||||
- No decision content changed — the shared dispatch loop, the
|
||||
connection-direction independence, the adapter contract trait, the
|
||||
cross-node abort cascade, the credential-source rule, and all three
|
||||
amendment blocks are verbatim from the alknet ADR modulo the mechanical
|
||||
corrections logged above.
|
||||
Reference in New Issue
Block a user