docs(architecture): resolve review #003 — type/API surface completeness

Review #003 found 11 critical, 14 warning, and 6 suggestion findings
after reviews #001 (governance/security) and #002 (cross-document
consistency/two-way-door audit) were resolved. The theme: types and
APIs that were *referenced* but never *defined*, and stale ADR sketches
that didn't match the now-updated spec docs.

Critical fixes (11):

- C1: DerivedKey #[derive(Deserialize)] contradicted the custom
  Deserialize that rejects "[REDACTED]" — dropped the derive, added
  explicit manual Serialize/Deserialize impls (protocol.md).
- C2: encrypt prose said "derived at PATHS::ENCRYPTION" but the
  signature takes key_version — updated to encryption_path_for_version
  (service.md).
- C3: derive_encryption_key returned DerivedKey, derive_encryption_key
  _for_version returned EncryptionKey (same cache) — unified on
  DerivedKey, defined CachedKey (service.md).
- C4: tokio vs std::sync::RwLock contradiction — specified
  std::sync::RwLock, dropped tokio from vault deps (ADR-018, ADR-025,
  service.md).
- C5: Missing drift rows in vault README — added #9 (key_version
  ignored) and #10 (rotate not implemented).
- C6: ADR-022 build_root_context and invoke() sketches omitted
  abort_policy (9 fields vs 10) — added the field to both sketches.
- C7: Capabilities type referenced 20+ times, never defined — added
  struct definition to core-types.md with Clone+Send+Sync, Zeroize,
  sealed builder API, immutability guard.
- C8: SessionOverlaySource on CallAdapter but never defined, crate
  violation (alknet-call can't depend on alknet-agent) — defined the
  trait in alknet-call (call-protocol.md), matching the IdentityProvider
  pattern.
- C9: CompositeOperationEnv dispatch fall-through was "a two-way door"
  — added contains() to OperationEnv trait, made the composite probe
  before dispatching, eliminating the sentinel ambiguity.
- C10: No API for Layer 2 (connection overlay) registration, CallConnection
  undefined — defined CallConnection struct + register_imported() API
  (call-protocol.md).
- C11: with_local signature diverged between two examples (4 args vs 5)
  — added capabilities as the 5th arg, made both examples consistent.

Warning fixes (14):

- W1: invoke_with_policy restructured as required method, invoke gets a
  default impl delegating to it — eliminates duplication across impls.
- W2: CachedKey defined (service.md).
- W3: EncryptionKey constructor/glue specified, added to re-export list.
- W4: Secp256k1ExtendedPrivKey defined, derive_ethereum_key glue shown.
- W5: encryption_path_for_version rejects version < 2 (v1 is TS PBKDF2).
- W6: Wire payload schemas for all event types + ResponseEnvelope →
  EventEnvelope conversion table (call-protocol.md).
- W7: Timeout section — deadline on OperationContext, composed calls
  inherit parent's deadline, CallAdapter::with_timeout().
- W8: Request ID generation spec — UUID v4 for composed calls, wire ID
  vs internal ID relationship for abort cascade.
- W9: unlock_new already-unlocked behavior specified (returns
  AlreadyUnlocked).
- W10: KeyType Serialize/Deserialize justification corrected (stale
  irpc reference removed).
- W11: OperationProvenance and CompositionAuthority defined inline in
  operation-registry.md (were only in ADR-022).
- W12: encrypt/decrypt free functions marked pub(crate), relationship
  to VaultServiceHandle methods stated.
- W13: rotate signature removed from encryption.md (it's a
  VaultServiceHandle method, not a free function).
- W14: CallAdapter::new() + with_session_source() + with_timeout()
  constructors shown.

Suggestion fixes (6): Seed: Clone note, VaultServiceInner invariant,
ExtendedPrivKey accessor signatures, CURRENT_KEY_VERSION location, ADR-018
stale actor text, derivation helpers re-export note.
This commit is contained in:
2026-06-23 10:56:05 +00:00
parent cb98f42cd4
commit 2e34590522
14 changed files with 1129 additions and 120 deletions

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-22-22
last_updated: 2026-06-23
---
# Call Protocol
@@ -39,13 +39,54 @@ pub struct CallAdapter {
/// Layer 1 — optional session-overlay source (agent crate supplies this;
/// None for non-agent deployments). See ADR-024, OQ-19.
session_source: Option<Arc<dyn SessionOverlaySource + Send + Sync>>,
/// Default timeout for wire calls (30s). Composed calls inherit the
/// parent's remaining deadline via `OperationContext.deadline`.
default_timeout: Duration,
}
// The connection's imported-ops overlay (Layer 2) is built per CallConnection
// as from_call discovery completes — it's not a field on CallAdapter but
// rather state held by the CallConnection / dispatch context for incoming
// calls on that connection. See ADR-024.
```
impl CallAdapter {
/// Non-agent deployment: no session overlay, default timeout.
pub fn new(
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
) -> Self {
Self { registry, identity_provider, session_source: None,
default_timeout: Duration::from_secs(30) }
}
/// Agent deployment: supply a session-overlay source. The agent crate
/// implements `SessionOverlaySource`; alknet-call defines the trait.
pub fn with_session_source(mut self, source: Arc<dyn SessionOverlaySource + Send + Sync>) -> Self {
self.session_source = Some(source);
self
}
/// Override the default timeout.
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.default_timeout = timeout;
self
}
}
/// Session overlay integration point (ADR-024). Defined in alknet-call
/// because `CallAdapter` must name the type — alknet-call cannot depend on
/// alknet-agent (agent depends on call, not reverse). The agent crate
/// implements this trait; alknet-call defines it. This is the same pattern
/// as `IdentityProvider` (ADR-004: core defines the trait, handlers impl it).
///
/// The session overlay is an `OperationEnv` impl that wraps the curated base
/// (Layer 0). The `CallAdapter` composes it into the root
/// `OperationContext.env` per incoming call when a session is active. The
/// lookup mechanism (session ID in metadata, payload field, connection-bound
/// session state) belongs to the agent crate — this trait is the integration
/// point, not the lookup policy.
pub trait SessionOverlaySource: Send + Sync {
/// Returns the session overlay env for the given call, if a session is
/// active. `None` means no session is active for this call — the root
/// env is `curated base + connection overlay` (no session layer).
/// The agent crate determines how to map a call to its session.
fn overlay_for(&self, context: &OperationContext) -> Option<Arc<dyn OperationEnv + Send + Sync>>;
}
The `CallAdapter` holds the static curated registry and an optional
session-overlay source. Per-connection imported-ops overlays (Layer 2,
@@ -53,6 +94,68 @@ ADR-024) are held with the connection and composed into the root
`OperationContext.env` per incoming call. See ADR-024 for the layering
model and `compose_root_env` below.
### CallConnection
A `CallConnection` represents an established `alknet/call` connection,
regardless of which side opened it (ADR-017). It holds the connection's
imported-ops overlay (Layer 2, ADR-024) the set of `from_call`-imported
operations discovered when the connection was established.
```rust
/// 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).
connection: Connection,
/// Layer 2 — this connection's imported-ops overlay. Populated by
/// `from_call` discovery when the connection is established. Each
/// imported op is a `HandlerRegistration` with `provenance: FromCall`.
/// This overlay is an `OperationEnv` impl that the `CallAdapter`
/// composes into the root `OperationContext.env` per incoming call.
imported_operations: Arc<RwLock<HashMap<String, HandlerRegistration>>>,
}
impl CallConnection {
/// Register an imported operation into this connection's overlay
/// (Layer 2, ADR-024). Called by `from_call` after discovery.
pub fn register_imported(&self, registration: HandlerRegistration) {
let name = registration.spec.name.clone();
self.imported_operations.write().insert(name, registration);
}
/// Register multiple imported operations (bulk variant for `from_call`).
pub fn register_imported_all(&self, registrations: Vec<HandlerRegistration>) {
let mut overlay = self.imported_operations.write();
for reg in registrations {
overlay.insert(reg.spec.name.clone(), reg);
}
}
/// Build an `OperationEnv` impl for this connection's overlay. Used by
/// the `CallAdapter` when composing the root `OperationContext.env`.
/// Returns an `OperationEnv` that dispatches to this connection's
/// imported ops (and reports `contains` only for ops in the overlay).
pub fn overlay_env(&self) -> Arc<dyn OperationEnv + Send + Sync>;
/// Call an operation on the remote peer (sends `call.requested`).
pub async fn call(&self, operation_id: &str, input: Value) -> ResponseEnvelope;
/// Subscribe to a streaming operation on the remote peer.
pub async fn subscribe(&self, operation_id: &str, input: Value) -> impl Stream<Item = ResponseEnvelope>;
/// Abort an in-flight request (sends `call.aborted`, cascades per ADR-016).
pub async fn abort(&self, request_id: &str);
}
```
**Layer 0 vs Layer 2 registration API** (ADR-024): `OperationRegistryBuilder`
builds Layer 0 (curated, immutable after startup) via `.with_local()` /
`.with_leaf()` / `.with()`. Layer 2 (per-connection) registration uses
`CallConnection::register_imported()` at runtime — the builder is
Layer-0-only; runtime overlay registration uses `CallConnection` methods.
When the connection drops, the overlay (and all imported ops) is dropped —
no explicit deregistration needed.
The adapter:
1. Accepts bidirectional streams on the connection
2. Reads length-prefixed JSON `EventEnvelope` frames from each stream
@@ -162,6 +265,29 @@ Fields:
New error codes may be added in future versions. Clients should treat unknown error codes as `INTERNAL` with `retryable: false`.
### Wire Payload Schemas
The `payload` field of `EventEnvelope` has a different shape per event type:
| Event | `payload` shape |
|-------|----------------|
| `call.requested` | `{ "operationId": "/fs/readFile", "input": {...}, "auth_token": "alk_..." (optional) }` |
| `call.responded` | `{ "output": <Value> }` — the operation's output, matching `output_schema` |
| `call.completed` | `{}` — empty object (subscription stream end signal) |
| `call.aborted` | `{}` — empty object (cancellation signal; the `id` identifies which request) |
| `call.error` | `{ "code": "...", "message": "...", "retryable": bool, "details": {...} (optional) }` |
### `ResponseEnvelope` → `EventEnvelope` Conversion
Local dispatch produces `ResponseEnvelope { request_id, result: Result<Value, CallError> }`. The `CallAdapter` converts it to `EventEnvelope` for the wire:
| `ResponseEnvelope` | `EventEnvelope` |
|--------------------|-----------------|
| `Ok(value)` | `{ type: "call.responded", id: request_id, payload: { output: value } }` |
| `Err(call_error)` | `{ type: "call.error", id: request_id, payload: <serialized CallError> }` |
The `request_id` becomes the `id` field. For subscriptions, each `call.responded` is a separate `EventEnvelope` with the same `id`; `call.completed` is `{ type: "call.completed", id, payload: {} }`.
### Protocol Operations
The call protocol defines four top-level operations, expressed through event types and operation names:
@@ -304,6 +430,7 @@ fn build_root_context(
handler_identity: registration.composition_authority.clone(),
capabilities: registration.capabilities.clone(), // from the registration bundle
metadata: HashMap::new(), // fresh per request
deadline: Some(Instant::now() + self.default_timeout), // root deadline (W7)
scoped_env: registration.scoped_env.clone()
.unwrap_or_else(ScopedOperationEnv::empty), // from the bundle, empty for leaves
// Per-call env composition (ADR-024): the root env is a composite
@@ -349,7 +476,17 @@ Local dispatch produces `ResponseEnvelope` with no serialization overhead. The `
**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.
**Timeouts**: Default timeout for calls is 30 seconds. Default timeout for subscriptions is optional (the client can specify a timeout in the `call.requested` payload, or leave it open-ended). The `PendingRequestMap` sweeper runs every 10 seconds and removes expired entries. Timeouts are configurable at the `CallAdapter` level, not per-operation.
**Timeouts**: Default timeout for wire calls is 30 seconds, configurable via
`CallAdapter::with_timeout()`. The `build_root_context` sets
`OperationContext.deadline` to `now + default_timeout`. Composed calls
inherit the parent's deadline (children do **not** get a fresh 30s — the
root call's deadline bounds the entire call tree, preventing a depth-5
composition from running 150s). A composed call that exceeds the deadline
is cancelled (future dropped, `Drop` guards release resources) and returns
`CallError { code: "TIMEOUT", retryable: true }`. Subscriptions default to
no deadline (`deadline: None` — unbounded); the client can specify a
timeout in the `call.requested` payload. The `PendingRequestMap` sweeper
runs every 10 seconds and removes expired wire entries.
**Error handling in `CallAdapter::handle()`**: If a handler panics, the stream is closed and the `PendingRequestMap` entry (if any) is cleaned up by the next sweeper pass. Other streams and the connection are unaffected.