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
---
# Operation Registry
@@ -136,6 +136,14 @@ pub struct OperationContext {
/// composing handler via `OperationEnv::invoke()` (or
/// `invoke_with_policy()`), not by the wire caller.
pub abort_policy: AbortPolicy,
/// Deadline for this call and all descendants. Set by `build_root_context`
/// to `now + CallAdapter.default_timeout` (default 30s). Composed calls
/// inherit the parent's deadline (children do not get a fresh 30s — the
/// root call's deadline bounds the entire call tree). A composed call
/// that exceeds the deadline is cancelled (future dropped, `Drop` guards
/// release resources). `None` means no deadline (unbounded — used for
/// long-running subscriptions). See call-protocol.md → Timeouts.
pub deadline: Option<Instant>,
/// Composition-origin flag. Set by `OperationEnv::invoke()` (true) or the
/// `CallAdapter` dispatch path (false) — never by handlers. Module-private
/// for writes; read via `is_internal()`. See ADR-015.
@@ -191,6 +199,27 @@ The registry maps operation names to `HandlerRegistration` bundles. The curated
- `invoke(name, input, context)`: Look up, check ACL, invoke handler, return result
- `list_operations()`: Return all registered specs (for `/services/list` — returns curated + active overlay ops)
### Request ID Generation
Request IDs correlate `call.requested`/`call.responded` events and index the
abort-cascade tree (`PendingRequestMap` is keyed by request ID, ADR-016).
- **Wire calls**: the root `OperationContext.request_id` is the `id` field
from the wire `call.requested` event (generated by the client).
- **Composed calls**: `OperationEnv::invoke()` generates a new `request_id`
for each child via `generate_request_id()` — a UUID v4 (or
`parent_id + "-" + counter`). Deterministic IDs (e.g.
`format!("env-{name}")`) **must not** be used — they collide across
concurrent invocations of the same operation, corrupting
`PendingRequestMap` correlation and the abort-cascade tree.
- **Wire visibility**: composed child `request_id`s are **internal** — they
appear in `PendingRequestMap` for abort-cascade indexing but are not sent
as `call.requested` to any peer. The client only sees `call.aborted` for
the root ID it sent; the server cascades internally to descendants. The
exception is `from_call` ops, which generate their own wire ID when
forwarding to the remote node (the remote node's `PendingRequestMap`
indexes it).
### HandlerRegistration
The registration bundle carries everything the dispatch path needs to construct an `OperationContext`. See ADR-022 for the full rationale.
@@ -206,25 +235,74 @@ pub struct HandlerRegistration {
}
```
- `provenance`: Where the op came from (`Local`, `FromOpenAPI`, `FromMCP`, `FromCall`, `FromJsonSchema`, `Session`). Determines composition capability, default visibility, and trust model. Only `Local` and `Session` ops can compose; leaves get `composition_authority: None` and `scoped_env: None`.
- `composition_authority`: The declared authority (label + scopes + resources) the handler operates under when composing children. `None` for leaves. This replaces ADR-015's `handler_identity: Identity` — it's not a peer identity, it's a declared authority bundle. See ADR-022.
#### OperationProvenance
Where the op came from. Determines composition capability, default
visibility, and trust model. See ADR-022 for rationale.
```rust
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
FromJsonSchema, // JSON Schema definition, no handler — schema only
Session, // Agent-written, sandboxed, can compose within sandbox
}
```
| Provenance | Can compose? | Has composition authority? | Default visibility |
|-----------|-------------|---------------------------|-------------------|
| `Local` | Yes | Yes — scopes set by assembly layer | External or Internal (assembly declares) |
| `FromOpenAPI` | No (leaf) | No | Internal |
| `FromMCP` | No (leaf) | No | Internal |
| `FromCall` | No (leaf in local registry) | No | Internal |
| `FromJsonSchema` | N/A (no handler) | No | N/A |
| `Session` | Yes (within sandbox) | Yes — scopes set at sandbox creation | Internal always |
#### CompositionAuthority
The declared authority (label + scopes + resources) the handler operates
under when composing children. `None` for leaves. This replaces ADR-015's
`handler_identity: Identity` — it's not a peer identity, it's a declared
authority bundle. See ADR-022.
```rust
pub struct CompositionAuthority {
pub label: String, // e.g., "agent-chat" — not a peer id
pub scopes: Vec<String>, // e.g., ["llm:call", "fs:read"]
pub resources: HashMap<String, Vec<String>>, // e.g., {"service": ["vastai"]}
}
impl CompositionAuthority {
pub fn none() -> Option<Self> { None } // Convenience for leaves
pub fn new(label: &str, scopes: impl IntoIterator<Item = String>) -> Self { ... }
pub fn as_identity(&self) -> Option<Identity> { ... } // Synthetic Identity for ACL
}
```
- `provenance`: Determines composition capability. Only `Local` and `Session` ops can compose; leaves get `composition_authority: None` and `scoped_env: None`.
- `composition_authority`: The declared authority the handler operates under when composing children. `None` for leaves. See ADR-022.
- `scoped_env`: The set of operations this handler may reach via `env.invoke()`. `None` for leaves (empty env). The reachability control from ADR-015.
- `capabilities`: Outbound credentials (decrypted API keys, signing keys). Populated by the assembly layer from the vault at registration time. See [Capability Injection](#capability-injection).
The `OperationRegistryBuilder` provides a fluent API with convenience methods for common cases:
```rust
// with_local: Local provenance, full bundle — all 5 args required.
// with_local(spec, handler, composition_authority, scoped_env, capabilities)
let registry = OperationRegistryBuilder::new()
// Built-in service discovery (Local, no composition)
// Built-in service discovery (Local, no composition — empty authority, empty env, empty caps)
.with_local(services_list_spec(), Arc::new(services_list_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty())
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
.with_local(services_schema_spec(), Arc::new(schema_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty())
// Agent handler (Local, composes — has authority + scoped env)
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
// Agent handler (Local, composes — authority + scoped env + capabilities)
.with_local(agent_chat_spec(), Arc::new(agent_chat_handler),
CompositionAuthority::new("agent-chat", ["llm:call", "fs:read", "vastai:query"]),
ScopedOperationEnv::new(["fs/readFile", "vastai/listMachines", "llm/generate"]))
// Imported ops (leaves — no authority, no scoped env)
ScopedOperationEnv::new(["fs/readFile", "vastai/listMachines", "llm/generate"]),
Capabilities::new().with_api_key("google", google_api_key))
// Imported ops (leaves — no authority, no scoped env; capabilities for outbound HTTP)
.with_leaf(vastai_listMachines_spec(), Arc::new(vastai_handler), vastai_credentials)
.build();
```
@@ -249,19 +327,25 @@ pub trait OperationEnv: Send + Sync {
/// Compose a child operation. The child's `OperationContext` is
/// constructed with `internal: true`, inheriting the parent's
/// composition authority as the child's caller identity. The abort
/// policy defaults to the parent's (ADR-016 Decision 6).
/// policy defaults to the parent's (ADR-016 Decision 6, W19).
///
/// Default impl: delegates to `invoke_with_policy` with
/// `parent.abort_policy.clone()`. Impls only need to implement
/// `invoke_with_policy` — `invoke` is provided.
async fn invoke(
&self,
namespace: &str,
operation: &str,
input: Value,
parent: &OperationContext,
) -> ResponseEnvelope;
) -> ResponseEnvelope {
self.invoke_with_policy(namespace, operation, input, parent, parent.abort_policy.clone()).await
}
/// Compose a child with an explicit abort policy (ADR-016 Decision 6).
/// Use `AbortPolicy::ContinueRunning` for long-running work that
/// should survive a parent's abort. The default `invoke()` inherits
/// the parent's policy; this method overrides it for this child.
/// should survive a parent's abort. This is the required method —
/// `invoke()` delegates to it with the parent's policy.
async fn invoke_with_policy(
&self,
namespace: &str,
@@ -270,6 +354,14 @@ pub trait OperationEnv: Send + Sync {
parent: &OperationContext,
policy: AbortPolicy,
) -> ResponseEnvelope;
/// Does this env contain the named operation? Used by
/// `CompositeOperationEnv` to probe overlays before dispatching
/// (ADR-024). The composite checks `session.contains()` →
/// `connection.contains()` → base, dispatching to the first overlay
/// that contains the op. Default impl returns `true` (a single-layer
/// env like `LocalOperationEnv` contains everything it can dispatch).
fn contains(&self, name: &str) -> bool { true }
}
```
@@ -292,7 +384,10 @@ pub struct LocalOperationEnv {
#[async_trait]
impl OperationEnv for LocalOperationEnv {
async fn invoke(&self, namespace: &str, operation: &str, input: Value, parent: &OperationContext) -> ResponseEnvelope {
// `invoke` uses the default impl (delegates to `invoke_with_policy`
// with `parent.abort_policy.clone()`).
async fn invoke_with_policy(&self, namespace: &str, operation: &str, input: Value, parent: &OperationContext, policy: AbortPolicy) -> ResponseEnvelope {
let name = format!("{namespace}/{operation}");
// Reachability check (ADR-015, ADR-022): is this op in the parent's
@@ -307,7 +402,7 @@ impl OperationEnv for LocalOperationEnv {
let registration = self.registry.registration(&name);
let context = OperationContext {
// Unique per invocation — a counter, UUID, or parent_id + suffix.
// Unique per invocation — a UUID v4 or parent_id + counter.
// A deterministic ID (e.g. format!("env-{name}")) collides across
// concurrent invocations of the same operation, which corrupts
// PendingRequestMap correlation and the abort-cascade tree
@@ -324,21 +419,21 @@ impl OperationEnv for LocalOperationEnv {
handler_identity: registration.composition_authority.clone(),
capabilities: parent.capabilities.clone(), // Inherit caller's capabilities
metadata: HashMap::new(), // Fresh — does NOT propagate parent metadata (ADR-014)
abort_policy: policy, // Explicit policy (from invoke() default or invoke_with_policy)
deadline: parent.deadline, // Inherit parent's deadline (children don't get a fresh 30s)
scoped_env: registration.scoped_env.clone()
.unwrap_or_else(ScopedOperationEnv::empty), // Child's own scoped env (empty for leaves)
// Dispatch trait: the child inherits the parent's env (the same
// composite of curated base + active overlays). See ADR-024.
env: parent.env.clone(),
// Abort policy: inherit the parent's policy by default (ADR-016).
// The parent handler can override via `invoke_with_policy()`.
abort_policy: parent.abort_policy.clone(),
internal: true, // Nested calls use handler authority
};
self.registry.invoke(&name, input, context).await
}
// invoke_with_policy() delegates to invoke() with the policy set on the
// child context (ADR-016 Decision 6). See the trait definition above.
// `contains` uses the default impl (returns true — the curated registry
// contains everything it can dispatch). For a single-layer env, the
// reachability check in `invoke_with_policy` is the real gate.
}
```
@@ -357,34 +452,48 @@ pub struct CompositeOperationEnv {
#[async_trait]
impl OperationEnv for CompositeOperationEnv {
async fn invoke(&self, namespace: &str, operation: &str, input: Value, parent: &OperationContext) -> ResponseEnvelope {
// `invoke` uses the default impl (delegates to `invoke_with_policy`
// with `parent.abort_policy.clone()`).
async fn invoke_with_policy(&self, namespace: &str, operation: &str, input: Value, parent: &OperationContext, policy: AbortPolicy) -> ResponseEnvelope {
let name = format!("{namespace}/{operation}");
// Reachability check against parent.scoped_env (same as LocalOperationEnv).
if !parent.scoped_env.allows(&name) {
return ResponseEnvelope::not_found(name);
}
// Dispatch in overlay order: session → connection → curated base.
// First match wins. Each overlay is an OperationEnv impl that knows
// its own registry; the composite routes to the right one.
// First overlay that *contains* the op wins. `contains()` (ADR-024)
// is the probe — it avoids the sentinel-return ambiguity and ensures
// cross-impl interop: any OperationEnv impl that correctly reports
// `contains` works with this composite.
if let Some(session) = &self.session {
// session impl checks its own registry; if not found, falls
// through (returns a sentinel or the composite continues).
// Implementation detail: the session impl's `invoke` either
// dispatches or returns a "not in this overlay" signal.
if session.contains(&name) {
return session.invoke_with_policy(namespace, operation, input, parent, policy).await;
}
}
if let Some(connection) = &self.connection {
// same pattern
if connection.contains(&name) {
return connection.invoke_with_policy(namespace, operation, input, parent, policy).await;
}
}
self.base.invoke(namespace, operation, input, parent).await
self.base.invoke_with_policy(namespace, operation, input, parent, policy).await
}
fn contains(&self, name: &str) -> bool {
// The composite contains the op if any layer does.
self.session.as_ref().map_or(false, |s| s.contains(name))
|| self.connection.as_ref().map_or(false, |c| c.contains(name))
|| self.base.contains(name)
}
}
```
The exact "first match wins" mechanism (sentinel return, a separate
`contains` check, or a try/else pattern) is a two-way door for
implementation — the structural decision (composite trait object, overlay
order, `Arc::clone` inheritance) is what ADR-024 locks.
```
The `contains()` method (review #003 C9) is the overlay-dispatch contract.
It replaces the previous "sentinel or contains check — two-way door" framing,
which was ambiguous enough to produce non-interoperable `OperationEnv` impls.
The structural decision (composite trait object, overlay order, `Arc::clone`
inheritance) is locked by ADR-024; the dispatch contract (`contains` probe
before `invoke_with_policy`) is now locked too.
Two things happen in `invoke()`:
@@ -456,12 +565,12 @@ let vastai_credentials = Capabilities::new().with_http_token("vastai", vastai_to
// Register operations — vault operations are NOT registered here
let registry = OperationRegistryBuilder::new()
// Built-in service discovery (Local, no composition)
// Built-in service discovery (Local, no composition — empty caps)
.with_local(services_list_spec(), Arc::new(services_list_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty())
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
.with_local(services_schema_spec(), Arc::new(schema_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty())
// Agent handler (Local, composes — has authority + scoped env + capabilities)
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
// Agent handler (Local, composes — full bundle via .with())
.with(HandlerRegistration {
spec: agent_chat_spec(),
handler: Arc::new(agent_chat_handler),
@@ -478,6 +587,7 @@ let registry = OperationRegistryBuilder::new()
.build();
let call_adapter = CallAdapter::new(Arc::new(registry), identity_provider);
// Agent deployment: let call_adapter = CallAdapter::new(...).with_session_source(source);
```
The vault is used at construction time to populate `capabilities` in the registration bundle, not registered as call protocol operations. The curated layer (Layer 0) is immutable after construction — adding a `Local` op requires restarting the process. Session and imported overlays are dynamic at their respective scopes (ADR-024). This is consistent with OQ-04 (scoped to the `HandlerRegistry` by ADR-024), ADR-008, ADR-014, and ADR-022.