docs(architecture): resolve review #002 remaining Tier 4 findings

Add ADR-026 (vault key model — HD derivation) recording the foundational
HD-derivation decision, 74' coin type reservation, SLIP-0010/Ed25519
default, secp256k1 feature-gating, and AES-256-GCM cipher choice. These
were previously inline rationale with no ADR (W9).

Extend ADR-018 with an explicit EncryptedData wire format lock — fields,
encoding, and semantics are frozen; no removal without a format-version
migration (W10).

Resolve the remaining guard clauses and spec decisions:

- W2: Capabilities must be immutable after construction (no interior
  mutability). Makes the Arc vs deep-copy clone semantics genuinely
  two-way.
- W5: Published to_* specs are compatibility contracts — best-effort
  mappings are two-way before first publication, one-way after. Version
  generated specs.
- W6: Salt field clarification — v2 salt is permanently unused; a future
  KDF is a different derivation family, not a version-indexed path; the
  field saves a wire-format change only.
- W7: unlock_new returns Zeroizing<String> — the mnemonic is the root of
  trust and must not linger in freed memory.
- W17: OQ-09 WASM — server-side dispatch door is honestly closed
  (Connection is concrete, tokio-bound), not implicitly preserved.
- W18: OQ-10 git — composability fork (raw smart protocol vs call-protocol
  projection) is a separate decision from ERC721 scope.
- W20: from_openapi must prefix imported error codes (HTTP_404) to avoid
  collision with protocol-level codes (NOT_FOUND). Normative rule, not
  naming convention.
- W21: ScopedOperationEnv field is private — construction via new()/
  empty(), query via allows(). Makes the future subgraph refactor
  non-breaking.
- C13: Connection::set_identity — the endpoint does not read identity()
  after handle() returns (Connection is moved into the spawned task).
  Observability is handler-side logging. Simplest honest answer.
- W1: OperationAdapter trait is async, returns Vec<HandlerRegistration>.
  from_call requires async discovery; ADR-022 changed the return type.
- W11: CompositionAuthority::as_identity() defined — constructs a
  synthetic Identity (label as id, scopes, resources) not resolvable via
  IdentityProvider. Second Identity construction path, acknowledged.
- W14: SecretKey is iroh::SecretKey (Ed25519) — consistent with the
  endpoint's iroh dependency.
- W19: Grandchild abort propagation is inherit-by-default (option a) —
  invoke() with no explicit policy inherits parent's policy. ContinueRunning
  auto-propagates to grandchildren unless explicitly overridden.
This commit is contained in:
2026-06-23 08:20:27 +00:00
parent 91159bf574
commit cb98f42cd4
17 changed files with 413 additions and 47 deletions

View File

@@ -196,6 +196,30 @@ impl CompositionAuthority {
resources: HashMap::new(),
}
}
/// Convert to a synthetic `Identity` for ACL matching on child calls.
///
/// When a handler composes a child via `env.invoke()`, the child's
/// `identity` (the caller identity for ACL) is set to the parent's
/// composition authority converted to an `Identity`. This constructs
/// a synthetic `Identity { id: label, scopes, resources }` that is
/// **not** resolvable via `IdentityProvider` — it's not a peer
/// identity, it's a declared authority bundle used directly for ACL
/// matching. This creates a second `Identity` construction path (the
/// first is `IdentityProvider::resolve_*`), which is acknowledged and
/// intentional: the composition authority is a declared authority, not
/// a resolved credential.
///
/// Returns `None` when the authority is `None` (leaf case — leaves
/// don't compose, so `as_identity()` is never called on them in
/// practice, but the `Option` makes the types line up).
pub fn as_identity(&self) -> Option<Identity> {
Some(Identity {
id: self.label.clone(),
scopes: self.scopes.clone(),
resources: self.resources.clone(),
})
}
}
```
@@ -224,12 +248,17 @@ as a subgraph of the operation graph.
/// set of operation names — the *model* is a subgraph (which nodes this
/// handler can reach), but type-compatibility edges between those nodes are
/// a future enhancement for static validation, not a v1 requirement.
///
/// The `allowed_operations` field is **private** (not `pub`). Construction
/// is via `ScopedOperationEnv::new(ops)` or `ScopedOperationEnv::empty()`.
/// Reachability is queried via `allows(&name)`. This encapsulation makes the
/// future subgraph refactor (from `HashSet<String>` to a typed subgraph) a
/// non-breaking change to construction sites (review #002 W21). The
/// `HashSet<String>` representation does not support type-compatibility
/// validation — session-scoped ops (OQ-19, untrusted code) compose without
/// static type checking until a flowgraph crate is built.
pub struct ScopedOperationEnv {
/// Operation names this handler may compose (e.g., {"fs/readFile",
/// "vastai/listMachines"}). `env.invoke()` for any name not in this set
/// returns NOT_FOUND. This is the reachability boundary — it bounds the
/// parameterized-dispatch attack surface.
pub allowed_operations: HashSet<String>,
allowed_operations: HashSet<String>,
}
impl ScopedOperationEnv {
@@ -353,7 +382,7 @@ async fn invoke(&self, namespace: &str, operation: &str, input: Value,
let context = OperationContext {
request_id: generate_request_id(),
parent_request_id: Some(parent.request_id.clone()),
identity: parent.handler_identity_as_identity(), // parent's authority becomes the caller
identity: parent.handler_identity.as_identity(), // parent's authority becomes the caller
handler_identity: registration.composition_authority.clone(), // C1: child's own authority
capabilities: parent.capabilities.clone(), // C3: propagate through composition
metadata: HashMap::new(), // fresh — does NOT propagate (ADR-014)
@@ -409,10 +438,8 @@ let agent_registration = HandlerRegistration {
scopes: vec!["llm:call".into(), "fs:read".into(), "vastai:query".into()],
resources: HashMap::new(),
}),
scoped_env: Some(ScopedOperationEnv {
allowed_operations: HashSet::from(["fs/readFile".into(), "vastai/listMachines".into(),
"llm/generate".into()]),
}),
scoped_env: Some(ScopedOperationEnv::new(
["fs/readFile", "vastai/listMachines", "llm/generate"])),
capabilities: Capabilities::new()
.with_api_key("google", google_api_key), // C3: in the bundle, not the closure
};