From cb98f42cd4d0f5d253c661505460fa3b58fd1708 Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Tue, 23 Jun 2026 08:20:27 +0000 Subject: [PATCH] docs(architecture): resolve review #002 remaining Tier 4 findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — 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. 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. --- docs/architecture/README.md | 3 +- .../crates/call/operation-registry.md | 1 + docs/architecture/crates/core/config.md | 8 +- docs/architecture/crates/core/core-types.md | 5 +- docs/architecture/crates/vault/README.md | 2 + docs/architecture/crates/vault/encryption.md | 5 +- .../crates/vault/mnemonic-derivation.md | 7 +- docs/architecture/crates/vault/service.md | 18 +- .../016-abort-cascade-for-nested-calls.md | 23 ++- ...ll-protocol-client-and-adapter-contract.md | 62 +++++- .../decisions/018-vault-standalone-crate.md | 44 ++++- .../020-hd-derivation-for-encryption-keys.md | 16 +- ...on-provenance-and-composition-authority.md | 47 ++++- .../decisions/023-operation-error-schemas.md | 16 +- .../026-vault-key-model-hd-derivation.md | 185 ++++++++++++++++++ docs/architecture/open-questions.md | 17 +- docs/architecture/overview.md | 1 + 17 files changed, 413 insertions(+), 47 deletions(-) create mode 100644 docs/architecture/decisions/026-vault-key-model-hd-derivation.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 9616165..0155ddc 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -7,7 +7,7 @@ last_updated: 2026-06-22-20 ## 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 — implementation exists, pending ADR-025 refactor to drop irpc) and research/reference material. Foundational ADRs (001–025) 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), call protocol client and adapter contract (ADR-017), vault standalone crate (ADR-018), vault assembly-layer-only access (ADR-019), HD derivation for encryption keys (ADR-020), key rotation via version-indexed paths (ADR-021), handler registration, provenance, and composition authority (ADR-022), operation error schemas (ADR-023), operation registry layering (ADR-024), and vault local-only dispatch (ADR-025). ADR-024 resolves the registry mutability question (`from_call` imports require a runtime-mutable home) and the `OperationContext.env` type identity crisis (review #002 C6), by layering the registry by trust boundary and making `OperationEnv` a trait-object integration point. ADR-025 drops irpc from the vault, making it local-only by construction (inverting the security default from remote-capable-by-default to local-only-by-default) and resolving OQ-21, C7, C8, and W8. The alknet-core, alknet-call, and alknet-vault 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 — implementation exists, pending ADR-025/026 refactor to drop irpc and remove derive_password) and research/reference material. Foundational ADRs (001–026) are in place. ADR-024 resolves the registry mutability question and the `OperationContext.env` type identity crisis by layering the registry by trust boundary. ADR-025 drops irpc from the vault, making it local-only by construction. ADR-026 records the HD-derivation key model as a foundational decision. The alknet-core, alknet-call, and alknet-vault crate specs are in draft. **Next step**: Continue working through review #002's remaining Tier 4 findings (vault security decisions, guard clauses, ADR-writing exercises, smaller spec decisions). All open questions for the core and call crates are resolved; the vault crate's OQ-21 (remote vault) is now resolved (ADR-025 — vault is local-only by construction). @@ -60,6 +60,7 @@ last_updated: 2026-06-22-20 | [023](decisions/023-operation-error-schemas.md) | Operation Error Schemas | Accepted | | [024](decisions/024-operation-registry-layering.md) | Operation Registry Layering | Accepted | | [025](decisions/025-vault-local-only-dispatch.md) | Vault Local-Only Dispatch | Accepted | +| [026](decisions/026-vault-key-model-hd-derivation.md) | Vault Key Model — HD Derivation | Accepted | ## Open Questions diff --git a/docs/architecture/crates/call/operation-registry.md b/docs/architecture/crates/call/operation-registry.md index 58b9e0a..67211eb 100644 --- a/docs/architecture/crates/call/operation-registry.md +++ b/docs/architecture/crates/call/operation-registry.md @@ -510,6 +510,7 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe - Capabilities hold secret material that does not implement `Serialize` and does not appear in `EventEnvelope` payloads. - The call protocol carries no secret material. See [call-protocol.md](call-protocol.md) for the wire-level constraint. - **Capabilities are `Clone` and cloned through composition.** `OperationEnv::invoke()` calls `parent.capabilities.clone()` to pass capabilities to nested calls. This is intentional: a child handler needs the same outbound credentials as its parent (e.g., the `/agent/chat` handler composing `/fs/readFile` may need the same API key for an outbound LLM call). The security implication is that each composition step duplicates the secret material reference — but capabilities are scoped (the handler can only use what the assembly layer declared on the registration bundle), and children run under the parent's composition authority (ADR-015, ADR-022). A clone is the same scoped handle, not a widening of scope. The concrete cloning semantics (reference-counted `Arc` vs deep copy of zeroized material) is a two-way door for implementation, but `Capabilities: Clone` is required by the composition model. +- **Capabilities must be immutable after construction.** No interior mutability, no `Mutex`, no `RefCell`. This makes the clone-semantics two-way door genuinely two-way: Arc-based clone (shared immutable state) and deep-copy clone (isolated state) are behaviorally identical when neither supports mutation. Without this guard, a handler that mutates capabilities (e.g., adds a derived key for a child) would make the mutation visible to siblings and the parent under Arc-based clone — shared mutable state across the call tree, a security-relevant behavior. Once shipped, handlers may depend on shared mutation, and switching from Arc-shared to deep-copy-isolated later is a behavior change that breaks them. The immutability guard prevents the "two-way door" from becoming a future one-way door. **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. diff --git a/docs/architecture/crates/core/config.md b/docs/architecture/crates/core/config.md index dc44a27..08fe888 100644 --- a/docs/architecture/crates/core/config.md +++ b/docs/architecture/crates/core/config.md @@ -41,7 +41,11 @@ pub enum TlsIdentity { /// RFC 7250 raw Ed25519 public key. /// No domain, no CA, no cert renewal. Key = identity. /// Same model as iroh's NodeId, but for direct QUIC connections. - RawKey(SecretKey), + /// `SecretKey` is `iroh::SecretKey` (Ed25519) — re-exported from iroh, + /// which alknet-core already depends on (feature-gated, ADR-010). The + /// key can be derived from alknet-vault at the assembly layer + /// (endpoint.md) or generated fresh. See OQ-12, W14. + RawKey(iroh::SecretKey), /// Self-signed X.509 cert for development. /// Generated on startup, not validated by external clients. @@ -76,7 +80,7 @@ The reference `StaticConfig` (in `alknet-main/crates/alknet-core/src/config/stat // P2P / key-based identity (default for most nodes) let p2p_config = StaticConfig { listen_addr: Some("0.0.0.0:4433".parse()?), - tls_identity: Some(TlsIdentity::RawKey(SecretKey::generate())), + tls_identity: Some(TlsIdentity::RawKey(iroh::SecretKey::generate())), iroh_relay: None, drain_timeout: Duration::from_secs(2), }; diff --git a/docs/architecture/crates/core/core-types.md b/docs/architecture/crates/core/core-types.md index aa29ffb..18e199a 100644 --- a/docs/architecture/crates/core/core-types.md +++ b/docs/architecture/crates/core/core-types.md @@ -80,8 +80,7 @@ impl Connection { - `remote_alpn()`: The ALPN negotiated for this connection. Always present. - `remote_addr()`: The peer's address, if available. Informational (NAT/proxy). - `close()`: Close the connection with an error code and reason. -- `set_identity()`: Store the handler-resolved identity for observability (OQ-11). Write-once-read-many — a second call returns an error. Handlers that resolve identity inside `handle()` call this; the endpoint and observability layers read it via `identity()`. -- `identity()`: Read the handler-resolved identity, if set. Returns `None` until `set_identity()` is called. +- `set_identity()`: Store the handler-resolved identity for observability (OQ-11). Write-once-read-many — a second call returns an error. Handlers that resolve identity inside `handle()` call this; the identity is read by handler-side logging (the handler logs which identity it resolved) and is available on the `Connection` for any code that holds a reference to it. The endpoint does **not** read `identity()` after `handle()` returns — the `Connection` is moved into the spawned handler task (endpoint.md), so the endpoint no longer has a reference. Connection-level observability (remote addr, ALPN, connection ID) is logged by the endpoint before the move; identity-level observability is logged by the handler. See OQ-11 for the full resolution. The `Connection` type does not expose quinn types in its public API. It wraps `quinn::Connection` internally, but the wrapper allows test implementations. @@ -187,7 +186,7 @@ connection, ADR-006). | BiStream is a trait | [ADR-007](../../decisions/007-bistream-type-definition.md) | WASM door preserved, test mocks possible | | HandlerError is non-fatal | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Handler errors close the connection, not the endpoint | | SendStream/RecvStream wrap quinn + iroh | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Internal enum dispatch for both QUIC sources | -| Connection stores handler-resolved identity | OQ-11 (resolved) | `set_identity` via `OnceLock` — write-once-read-many for observability | +| Connection stores handler-resolved identity | OQ-11 (resolved) | `set_identity` via `OnceLock` — write-once-read-many; read by handler-side logging, not by the endpoint (C13 resolved) | ## Open Questions diff --git a/docs/architecture/crates/vault/README.md b/docs/architecture/crates/vault/README.md index 478226f..38b8cb9 100644 --- a/docs/architecture/crates/vault/README.md +++ b/docs/architecture/crates/vault/README.md @@ -48,6 +48,7 @@ seed and derived private keys never cross the network. | [020](../../decisions/020-hd-derivation-for-encryption-keys.md) | HD Derivation for Encryption Keys | SLIP-0010 derivation, not PBKDF2; salt unused in v2 | | [021](../../decisions/021-key-rotation-via-version-indexed-paths.md) | Key Rotation via Version-Indexed Paths | Version-indexed paths; `rotate` re-encrypts | | [025](../../decisions/025-vault-local-only-dispatch.md) | Vault Local-Only Dispatch | Dropped irpc; direct method calls; local-only by construction | +| [026](../../decisions/026-vault-key-model-hd-derivation.md) | Vault Key Model — HD Derivation | HD derivation from BIP39 seed; `74'` coin type; AES-256-GCM | ## Relevant Open Questions @@ -126,6 +127,7 @@ truth for drift tracking — if an item is fixed in source, update this table. | 5 | `DerivedKey` dual serialization | JSON redacts, postcard preserves bytes | Always redact on serialize; reject `"[REDACTED]"` on deserialize with error (ADR-025, resolves W8) | `protocol.rs` | [protocol.md → Serialization Redaction](protocol.md#serialization-redaction), [ADR-025](../../decisions/025-vault-local-only-dispatch.md) | | 6 | `HashMap::clear` zeroization | `KeyCache::clear()` removes entries and relies on `CachedKey`'s `Drop` impl for zeroization | Verify `HashMap::clear()` actually drops values (it does, but worth a test) | `cache.rs` | [service.md → Security Constraints](service.md#security-constraints) | | 7 | `derive_password` / `site_password_path` | `derive_password`, `derive_password_string`, `site_password_path` methods exist | Remove entirely — password-manager pattern not relevant to RPC system's vault (ADR-025, resolves C9) | `service.rs`, `mnemonic-derivation.rs` | [ADR-025](../../decisions/025-vault-local-only-dispatch.md) | +| 8 | `unlock_new` return type | Returns `String` (not zeroized on drop) | Return `Zeroizing` — the mnemonic is the root of trust and must not linger in freed memory (resolves W7) | `service.rs` | [service.md → unlock_new](service.md#unlock_newword_count--phrase) | ## Public API diff --git a/docs/architecture/crates/vault/encryption.md b/docs/architecture/crates/vault/encryption.md index 61a11f2..c8ee93c 100644 --- a/docs/architecture/crates/vault/encryption.md +++ b/docs/architecture/crates/vault/encryption.md @@ -218,13 +218,14 @@ not part of ADR-021's rotation scheme. | Decision | ADR | Summary | |----------|-----|---------| -| AES-256-GCM for credential encryption | — | Authenticated encryption, hardware-accelerated | +| AES-256-GCM for credential encryption | [ADR-026](../../decisions/026-vault-key-model-hd-derivation.md) | Authenticated encryption, hardware-accelerated | | HD derivation, not PBKDF2 | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Seed-derived key; no password; deterministic | | Salt unused in v2 (wire-format compat) | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Kept for TS compat; not used in key derivation | -| Key derived at `m/74'/2'/0'/0'` | — | Dedicated account for encryption keys | +| Key derived at `m/74'/2'/0'/0'` | [ADR-026](../../decisions/026-vault-key-model-hd-derivation.md) | Dedicated account for encryption keys | | Version-indexed paths for rotation | [ADR-021](../../decisions/021-key-rotation-via-version-indexed-paths.md) | `m/74'/2'/0'/{version-2}'` | | Key versioning (v1=TS PBKDF2, v2=vault HD) | [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) | Distinguishes derivation methods | | All fields base64-encoded | — | JSON serialization compatibility | +| `EncryptedData` wire format frozen | [ADR-018](../../decisions/018-vault-standalone-crate.md) | Fields, encoding, semantics locked; no removal without migration | ## Open Questions diff --git a/docs/architecture/crates/vault/mnemonic-derivation.md b/docs/architecture/crates/vault/mnemonic-derivation.md index 327552d..d9b18f9 100644 --- a/docs/architecture/crates/vault/mnemonic-derivation.md +++ b/docs/architecture/crates/vault/mnemonic-derivation.md @@ -241,10 +241,11 @@ assembly-layer concern. | Decision | ADR | Summary | |----------|-----|---------| | Vault is standalone | [ADR-018](../../decisions/018-vault-standalone-crate.md) | Zero alknet crate dependencies | -| HD derivation (not stored keys) | — | One seed, many keys, no key storage | -| `74'` coin type reserved for alknet | — | SLIP-0044 unallocated; alknet namespace | -| secp256k1 feature-gated | — | Heavy dep; only needed for Ethereum | +| HD derivation (not stored keys) | [ADR-026](../../decisions/026-vault-key-model-hd-derivation.md) | One seed, many keys, no key storage; reproducible across nodes | +| `74'` coin type reserved for alknet | [ADR-026](../../decisions/026-vault-key-model-hd-derivation.md) | SLIP-0044 unallocated; alknet namespace | +| secp256k1 feature-gated | [ADR-026](../../decisions/026-vault-key-model-hd-derivation.md) | Heavy dep; only needed for Ethereum | | Hardened-only for Ed25519 | SLIP-0010 | Ed25519 cannot do public derivation | +| Vault is local-only | [ADR-025](../../decisions/025-vault-local-only-dispatch.md) | Direct method calls, no irpc, no remote dispatch | ## Open Questions diff --git a/docs/architecture/crates/vault/service.md b/docs/architecture/crates/vault/service.md index 99ecb81..668f77d 100644 --- a/docs/architecture/crates/vault/service.md +++ b/docs/architecture/crates/vault/service.md @@ -74,15 +74,23 @@ produce different seeds. ### unlock_new(word_count) → phrase ```rust -pub fn unlock_new(&self, word_count: usize) -> Result; +pub fn unlock_new(&self, word_count: usize) -> Result, VaultServiceError>; ``` -Generate a new random mnemonic, unlock with it, and return the phrase. -Store the returned phrase securely — it is the root of trust. Supported -word counts: 12, 15, 18, 21, 24. +Generate a new random mnemonic, unlock with it, and return the phrase as +a `Zeroizing`. The returned phrase is the root of trust — it is +heap-allocated and zeroized on drop, so it does not linger in freed +memory. The caller should extract the phrase for secure storage (write +down, display to user) and let the `Zeroizing` drop when done. +Do not clone the returned value or store it in a non-zeroizing container. +Supported word counts: 12, 15, 18, 21, 24. This is the "first run" path — a new node generates its mnemonic, writes -it down, and the vault is unlocked for the process lifetime. +it down, and the vault is unlocked for the process lifetime. The +`Zeroizing` wrapper (from the `zeroize` crate) ensures the +mnemonic is wiped from memory once the caller is done with it, matching +the `Mnemonic` type's own `ZeroizeOnDrop` behavior. This resolves review +#002 W7. ### lock() diff --git a/docs/architecture/decisions/016-abort-cascade-for-nested-calls.md b/docs/architecture/decisions/016-abort-cascade-for-nested-calls.md index 5e92345..c03d0f1 100644 --- a/docs/architecture/decisions/016-abort-cascade-for-nested-calls.md +++ b/docs/architecture/decisions/016-abort-cascade-for-nested-calls.md @@ -152,10 +152,25 @@ context.env.invoke_with_policy( ``` The child's `OperationContext` carries the policy. If the child itself -composes grandchildren, the policy propagates unless the child explicitly -overrides it. This is consistent with the composition authority and scoped -env propagation in ADR-022 — the parent handler decides the child's -runtime context, including abort policy. +composes grandchildren, the policy **propagates by inheritance** — the +grandchild inherits the child's policy (which was the parent's policy, +unless the parent overrode it for the child via `invoke_with_policy`). +`ContinueRunning` does auto-propagate to grandchildren: if a parent opts +its child into `ContinueRunning`, and the child composes grandchildren +without explicitly overriding, the grandchildren also get +`ContinueRunning`. This is consistent with the composition authority and +scoped env propagation in ADR-022 — the parent handler decides the +child's runtime context, including abort policy, and that decision +propagates through the composition tree by default. + +**Review #002 W19 resolution**: `invoke()` with no explicit policy +argument inherits the parent's current policy (option a). It does **not** +reset to `AbortDependents`. A handler that wants a child to reset to the +default must explicitly call `invoke_with_policy(..., +AbortPolicy::AbortDependents)`. This makes the propagation predictable: +the policy I set for my child applies to my child's children unless they +re-decide. The `invoke()` default in operation-registry.md +(`abort_policy: parent.abort_policy.clone()`) is correct. The `OperationEnv` trait gains an optional policy parameter. The specific API shape (a separate `invoke_with_policy` method, a policy field on an 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 index ffa92b2..8484ba4 100644 --- a/docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md +++ b/docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md @@ -107,7 +107,7 @@ forwarding handlers. pub async fn from_call( connection: &CallConnection, config: FromCallConfig, -) -> Vec<(OperationSpec, Handler)> +) -> Vec ``` The adapter: @@ -115,12 +115,15 @@ The adapter: operations 2. Calls `services/schema` for each → gets the input/output JSON Schemas and declared error_schemas (ADR-023) -3. For each discovered operation, constructs an `(OperationSpec, Handler)` pair: +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 subscriptions) -4. The caller registers these pairs in their local registry + - `provenance: FromCall`, `composition_authority: None`, `scoped_env: None` + (leaves — ADR-022) +4. The caller registers these bundles in their local registry (into the + connection's overlay — ADR-024) `from_call`-registered operations are `Internal` by default (ADR-015) — they are composition material, not directly callable from the wire. The handler @@ -149,15 +152,31 @@ 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: +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 { - fn import(&self) -> Vec<(OperationSpec, Handler)>; + async fn import(&self) -> Vec; } ``` +The return type is `Vec` (not `(OperationSpec, +Handler)` pairs) — ADR-022 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 QUIC 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) - `FromMCP` — imports from an MCP server (MCP-backed handlers) @@ -169,10 +188,10 @@ Implementations: 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. +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), the +trait is async (required by `from_call`), and adapters live in alknet-call. ### 6. Cross-node call tree and abort cascade @@ -245,6 +264,29 @@ same as `from_openapi` receives HTTP credentials. (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. +- **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., + subscriptions → 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 ADR-009's framework: it classifies doors by reversal cost + in the codebase, not by compatibility cost for external consumers. +- **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: 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 QUIC stream. This is necessary for the `from_call` handler to be transparent — it shouldn't know about QUIC streams, only about call/request diff --git a/docs/architecture/decisions/018-vault-standalone-crate.md b/docs/architecture/decisions/018-vault-standalone-crate.md index b1dab6a..9d6cd9b 100644 --- a/docs/architecture/decisions/018-vault-standalone-crate.md +++ b/docs/architecture/decisions/018-vault-standalone-crate.md @@ -104,7 +104,49 @@ The vault defines its own types and does not share types with alknet-core: - `EncryptedData` is the vault's encrypted blob format. It is shared with `alknet-storage` (a future crate) by type-level agreement, not by a crate dependency — both crates must agree on the serialization format (see - [encryption.md](../crates/vault/encryption.md)). + [encryption.md](../crates/vault/encryption.md)). The format is **frozen** + (see Decision below). + +## `EncryptedData` Wire Format Lock + +The `EncryptedData` struct is a **stable wire format** shared with +`alknet-storage` (a future crate) and the TypeScript consumer +(`@alkdev/storage`) by type-level agreement, not by a crate dependency. +Both crates and the TypeScript consumer must agree on the serialization +format. The format is now explicitly **frozen**: + +```rust +pub struct EncryptedData { + pub key_version: u32, // rotation tracking + pub salt: String, // base64, 32 bytes — unused in v2 (wire-format compat) + pub iv: String, // base64, 12 bytes — AES-GCM nonce + pub data: String, // base64 — ciphertext + auth tag +} +``` + +The frozen compatibility surface: + +- **Fields**: `key_version`, `salt`, `iv`, `data` — no fields may be + removed or renamed. New fields may be added only if they are optional + (default on deserialization) and do not change the meaning of existing + fields. +- **Encoding**: all binary fields are base64-encoded as strings for JSON + serialization. This is the cross-language wire format. +- **Field semantics**: `key_version` selects the derivation path + (ADR-021). `salt` is unused in v2 but is part of the frozen format — + it cannot be removed without a format-version migration (a future KDF + in v3 would use the salt for *new* data, not retroactively for v2 data + — see ADR-020, W6). `iv` is the 12-byte GCM nonce. `data` is the + ciphertext with the GCM auth tag appended. + +**Why this needs an explicit lock**: the "type-level agreement, not a +crate dependency" approach means there is no compiler enforcement of the +format across crates. The stability contract existed only in prose. An +implementer modifying `EncryptedData` (e.g., removing the unused `salt` +field) would find no ADR saying "this format is frozen." This decision +makes the freeze explicit and enforceable by review. + +**This resolves review #002 W10.** ## Consequences diff --git a/docs/architecture/decisions/020-hd-derivation-for-encryption-keys.md b/docs/architecture/decisions/020-hd-derivation-for-encryption-keys.md index 81b2721..ffa8080 100644 --- a/docs/architecture/decisions/020-hd-derivation-for-encryption-keys.md +++ b/docs/architecture/decisions/020-hd-derivation-for-encryption-keys.md @@ -153,8 +153,20 @@ not a bug. The migration tool handles the TS→vault transition. If a future use case requires KDF-based key derivation (e.g., stretching a key derived from a non-seed source, or using a salt for additional domain separation), it would be a new key_version with its own derivation method. -The `salt` field is available for this. This is additive — it doesn't -change v2 data. See OQ-22 (key rotation). +The `salt` field is available for this. + +**Clarification (review #002 W6)**: the salt field is reserved for *future +versions'* use. v2 data's salt is permanently unused — it was random, never +participated in key derivation, and cannot be retroactively made +load-bearing for v2 data. Introducing a KDF in v3 is a new derivation +method (not a version-indexed path), requiring its own design and a v2→v3 +migration (re-encrypt with the new KDF, using a newly-generated v3 salt — +the v2 salt is not reused). The field's presence saves a wire-format struct +change only (ADR-018 locks the wire format); it does not make the KDF +design or migration trivial. A KDF doesn't fit the rotation scheme +(version-indexed paths, ADR-021) — it's a different derivation *family*, +not another version index. See OQ-22 (key rotation) and ADR-018 +(`EncryptedData` wire format lock). ## Consequences diff --git a/docs/architecture/decisions/022-handler-registration-provenance-and-composition-authority.md b/docs/architecture/decisions/022-handler-registration-provenance-and-composition-authority.md index ad72b49..a1fcaee 100644 --- a/docs/architecture/decisions/022-handler-registration-provenance-and-composition-authority.md +++ b/docs/architecture/decisions/022-handler-registration-provenance-and-composition-authority.md @@ -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 { + 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` to a typed subgraph) a +/// non-breaking change to construction sites (review #002 W21). The +/// `HashSet` 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, + allowed_operations: HashSet, } 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 }; diff --git a/docs/architecture/decisions/023-operation-error-schemas.md b/docs/architecture/decisions/023-operation-error-schemas.md index 301ff0f..158ad27 100644 --- a/docs/architecture/decisions/023-operation-error-schemas.md +++ b/docs/architecture/decisions/023-operation-error-schemas.md @@ -238,9 +238,23 @@ accordingly. ```rust // OpenAPI: 404: { schema: NotFoundError } -// → ErrorDefinition { code: "NOT_FOUND", http_status: Some(404), schema: NotFoundError } +// → ErrorDefinition { code: "HTTP_404", http_status: Some(404), schema: NotFoundError } ``` +**Normative rule (review #002 W20)**: `from_openapi` must not produce error +codes that collide with the five protocol-level codes (`NOT_FOUND`, +`FORBIDDEN`, `INVALID_INPUT`, `INTERNAL`, `TIMEOUT`). The adapter prefixes +imported error codes with `HTTP_` and the status number (e.g., `HTTP_404`, +`HTTP_429`) to avoid collision. This is a requirement for the adapter, not +a naming convention — the `from_openapi` example above was previously shown +producing `NOT_FOUND` from a 404, which collided with the protocol-level +`NOT_FOUND` (operation not registered). The `details` field disambiguates +in practice (present for operation-level, absent for protocol-level), but +ADR-023 says "clients should switch on `code`, not parse `message`" — so +the `code` alone must be unambiguous. Operations that hand-write their own +`ErrorDefinition`s should use domain-specific codes (`FILE_NOT_FOUND`, +`RATE_LIMITED`) rather than reusing protocol codes. + The adapter maps the OpenAPI error schema to alknet's JSON Schema format (same conversion as input/output schemas). The `http_status` field records the original status code so `to_openapi` can project it back. diff --git a/docs/architecture/decisions/026-vault-key-model-hd-derivation.md b/docs/architecture/decisions/026-vault-key-model-hd-derivation.md new file mode 100644 index 0000000..9b1e0ad --- /dev/null +++ b/docs/architecture/decisions/026-vault-key-model-hd-derivation.md @@ -0,0 +1,185 @@ +# ADR-026: Vault Key Model — HD Derivation + +## Status + +Accepted + +## Context + +The vault's primary use of HD (hierarchical deterministic) derivation is +for identity keys, SSH host keys, and signing keys. ADR-020 covers HD +derivation for *encryption* keys specifically, but the broader decision — +"the vault uses HD derivation from a single BIP39 seed for all +self-generated secrets, not stored keys" — has no ADR. The rationale is +inline in `mnemonic-derivation.md`'s "Why HD Derivation" section, but the +choice is a one-way door: switching to stored keys would change the entire +trust model, the backup story, and the derivation path semantics. + +Several related design choices also have inline rationale but no ADR: + +- **`74'` coin type reservation** (SLIP-0044): alknet claims an unallocated + coin type for its derivation paths. Once keys are derived at `m/74'/...`, + changing the coin type would re-derive all keys — effectively one-way. +- **secp256k1 feature-gating**: the secp256k1/BIP-0032 dependency (needed + only for Ethereum signing) is feature-gated to avoid pulling a heavy C + dependency into nodes that don't do Ethereum signing. +- **AES-256-GCM cipher/mode choice**: the authenticated encryption scheme + for credential storage. The rationale (authenticated, hardware-accelerated) + is inline in `encryption.md` with no ADR. + +These are foundational one-way doors that the entire vault model depends +on. They should be recorded as ADRs so a future reader sees *why* these +choices were made, not just *what* they are. + +### Relationship to ADR-020 + +ADR-020 is a special case of this ADR — it covers HD derivation for the +*encryption key* specifically, including the v1→v2 migration from PBKDF2 +to HD derivation. This ADR covers the *general* HD-derivation model that +ADR-020 builds on. ADR-020's decision (HD derivation at `m/74'/2'/0'/0'` +for encryption keys) is unchanged; this ADR records the overarching +principle. + +## Decision + +### 1. HD derivation from a single BIP39 seed is the vault's key model + +All self-generated secrets in alknet are derived from a single BIP39 +mnemonic via hierarchical deterministic (HD) derivation. The vault does +not store keys — it derives them on demand from the seed and caches them +for performance (the cache is rebuildable from the seed). + +This is the same model as cryptocurrency wallets: one seed phrase, many +derived keys at deterministic paths. The properties that make this the +right model for alknet: + +- **No key storage**: keys are derived on demand, not stored. The vault + caches derived keys for performance, but the cache is rebuildable from + the seed. No key file management, no key rotation infrastructure, no + per-key backup. +- **Reproducible across nodes**: the same mnemonic on a different node + produces the same keys. A backup node derives the same identity key. + This is critical for disaster recovery — the mnemonic is the only thing + that needs to be backed up. +- **Domain separation**: different paths produce cryptographically + independent keys. The identity key, SSH host key, encryption key, and + signing keys are all independent despite coming from one seed. +- **Auditable derivation**: the path records what a key is for. + `m/74'/0'/0'/0'` is the identity key; `m/74'/0'/1'/0'` is the SSH host + key. The path is the documentation. + +### 2. SLIP-0010 (Ed25519) is the default derivation scheme + +Ed25519 is alknet's default curve — it's what TLS raw key identity +(ADR-010), SSH host keys, and signing keys use. SLIP-0010 is the HD +derivation standard for Ed25519 (hardened-only, HMAC-SHA512 with +`"ed25519 seed"` as the key). + +BIP-0032 (secp256k1) is supported for Ethereum signing (the standard +Ethereum path `m/44'/60'/0'/0/0` requires unhardened indices, which +SLIP-0010 cannot handle). secp256k1 is feature-gated (see Decision 4). + +### 3. `74'` coin type is reserved for alknet + +alknet reserves the `74'` coin type (unallocated per SLIP-0044) for its +derivation paths. All alknet paths start with `m/74'/...`: + +| Path prefix | Purpose | +|-------------|---------| +| `m/74'/0'/...` | Identity keys (node, device, SSH host) | +| `m/74'/2'/...` | Encryption keys (credential storage) | +| `m/44'/60'/...` | Ethereum signing keys (secp256k1, standard BIP-44) | + +Once keys are derived at `m/74'/...`, the coin type cannot be changed +without re-deriving all keys from a new path — which would produce +different keys, breaking all existing identity, TLS, SSH, and encryption +contexts. This is effectively one-way once any deployment generates keys. + +### 4. secp256k1 is feature-gated + +The `secp256k1` crate (BIP-0032 derivation for Ethereum) is a heavy C +dependency. Most alknet nodes do not do Ethereum signing and should not +pay the compilation cost. The `secp256k1` feature flag gates +Ethereum-specific derivation: + +- Without the feature: `derive_ethereum_key` returns + `VaultServiceError::UnsupportedKeyType`. +- With the feature: full BIP-0032 secp256k1 derivation at the standard + Ethereum path. + +### 5. AES-256-GCM for credential encryption + +External credentials (API keys, OAuth tokens, bearer tokens) are encrypted +at rest using AES-256-GCM with a seed-derived key. AES-256-GCM is an +authenticated encryption scheme — it provides both confidentiality +(encryption) and integrity (authentication tag). A tampered ciphertext +fails decryption, which is the correct behavior for credential storage: +if an attacker modifies an encrypted API key in storage, decryption fails +rather than producing a different plaintext. + +GCM is hardware-accelerated on modern CPUs (AES-NI), making it fast enough +that encryption is never a bottleneck. The 12-byte nonce (IV) is generated +with `OsRng` (CSPRNG) — IV reuse under the same key is catastrophic for +GCM. + +The encryption key is derived from the seed at `m/74'/2'/0'/0'` via +SLIP-0010 — see ADR-020 for the full encryption key derivation rationale +and the v1→v2 migration from PBKDF2. + +## Consequences + +**Positive:** + +- One seed, many keys, no key storage. The mnemonic is the only thing + that needs to be backed up. Disaster recovery is "restore the mnemonic, + re-derive everything." +- Reproducibility across nodes. A backup node with the same mnemonic + derives the same identity key, SSH host key, and encryption key. This + is critical for failover and migration. +- Domain separation via paths. The path *is* the documentation of what a + key is for. No separate key registry or metadata needed. +- Ed25519 as the default curve aligns with TLS raw key identity (RFC 7250, + ADR-010), SSH key-based auth, and iroh's NodeId model. One key type for + all identity purposes. +- secp256k1 feature-gating keeps the default dependency tree lean. Nodes + that don't do Ethereum signing don't pay the secp256k1 compilation cost. + +**Negative:** + +- The mnemonic is a single point of failure. If the mnemonic is lost, all + derived keys are lost. If the mnemonic is compromised, all derived keys + are compromised. Mitigated: the mnemonic is stored offline (written + down), the vault is local-only (ADR-025), and the passphrase (BIP39 + password extension) adds a second factor. +- Changing the coin type (`74'`) or any path prefix is effectively + one-way once keys are derived. This is inherent to HD derivation — the + path *is* the key identity. Mitigated: the path scheme is designed to + accommodate future use cases (device index, key version) without + changing prefixes. +- Ed25519-only for the default derivation scheme means non-hardened + derivation is not available (SLIP-0010 limitation). If a future use case + needs non-hardened Ed25519 derivation (e.g., deriving public keys from a + public key without the seed), SLIP-0010 cannot do it. Mitigated: this is + not a current use case; if it becomes one, a different derivation scheme + or a non-HD approach would be needed for that specific key. + +## References + +- ADR-020: HD derivation for encryption keys (a special case of this ADR — + covers the encryption key at `m/74'/2'/0'/0'` and the v1→v2 migration + from PBKDF2) +- ADR-010: ALPN router and endpoint (Ed25519 as the default curve for TLS + raw key identity — the identity key at `m/74'/0'/0'/0'`) +- ADR-018: Vault as standalone crate (the vault defines its own key types + and derivation paths) +- ADR-025: Vault local-only dispatch (the vault is local-only; the seed + never crosses the network) +- [mnemonic-derivation.md](../crates/vault/mnemonic-derivation.md) — + BIP39, SLIP-0010, BIP-0032, derivation paths, PATHS module +- [encryption.md](../crates/vault/encryption.md) — AES-256-GCM, + EncryptedData, key versioning +- SLIP-0010: Universal hierarchical deterministic keys (Ed25519) +- SLIP-0044: Registered coin types for BIP-0032 / SLIP-0010 (`74'` is + unallocated) +- BIP-0032: Hierarchical deterministic wallets (secp256k1) +- BIP-39: Mnemonic code for generating deterministic keys \ No newline at end of file diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index 27b4621..06acd44 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -118,7 +118,7 @@ These questions are acknowledged but not active. They will be promoted to open w - **Status**: deferred - **Door type**: One-way (when applicable) - **Priority**: low -- **Resolution**: Not an active question — WASM compatibility is a design constraint (see ADR-009, overview.md design principles), not a deliverable. Specific WASM targeting decisions will be made when individual crates are implemented. The BiStream trait decision (ADR-007) has already preserved the most important WASM door. +- **Resolution**: Not an active question — WASM compatibility is a design constraint (see ADR-009, overview.md design principles), not a deliverable. Specific WASM targeting decisions will be made when individual crates are implemented. **BiStream being a trait preserves the *client-side* stream door** — a browser can implement BiStream over WebTransport streams. **The *server-side* dispatch door is NOT preserved by ADR-007 and is a known, accepted closure**: `Connection` is a concrete quinn-bound struct (not a trait), the accept loop uses `tokio::spawn` (tokio does not run on WASM), and the call-protocol dispatch internals (`PendingRequestMap`, `CallAdapter`) use tokio `oneshot`/`mpsc` channels. A WASM server-side peer would require a `Connection` trait and a runtime-abstracted accept loop — not planned. The browser path is client-side via a JS SDK, not server-side Rust-to-WASM. This is an explicit one-way door, not an oversight. - **Cross-references**: ADR-007, ADR-009 ### OQ-10: Git Adapter Scope — Smart Protocol Only or Full Server? @@ -127,7 +127,7 @@ These questions are acknowledged but not active. They will be promoted to open w - **Status**: deferred - **Door type**: Two-way - **Priority**: low -- **Resolution**: Deferred per the cleanup plan. Start with git smart protocol over QUIC streams. ERC721 integration and full server capabilities are additive. Resolve when speccing alknet-git. +- **Resolution**: Deferred per the cleanup plan. Start with git smart protocol over QUIC streams. ERC721 integration and full server capabilities are additive. **Composability fork (review #002 W18)**: whether git operations are registered in the `OperationRegistry` and callable via `env.invoke()`, or only available as raw smart protocol on `alknet/git`, is a separate decision from ERC721 scope. The path of least resistance (raw smart protocol only) forecloses agent composition of git operations — an agent handler that wants to compose `git/clone` cannot, because there's no `OperationSpec`, no `Handler`, no registration. To make git composable, a call-protocol projection (a set of `HandlerRegistration` bundles wrapping git operations behind the registry) must be built alongside or instead of the raw handler. Resolve this when speccing alknet-git, not deferred past it. - **Cross-references**: ADR-001 ## Theme: alknet-core @@ -150,7 +150,18 @@ These questions are acknowledged but not active. They will be promoted to open w Both exist. The connection-level identity is the stable "who is this connection from"; the per-request identity is the dynamic "who is this specific call from." The call protocol's per-request resolution (which may produce a different identity than the connection-level resolution) takes precedence for ACL on `OperationContext` — the connection-level identity is for observability only, not for ACL. - `Connection` exposes `set_identity` via interior mutability (`OnceLock` or `RwLock>` — the handler sets it once when resolved, the endpoint and observability layers read it). `handle()` receives `Connection` by value (owned), but the endpoint may also hold a reference for logging. The identity is write-once-read-many. + **C13 resolution (review #002)**: the endpoint does **not** read + `identity()` after `handle()` returns. The `Connection` is moved into the + spawned handler task (endpoint.md), so the endpoint no longer has a + reference to it. Connection-level observability (remote addr, ALPN, + connection ID) is logged by the endpoint *before* the move. Identity-level + observability is logged by the handler (the handler knows which identity + it resolved and can log it). There is no `Arc` sharing or + channel-based identity-reporting mechanism — the simplest honest answer + that avoids over-engineering the observability path before there's a + demonstrated need. If a future use case requires the endpoint to + correlate connections to identities, an `Arc` or a + side-channel can be added then. - **Cross-references**: ADR-004, ADR-011, ADR-015 (per-request identity on OperationContext), [auth.md](crates/core/auth.md) ### OQ-12: TLS Identity Provisioning in AlknetEndpoint diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 3e3c71f..82ac96b 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -216,6 +216,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/). | [023](decisions/023-operation-error-schemas.md) | Operation Error Schemas | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity for `from_openapi`/`to_openapi` | | [024](decisions/024-operation-registry-layering.md) | Operation Registry Layering | Curated (static) + session/connection overlays (dynamic); `OperationEnv` as trait-object integration point | | [025](decisions/025-vault-local-only-dispatch.md) | Vault Local-Only Dispatch | Dropped irpc from vault; direct method calls; local-only by construction | +| [026](decisions/026-vault-key-model-hd-derivation.md) | Vault Key Model — HD Derivation | HD derivation from BIP39 seed; `74'` coin type; SLIP-0010/Ed25519 default; AES-256-GCM for credentials | ## Open Questions