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

@@ -7,7 +7,7 @@ last_updated: 2026-06-22-20
## Current State ## 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 (001025) 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 (001026) 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). **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 | | [023](decisions/023-operation-error-schemas.md) | Operation Error Schemas | Accepted |
| [024](decisions/024-operation-registry-layering.md) | Operation Registry Layering | 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 | | [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 ## Open Questions

View File

@@ -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. - 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. - 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 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<Map>`, 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. **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.

View File

@@ -41,7 +41,11 @@ pub enum TlsIdentity {
/// RFC 7250 raw Ed25519 public key. /// RFC 7250 raw Ed25519 public key.
/// No domain, no CA, no cert renewal. Key = identity. /// No domain, no CA, no cert renewal. Key = identity.
/// Same model as iroh's NodeId, but for direct QUIC connections. /// 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. /// Self-signed X.509 cert for development.
/// Generated on startup, not validated by external clients. /// 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) // P2P / key-based identity (default for most nodes)
let p2p_config = StaticConfig { let p2p_config = StaticConfig {
listen_addr: Some("0.0.0.0:4433".parse()?), 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, iroh_relay: None,
drain_timeout: Duration::from_secs(2), drain_timeout: Duration::from_secs(2),
}; };

View File

@@ -80,8 +80,7 @@ impl Connection {
- `remote_alpn()`: The ALPN negotiated for this connection. Always present. - `remote_alpn()`: The ALPN negotiated for this connection. Always present.
- `remote_addr()`: The peer's address, if available. Informational (NAT/proxy). - `remote_addr()`: The peer's address, if available. Informational (NAT/proxy).
- `close()`: Close the connection with an error code and reason. - `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()`. - `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.
- `identity()`: Read the handler-resolved identity, if set. Returns `None` until `set_identity()` is called.
The `Connection` type does not expose quinn types in its public API. It wraps `quinn::Connection` internally, but the wrapper allows test implementations. 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 | | 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 | | 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 | | 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 ## Open Questions

View File

@@ -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 | | [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 | | [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 | | [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 ## 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) | | 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) | | 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) | | 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<String>` — 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 ## Public API

View File

@@ -218,13 +218,14 @@ not part of ADR-021's rotation scheme.
| Decision | ADR | Summary | | 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 | | 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 | | 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}'` | | 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 | | 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 | | 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 ## Open Questions

View File

@@ -241,10 +241,11 @@ assembly-layer concern.
| Decision | ADR | Summary | | Decision | ADR | Summary |
|----------|-----|---------| |----------|-----|---------|
| Vault is standalone | [ADR-018](../../decisions/018-vault-standalone-crate.md) | Zero alknet crate dependencies | | 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 | | 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 | | SLIP-0044 unallocated; alknet namespace | | `74'` coin type reserved for alknet | [ADR-026](../../decisions/026-vault-key-model-hd-derivation.md) | SLIP-0044 unallocated; alknet namespace |
| secp256k1 feature-gated | | Heavy dep; only needed for Ethereum | | 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 | | 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 ## Open Questions

View File

@@ -74,15 +74,23 @@ produce different seeds.
### unlock_new(word_count) → phrase ### unlock_new(word_count) → phrase
```rust ```rust
pub fn unlock_new(&self, word_count: usize) -> Result<String, VaultServiceError>; pub fn unlock_new(&self, word_count: usize) -> Result<Zeroizing<String>, VaultServiceError>;
``` ```
Generate a new random mnemonic, unlock with it, and return the phrase. Generate a new random mnemonic, unlock with it, and return the phrase as
Store the returned phrase securely — it is the root of trust. Supported a `Zeroizing<String>`. The returned phrase is the root of trust — it is
word counts: 12, 15, 18, 21, 24. 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<String>` 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 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<String>` 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() ### lock()

View File

@@ -152,10 +152,25 @@ context.env.invoke_with_policy(
``` ```
The child's `OperationContext` carries the policy. If the child itself The child's `OperationContext` carries the policy. If the child itself
composes grandchildren, the policy propagates unless the child explicitly composes grandchildren, the policy **propagates by inheritance** — the
overrides it. This is consistent with the composition authority and scoped grandchild inherits the child's policy (which was the parent's policy,
env propagation in ADR-022 — the parent handler decides the child's unless the parent overrode it for the child via `invoke_with_policy`).
runtime context, including abort 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 The `OperationEnv` trait gains an optional policy parameter. The specific
API shape (a separate `invoke_with_policy` method, a policy field on an API shape (a separate `invoke_with_policy` method, a policy field on an

View File

@@ -107,7 +107,7 @@ forwarding handlers.
pub async fn from_call( pub async fn from_call(
connection: &CallConnection, connection: &CallConnection,
config: FromCallConfig, config: FromCallConfig,
) -> Vec<(OperationSpec, Handler)> ) -> Vec<HandlerRegistration>
``` ```
The adapter: The adapter:
@@ -115,12 +115,15 @@ The adapter:
operations operations
2. Calls `services/schema` for each → gets the input/output JSON Schemas and 2. Calls `services/schema` for each → gets the input/output JSON Schemas and
declared error_schemas (ADR-023) 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 - The spec mirrors the remote operation's name, namespace, type, schemas
(input, output, and error_schemas — ADR-023), and access control (input, output, and error_schemas — ADR-023), and access control
- The handler sends `call.requested` through the `CallConnection` and awaits - The handler sends `call.requested` through the `CallConnection` and awaits
`call.responded` (or streams for subscriptions) `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 `from_call`-registered operations are `Internal` by default (ADR-015) — they
are composition material, not directly callable from the wire. The handler are composition material, not directly callable from the wire. The handler
@@ -149,15 +152,31 @@ registry; they project it.
### 5. The adapter contract trait ### 5. The adapter contract trait
The adapter patterns share a common shape: they produce `(OperationSpec, The adapter patterns share a common shape: they produce
Handler)` pairs that register in the local registry. The trait: `HandlerRegistration` bundles that register in the local registry. The
trait:
```rust ```rust
#[async_trait]
pub trait OperationAdapter: Send + Sync { pub trait OperationAdapter: Send + Sync {
fn import(&self) -> Vec<(OperationSpec, Handler)>; async fn import(&self) -> Vec<HandlerRegistration>;
} }
``` ```
The return type is `Vec<HandlerRegistration>` (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: Implementations:
- `FromOpenAPI` — imports from an OpenAPI spec (HTTP-backed handlers) - `FromOpenAPI` — imports from an OpenAPI spec (HTTP-backed handlers)
- `FromMCP` — imports from an MCP server (MCP-backed handlers) - `FromMCP` — imports from an MCP server (MCP-backed handlers)
@@ -169,10 +188,10 @@ Implementations:
The `to_*` adapters are outbound projections, not `OperationAdapter` The `to_*` adapters are outbound projections, not `OperationAdapter`
implementations — they consume the registry, they don't produce entries for it. implementations — they consume the registry, they don't produce entries for it.
The specific trait signatures (async vs sync, error types, configuration The specific trait signatures (error types, configuration parameters) are
parameters) are two-way doors for implementation. The one-way door is the two-way doors for implementation. The one-way doors are the architectural
architectural commitment that adapters produce `(OperationSpec, Handler)` commitments: adapters produce `HandlerRegistration` bundles (ADR-022), the
pairs and live in alknet-call. trait is async (required by `from_call`), and adapters live in alknet-call.
### 6. Cross-node call tree and abort cascade ### 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., (OpenAPI paths, MCP tools). Some semantics don't map cleanly (e.g.,
subscriptions in OpenAPI, bidirectional calls in MCP). The adapters handle subscriptions in OpenAPI, bidirectional calls in MCP). The adapters handle
these with best-effort mappings and document the gaps. 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 - The `CallConnection` abstraction adds a layer between the handler and the
raw QUIC stream. This is necessary for the `from_call` handler to be 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 transparent — it shouldn't know about QUIC streams, only about call/request

View File

@@ -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 - `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 `alknet-storage` (a future crate) by type-level agreement, not by a crate
dependency — both crates must agree on the serialization format (see 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 ## Consequences

View File

@@ -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 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 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. 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 The `salt` field is available for this.
change v2 data. See OQ-22 (key rotation).
**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 ## Consequences

View File

@@ -196,6 +196,30 @@ impl CompositionAuthority {
resources: HashMap::new(), 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 /// set of operation names — the *model* is a subgraph (which nodes this
/// handler can reach), but type-compatibility edges between those nodes are /// handler can reach), but type-compatibility edges between those nodes are
/// a future enhancement for static validation, not a v1 requirement. /// 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 { pub struct ScopedOperationEnv {
/// Operation names this handler may compose (e.g., {"fs/readFile", allowed_operations: HashSet<String>,
/// "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>,
} }
impl ScopedOperationEnv { impl ScopedOperationEnv {
@@ -353,7 +382,7 @@ async fn invoke(&self, namespace: &str, operation: &str, input: Value,
let context = OperationContext { let context = OperationContext {
request_id: generate_request_id(), request_id: generate_request_id(),
parent_request_id: Some(parent.request_id.clone()), 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 handler_identity: registration.composition_authority.clone(), // C1: child's own authority
capabilities: parent.capabilities.clone(), // C3: propagate through composition capabilities: parent.capabilities.clone(), // C3: propagate through composition
metadata: HashMap::new(), // fresh — does NOT propagate (ADR-014) 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()], scopes: vec!["llm:call".into(), "fs:read".into(), "vastai:query".into()],
resources: HashMap::new(), resources: HashMap::new(),
}), }),
scoped_env: Some(ScopedOperationEnv { scoped_env: Some(ScopedOperationEnv::new(
allowed_operations: HashSet::from(["fs/readFile".into(), "vastai/listMachines".into(), ["fs/readFile", "vastai/listMachines", "llm/generate"])),
"llm/generate".into()]),
}),
capabilities: Capabilities::new() capabilities: Capabilities::new()
.with_api_key("google", google_api_key), // C3: in the bundle, not the closure .with_api_key("google", google_api_key), // C3: in the bundle, not the closure
}; };

View File

@@ -238,9 +238,23 @@ accordingly.
```rust ```rust
// OpenAPI: 404: { schema: NotFoundError } // 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 The adapter maps the OpenAPI error schema to alknet's JSON Schema format
(same conversion as input/output schemas). The `http_status` field records (same conversion as input/output schemas). The `http_status` field records
the original status code so `to_openapi` can project it back. the original status code so `to_openapi` can project it back.

View File

@@ -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

View File

@@ -118,7 +118,7 @@ These questions are acknowledged but not active. They will be promoted to open w
- **Status**: deferred - **Status**: deferred
- **Door type**: One-way (when applicable) - **Door type**: One-way (when applicable)
- **Priority**: low - **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 - **Cross-references**: ADR-007, ADR-009
### OQ-10: Git Adapter Scope — Smart Protocol Only or Full Server? ### 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 - **Status**: deferred
- **Door type**: Two-way - **Door type**: Two-way
- **Priority**: low - **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 - **Cross-references**: ADR-001
## Theme: alknet-core ## 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. 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<Identity>` or `RwLock<Option<Identity>>` — 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<Connection>` 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<Connection>` 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) - **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 ### OQ-12: TLS Identity Provisioning in AlknetEndpoint

View File

@@ -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` | | [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 | | [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 | | [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 ## Open Questions