docs(architecture): resolve review #002 Tiers 1-3 — mechanical and consistency fixes
Governance (Tier 2):
- Advance ADR-022 and ADR-023 from Proposed to Accepted (specs already
depend on their types as source of truth)
- Amend ADR-015: mark Decision 3 and Assumption 6 as superseded by ADR-022;
update handler_identity type to CompositionAuthority
- Amend ADR-002: note handle() signature revised by ADR-007 (BiStream → Connection)
- Amend ADR-004: note 'enrich/replace' AuthContext language superseded by
ADR-011's immutability model; update to describe set_identity on Connection
- Update main README ADR table to show ADR-022/023 as Accepted
Spec-ADR consistency (Tier 3):
- Add abort_policy: AbortPolicy field to OperationContext struct (ADR-016
Decision 6 mandated this but the spec omitted it)
- Define AbortPolicy enum (AbortDependents | ContinueRunning) with Default impl
- Add abort_policy to build_root_context and LocalOperationEnv::invoke()
- Define the OperationEnv trait explicitly with invoke() and
invoke_with_policy() methods (was referenced as 'must remain a trait'
but never defined)
- Specify From<StreamError> for HandlerError impl with exact variant mapping
- Add Connection::from_quinn() / from_iroh() constructors (was referenced
as Connection::new() but never defined)
- Remove undefined CertAuthorityEntry placeholder from AuthPolicy v1 (will
be added additively when alknet-ssh lands)
- Fix config.md key-differences table: rate limits are in DynamicConfig,
not StaticConfig
Mechanical fixes (Tier 1):
- overview.md: 'closes the QUIC stream' → 'closes the connection' (stale
from pre-ADR-007 model)
- overview.md: OQ-04 entry updated from stale 'defer to implementation'
to 'resolved: static at startup'
- mnemonic-derivation.md: remove duplicate helper functions block (incomplete
first copy, complete second copy)
- ADR-003: add iroh (feature-gated) to alknet-core dependency list, added
by ADR-010
- ADR-021: fix ambiguous 'W1 drift issue from the vault review' cross-reference
- ADR-022: rephrase FromCall 'leaf locally' to 'leaf in the local registry'
- ADR-017: add error_schemas to from_call mirror list and services/schema
step (inconsistency with ADR-023)
- ADR-016: fix self-referential citation ('ADR-016 Assumption 5' → 'Assumption 5')
- Add ScopedOperationEnv::empty(), allows(), new() and
CompositionAuthority::none(), new() impl blocks (referenced but undefined)
- Add call.completed clarification for non-subscription calls
- Add services/schema leading-slash normalization note
- Crate README ADR tables: add missing ADR-013 (call), ADR-015 (core),
ADR-006 + ADR-010 (vault)
- Vault README: add consolidated 'Known Source Drift' table tracking all
four drift items (OsRng, unwrap, CURRENT_KEY_VERSION, spawn bug) in one
place, including the two previously missing from README
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-20
|
||||
last_updated: 2026-06-22-20
|
||||
---
|
||||
|
||||
# Alknet Architecture
|
||||
@@ -56,8 +56,8 @@ last_updated: 2026-06-20
|
||||
| [019](decisions/019-vault-assembly-layer-only.md) | Vault Assembly-Layer-Only Access | Accepted |
|
||||
| [020](decisions/020-hd-derivation-for-encryption-keys.md) | HD Derivation for Encryption Keys | Accepted |
|
||||
| [021](decisions/021-key-rotation-via-version-indexed-paths.md) | Key Rotation via Version-Indexed Paths | Accepted |
|
||||
| [022](decisions/022-handler-registration-provenance-and-composition-authority.md) | Handler Registration, Provenance, and Composition Authority | Proposed |
|
||||
| [023](decisions/023-operation-error-schemas.md) | Operation Error Schemas | Proposed |
|
||||
| [022](decisions/022-handler-registration-provenance-and-composition-authority.md) | Handler Registration, Provenance, and Composition Authority | Accepted |
|
||||
| [023](decisions/023-operation-error-schemas.md) | Operation Error Schemas | Accepted |
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-22
|
||||
last_updated: 2026-06-22-22
|
||||
---
|
||||
|
||||
# alknet-call
|
||||
@@ -21,6 +21,7 @@ Structured RPC over QUIC: operations, request/response, streaming subscriptions,
|
||||
| [001](../../decisions/001-alpn-protocol-dispatch.md) | ALPN-Based Protocol Dispatch | CallAdapter registers on ALPN `alknet/call` |
|
||||
| [002](../../decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | CallAdapter implements ProtocolHandler |
|
||||
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-call depends on alknet-core and irpc |
|
||||
| [013](../../decisions/013-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Adapter traits defined in Rust; TS is reference/browser adaptation |
|
||||
| [004](../../decisions/004-auth-as-shared-core.md) | Auth as Shared Core | AuthContext passed to call handlers |
|
||||
| [005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc as Call Protocol Foundation | irpc provides framing and service dispatch |
|
||||
| [006](../../decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention | `alknet/call` ALPN, one ALPN per connection |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-22
|
||||
last_updated: 2026-06-22-22
|
||||
---
|
||||
|
||||
# Call Protocol
|
||||
@@ -105,6 +105,10 @@ Five event types carry request/response and subscription semantics:
|
||||
|
||||
The `id` field carries the `requestId` for correlation.
|
||||
|
||||
`call.completed` is sent only for subscriptions. A plain `call()` (request/response)
|
||||
is complete after its single `call.responded`; no `call.completed` follows. The
|
||||
`PendingRequestMap` entry for a `Call` is deleted on the first `call.responded`.
|
||||
|
||||
### `call.requested` Payload
|
||||
|
||||
The `payload` of a `call.requested` event has this shape:
|
||||
@@ -297,6 +301,7 @@ fn build_root_context(
|
||||
metadata: HashMap::new(), // fresh per request
|
||||
env: registration.scoped_env.clone()
|
||||
.unwrap_or_else(ScopedOperationEnv::empty), // from the bundle, empty for leaves
|
||||
abort_policy: AbortPolicy::default(), // abort-dependents (ADR-016 Decision 6)
|
||||
internal: false, // external call — ACL against caller identity
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-22
|
||||
last_updated: 2026-06-22-22
|
||||
---
|
||||
|
||||
# Operation Registry
|
||||
@@ -116,12 +116,36 @@ pub struct OperationContext {
|
||||
pub capabilities: Capabilities,
|
||||
pub metadata: HashMap<String, Value>,
|
||||
pub env: OperationEnv,
|
||||
/// Abort policy for this call's descendants (ADR-016 Decision 6).
|
||||
/// Default `AbortDependents` — aborting this request aborts all
|
||||
/// non-terminal descendants. `ContinueRunning` is an opt-in for
|
||||
/// long-running work that should survive a parent's abort. Set by the
|
||||
/// composing handler via `OperationEnv::invoke()` (or
|
||||
/// `invoke_with_policy()`), not by the wire caller.
|
||||
pub abort_policy: AbortPolicy,
|
||||
/// Composition-origin flag. Set by `OperationEnv::invoke()` (true) or the
|
||||
/// `CallAdapter` dispatch path (false) — never by handlers. Module-private
|
||||
/// for writes; read via `is_internal()`. See ADR-015.
|
||||
pub(crate) internal: bool,
|
||||
}
|
||||
|
||||
/// Abort cascade policy for a call's descendants (ADR-016).
|
||||
///
|
||||
/// `AbortDependents` (default): aborting this call cascades to all
|
||||
/// non-terminal descendants.
|
||||
///
|
||||
/// `ContinueRunning` (opt-in): descendants that have already started
|
||||
/// continue to completion; descendants that haven't started are aborted;
|
||||
/// no new descendants start.
|
||||
pub enum AbortPolicy {
|
||||
AbortDependents,
|
||||
ContinueRunning,
|
||||
}
|
||||
|
||||
impl Default for AbortPolicy {
|
||||
fn default() -> Self { Self::AbortDependents }
|
||||
}
|
||||
|
||||
impl OperationContext {
|
||||
pub fn is_internal(&self) -> bool { self.internal }
|
||||
}
|
||||
@@ -195,7 +219,45 @@ The CLI binary (or assembly layer) constructs the registry and passes it to the
|
||||
|
||||
### OperationEnv
|
||||
|
||||
`OperationEnv` is the universal composition mechanism. A handler calls `context.env.invoke("fs", "readFile", input, &context)` and gets a `ResponseEnvelope` back — regardless of whether the operation runs locally, via an irpc service, or on a remote node.
|
||||
The `OperationEnv` trait is the universal composition mechanism. A handler calls `context.env.invoke("fs", "readFile", input, &context)` and gets a `ResponseEnvelope` back — regardless of whether the operation runs locally, via an irpc service, or on a remote node.
|
||||
|
||||
```rust
|
||||
/// The composition dispatch trait. A handler composes child operations
|
||||
/// through its `OperationContext.env` (which implements this trait).
|
||||
///
|
||||
/// This must remain a trait, not a concrete type — session-scoped
|
||||
/// registries (OQ-19) depend on wrapping the global env via trait
|
||||
/// layering. Making `OperationEnv` concrete or hardcoding the global
|
||||
/// registry into the dispatch path would close the session-overlay
|
||||
/// pattern.
|
||||
#[async_trait]
|
||||
pub trait OperationEnv: Send + Sync {
|
||||
/// Compose a child operation. The child's `OperationContext` is
|
||||
/// constructed with `internal: true`, inheriting the parent's
|
||||
/// composition authority as the child's caller identity. The abort
|
||||
/// policy defaults to the parent's (ADR-016 Decision 6).
|
||||
async fn invoke(
|
||||
&self,
|
||||
namespace: &str,
|
||||
operation: &str,
|
||||
input: Value,
|
||||
parent: &OperationContext,
|
||||
) -> ResponseEnvelope;
|
||||
|
||||
/// Compose a child with an explicit abort policy (ADR-016 Decision 6).
|
||||
/// Use `AbortPolicy::ContinueRunning` for long-running work that
|
||||
/// should survive a parent's abort. The default `invoke()` inherits
|
||||
/// the parent's policy; this method overrides it for this child.
|
||||
async fn invoke_with_policy(
|
||||
&self,
|
||||
namespace: &str,
|
||||
operation: &str,
|
||||
input: Value,
|
||||
parent: &OperationContext,
|
||||
policy: AbortPolicy,
|
||||
) -> ResponseEnvelope;
|
||||
}
|
||||
```
|
||||
|
||||
The `parent` parameter propagates the calling context: the nested call gets `parent_request_id: Some(parent.request_id)`, inherits `parent.handler_identity` as the caller identity, and is marked `internal: true`.
|
||||
|
||||
@@ -242,6 +304,9 @@ impl OperationEnv for LocalOperationEnv {
|
||||
metadata: HashMap::new(), // Fresh — does NOT propagate parent metadata (ADR-014)
|
||||
env: registration.scoped_env.clone()
|
||||
.unwrap_or_else(ScopedOperationEnv::empty), // Child's own scoped env (empty for leaves)
|
||||
// Abort policy: inherit the parent's policy by default (ADR-016).
|
||||
// The parent handler can override via `invoke_with_policy()`.
|
||||
abort_policy: parent.abort_policy.clone(),
|
||||
internal: true, // Nested calls use handler authority
|
||||
};
|
||||
self.registry.invoke(&name, input, context).await
|
||||
@@ -283,7 +348,14 @@ These are read-only — no admin operations are exposed through the call protoco
|
||||
}
|
||||
```
|
||||
|
||||
`services/schema` accepts `{ "name": "fs/readFile" }` and returns the full `OperationSpec` including input/output JSON Schemas and declared `error_schemas` (ADR-023). This enables client code generation: a client reading the schema can produce typed error enums instead of generic error handling.
|
||||
`services/schema` accepts `{ "name": "fs/readFile" }` (no leading slash —
|
||||
registry form, same as `OperationSpec.name`) and returns the full
|
||||
`OperationSpec` including input/output JSON Schemas and declared
|
||||
`error_schemas` (ADR-023). The `CallAdapter` normalizes the leading slash
|
||||
from wire `operationId`s before lookup, so `services/schema` accepts both
|
||||
`fs/readFile` and `/fs/readFile`. This enables client code generation: a
|
||||
client reading the schema can produce typed error enums instead of generic
|
||||
error handling.
|
||||
|
||||
### irpc Integration
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-21
|
||||
last_updated: 2026-06-22-21
|
||||
---
|
||||
|
||||
# alknet-core
|
||||
@@ -29,6 +29,7 @@ Core library for ALPN-based protocol dispatch. Every handler crate depends on al
|
||||
| [009](../../decisions/009-one-way-door-decision-framework.md) | One-Way Door Framework | Decision classification |
|
||||
| [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Endpoint, HandlerRegistry, accept loop |
|
||||
| [011](../../decisions/011-authcontext-structure.md) | AuthContext Structure | AuthContext fields and resolution flow |
|
||||
| [015](../../decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | Per-request identity on OperationContext; admin scope for config reload |
|
||||
|
||||
## Relevant Open Questions
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-21
|
||||
last_updated: 2026-06-22-21
|
||||
---
|
||||
|
||||
# Configuration
|
||||
@@ -115,19 +115,17 @@ pub struct AuthPolicy {
|
||||
/// Stored as strings to avoid russh dependency in core.
|
||||
pub authorized_fingerprints: HashSet<String>,
|
||||
|
||||
/// Certificate authorities for certificate-based auth.
|
||||
/// The exact structure is TBD — it will be defined when alknet-ssh
|
||||
/// is implemented. For now, this is a placeholder that reserves
|
||||
/// the field. alknet-ssh will define `CertAuthorityEntry` with
|
||||
/// the necessary fields (public key, principals, options).
|
||||
pub cert_authorities: Vec<CertAuthorityEntry>,
|
||||
|
||||
/// API keys for token-based auth.
|
||||
pub api_keys: Vec<ApiKeyEntry>,
|
||||
}
|
||||
```
|
||||
|
||||
`CertAuthorityEntry` is a placeholder type. Its fields will be defined when alknet-ssh is implemented and the certificate authority validation requirements are clear. For v1, `cert_authorities` will be an empty vector.
|
||||
Certificate authority entries for cert-based auth will be added when
|
||||
alknet-ssh is implemented. The `cert_authorities` field is omitted from v1
|
||||
to avoid referencing an undefined type. Adding it back is additive (a new
|
||||
field on `AuthPolicy` is non-breaking for existing config files that don't
|
||||
use it). alknet-ssh will define `CertAuthorityEntry` with the necessary
|
||||
fields (public key, principals, options).
|
||||
|
||||
This replaces the reference implementation's `AuthPolicy` which depended on `russh::keys::PublicKey`. The new version stores fingerprints as strings, not russh types. This removes the russh dependency from alknet-core.
|
||||
|
||||
@@ -217,7 +215,7 @@ Simplified from the reference implementation. Removes proxy-specific errors (now
|
||||
|
||||
| Aspect | Reference | New Model |
|
||||
|--------|-----------|-----------|
|
||||
| StaticConfig fields | SSH host key, stealth, transport_mode, listeners, proxy | listen_addr, TLS cert/key, drain_timeout, rate limits |
|
||||
| StaticConfig fields | SSH host key, stealth, transport_mode, listeners, proxy | listen_addr, TLS cert/key, drain_timeout |
|
||||
| DynamicConfig.auth | `HashSet<PublicKey>` (russh types) | `HashSet<String>` (fingerprint strings) |
|
||||
| ListenerConfig | Enum with Stream/Http/Dns variants | Eliminated — single endpoint, ALPN dispatch |
|
||||
| TransportMode | Tcp/Tls/Iroh | Eliminated — always QUIC+TLS |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-16
|
||||
last_updated: 2026-06-22-16
|
||||
---
|
||||
|
||||
# Core Types
|
||||
@@ -57,6 +57,14 @@ pub struct Connection {
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
/// Construct from a quinn connection (feature-gated on quinn).
|
||||
#[cfg(feature = "quinn")]
|
||||
pub fn from_quinn(conn: quinn::Connection) -> Self;
|
||||
|
||||
/// Construct from an iroh connection (feature-gated on iroh).
|
||||
#[cfg(feature = "iroh")]
|
||||
pub fn from_iroh(conn: iroh::Connection) -> Self;
|
||||
|
||||
pub async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
|
||||
pub async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
|
||||
pub fn remote_alpn(&self) -> &[u8];
|
||||
@@ -110,7 +118,7 @@ impl AsyncRead for RecvStream { ... }
|
||||
- `RecvStream` implements `AsyncRead`. Read bytes from the peer.
|
||||
- These are concrete wrapper types that use internal enum dispatch to delegate to the appropriate QUIC stream type (quinn or iroh) in production, and to test mocks in tests.
|
||||
|
||||
Since the endpoint supports both quinn and iroh connection sources (ADR-010), streams may come from either. `Connection::new()` wraps the appropriate stream source based on where the connection came from.
|
||||
Since the endpoint supports both quinn and iroh connection sources (ADR-010), streams may come from either. `Connection::from_quinn()` / `Connection::from_iroh()` wrap the appropriate stream source based on where the connection came from.
|
||||
|
||||
## StreamError
|
||||
|
||||
@@ -138,6 +146,39 @@ When a handler encounters a `StreamError` and needs to return from `handle()`, i
|
||||
|
||||
Handlers that manage multiple streams (SSH, call) may catch `StreamError::StreamClosed` per-stream and continue serving other streams on the same connection — only `ConnectionClosed` forces `handle()` to return.
|
||||
|
||||
The mapping is provided as a `From` impl so handlers can use the `?` operator:
|
||||
|
||||
```rust
|
||||
impl From<StreamError> for HandlerError {
|
||||
fn from(e: StreamError) -> Self {
|
||||
match e {
|
||||
StreamError::ConnectionClosed => HandlerError::ConnectionClosed,
|
||||
StreamError::StreamClosed => {
|
||||
HandlerError::StreamError(io::Error::new(
|
||||
io::ErrorKind::ConnectionReset,
|
||||
"stream closed",
|
||||
))
|
||||
}
|
||||
StreamError::Timeout => {
|
||||
HandlerError::StreamError(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"stream timed out",
|
||||
))
|
||||
}
|
||||
StreamError::Internal(e) => HandlerError::StreamError(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This `From` impl is the canonical conversion — handler examples that use
|
||||
`.await?` on `accept_bi()` / `open_bi()` rely on it. The `StreamError` →
|
||||
`HandlerError::StreamError(io::Error)` mapping is lossy by design: the
|
||||
distinction between stream-level and connection-level errors is preserved
|
||||
in `StreamError`, but once a handler propagates via `HandlerError`, the
|
||||
endpoint treats all variants as "close the connection" (one-ALPN-per-
|
||||
connection, ADR-006).
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-17
|
||||
last_updated: 2026-06-22-17
|
||||
---
|
||||
|
||||
# Endpoint
|
||||
@@ -124,7 +124,7 @@ fn dispatch(connection) {
|
||||
match handlers.get(alpn) {
|
||||
Some(handler) => {
|
||||
let auth = AuthContext::from_connection(&connection);
|
||||
let conn = Connection::new(connection);
|
||||
let conn = Connection::from_quinn(connection); // or from_iroh
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handler.handle(conn, &auth).await {
|
||||
// log error, connection closes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-19
|
||||
last_updated: 2026-06-22-19
|
||||
---
|
||||
|
||||
# alknet-vault
|
||||
@@ -38,8 +38,10 @@ cross the network.
|
||||
| ADR | Title | Relevance |
|
||||
|-----|-------|-----------|
|
||||
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-vault's standalone position |
|
||||
| [006](../../decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention | ALPN versioning pattern for potential `alknet/vault/v2` |
|
||||
| [005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc as Call Protocol Foundation | VaultProtocol uses irpc directly |
|
||||
| [008](../../decisions/008-secret-service-integration.md) | Vault Integration Point | CLI-embedded, capability source |
|
||||
| [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Ed25519 as default curve for TLS raw key identity |
|
||||
| [014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Capabilities carry vault-derived material |
|
||||
| [018](../../decisions/018-vault-standalone-crate.md) | Vault as Standalone Crate | Zero alknet crate dependencies |
|
||||
| [019](../../decisions/019-vault-assembly-layer-only.md) | Vault Assembly-Layer-Only Access | The assembly layer is the sole caller |
|
||||
@@ -101,6 +103,21 @@ the full list.
|
||||
depth measure, not the primary control — the primary control is that
|
||||
`DerivedKey` never crosses the call protocol wire (ADR-014).
|
||||
|
||||
## Known Source Drift
|
||||
|
||||
The vault crate carries over source from the POC. The following items are
|
||||
known divergences between the current source and the spec. All must be
|
||||
corrected during implementation sync. This table is the single source of
|
||||
truth for drift tracking — if an item is fixed in source, update this table.
|
||||
|
||||
| # | Item | Current source behavior | Target behavior (per spec) | Source location | Spec reference |
|
||||
|---|------|------------------------|-----------------------------|-----------------|----------------|
|
||||
| 1 | IV generation | `rand::random()` | `OsRng` (CSPRNG) | `encryption.rs` L133 | [encryption.md → Security Constraints](encryption.md#security-constraints), [service.md → Security Constraints](service.md#security-constraints) |
|
||||
| 2 | RwLock `unwrap()` | `unwrap()` on every `RwLock` acquisition (L142, 161, 182, 191, 196, 227, 264, 307, 340, 367) | `unwrap_or_else(\|e\| e.into_inner())` for poisoned lock recovery | `service.rs` (see line numbers) | [service.md → Security Constraints](service.md#security-constraints) |
|
||||
| 3 | `CURRENT_KEY_VERSION` | `1` (HD-derived, but v1 is reserved for TS PBKDF2 legacy per ADR-020) | `2` (HD-derived, per ADR-020) | `encryption.rs` | [encryption.md → Key Versioning](encryption.md#key-versioning), [ADR-020](../../decisions/020-hd-derivation-for-encryption-keys.md) |
|
||||
| 4 | `spawn()` return value | Returns a fresh, unspawned `VaultServiceActor` as the second tuple element (the spawned actor is consumed by `run`) | Either drop the second return value (return only `Client<VaultProtocol>`) or restructure so the returned actor is the one that was spawned | `service.rs` `VaultServiceActor::spawn()` | [service.md → Actor Dispatch](service.md#actor-dispatch) |
|
||||
| 5 | `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) |
|
||||
|
||||
## Public API
|
||||
|
||||
The vault re-exports its primary types from the crate root:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-19
|
||||
last_updated: 2026-06-22-19
|
||||
---
|
||||
|
||||
# Mnemonic and Key Derivation
|
||||
@@ -201,6 +201,7 @@ Helper functions construct parameterized paths:
|
||||
```rust
|
||||
pub fn device_path(index: u32) -> String; // m/74'/0'/0'/{index}'
|
||||
pub fn site_password_path(site_hash: &str) -> String; // m/74'/1'/0'/{site_hash}'
|
||||
pub fn encryption_path_for_version(version: u32) -> String; // m/74'/2'/0'/{version-2}'
|
||||
```
|
||||
|
||||
### Path semantics
|
||||
@@ -214,14 +215,6 @@ pub fn site_password_path(site_hash: &str) -> String; // m/74'/1'/0'/{site_hash}
|
||||
| `m/74'/2'/0'/0'` | Encryption key for external credentials | AES-256-GCM | Credential encryption (v2, see [encryption.md](encryption.md)) |
|
||||
| `m/44'/60'/0'/0/0` | Ethereum signing key | secp256k1 | Ethereum signing (feature-gated) |
|
||||
|
||||
Helper functions construct parameterized paths:
|
||||
|
||||
```rust
|
||||
pub fn device_path(index: u32) -> String; // m/74'/0'/0'/{index}'
|
||||
pub fn site_password_path(site_hash: &str) -> String; // m/74'/1'/0'/{site_hash}'
|
||||
pub fn encryption_path_for_version(version: u32) -> String; // m/74'/2'/0'/{version-2}'
|
||||
```
|
||||
|
||||
`encryption_path_for_version` maps a key version to its derivation path
|
||||
(ADR-021). v2 (current) maps to `m/74'/2'/0'/0'` (which is `PATHS::ENCRYPTION`);
|
||||
v3 maps to `m/74'/2'/0'/1'`; etc. This is the rotation mechanism — each
|
||||
|
||||
@@ -16,19 +16,27 @@ iroh's `ProtocolHandler` trait demonstrates this: it takes a bidirectional QUIC
|
||||
|
||||
A single `ProtocolHandler` trait replaces both `StreamInterface` and `MessageInterface`:
|
||||
|
||||
> **Note**: The signature below was revised by ADR-007. The `handle()` method
|
||||
> now receives a `Connection` (not a `BiStream`) — see ADR-007 for the
|
||||
> current authoritative signature. The original signature is retained here
|
||||
> for historical context.
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait ProtocolHandler: Send + Sync + 'static {
|
||||
/// The ALPN string this handler claims (e.g. b"alknet/ssh")
|
||||
fn alpn(&self) -> &'static [u8];
|
||||
|
||||
/// Handle an incoming bidirectional QUIC stream
|
||||
async fn handle(&self, stream: BiStream, auth: &AuthContext) -> Result<(), HandlerError>;
|
||||
/// Handle an incoming connection (revised by ADR-007 to receive
|
||||
/// `Connection` instead of `BiStream`)
|
||||
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError>;
|
||||
}
|
||||
```
|
||||
|
||||
- `alpn()` returns a static byte string — the handler's ALPN identifier
|
||||
- `handle()` receives a `BiStream` (a joined `(SendStream, RecvStream)` implementing `AsyncRead + AsyncWrite`) and an `AuthContext` carrying the authenticated identity, and returns `HandlerError` on failure
|
||||
- `handle()` receives a `Connection` (revised by ADR-007 from the original
|
||||
`BiStream`) and an `AuthContext` carrying the authenticated identity, and
|
||||
returns `HandlerError` on failure
|
||||
- Every handler manages its own wire format — no shared framing, no StreamInterface/MessageInterface split
|
||||
- The `ListenerConfig` enum is eliminated — ALPN advertisement configuration replaces it
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ The workspace decomposes into the following crates:
|
||||
|
||||
| Crate | Responsibility | Depends on |
|
||||
|-------|---------------|------------|
|
||||
| `alknet-core` | ProtocolHandler trait, ALPN router, endpoint, BiStream, AuthContext, IdentityProvider, config, ArcSwap dynamic config | tokio, quinn, rustls, irpc |
|
||||
| `alknet-core` | ProtocolHandler trait, ALPN router, endpoint, BiStream, AuthContext, IdentityProvider, config, ArcSwap dynamic config | tokio, quinn, rustls, irpc, iroh (feature-gated, added by ADR-010) |
|
||||
| `alknet-vault` | Local key vault: BIP39/SLIP-0010/AES-GCM key derivation, encryption, VaultProtocol dispatch | (standalone, no alknet-core) |
|
||||
| `alknet-ssh` | SshAdapter (russh, SOCKS5, port forwarding) | alknet-core, russh |
|
||||
| `alknet-call` | CallAdapter (JSON-RPC via irpc, operation registry, pub/sub, access control, call protocol client, adapter traits) | alknet-core, irpc |
|
||||
|
||||
@@ -12,6 +12,13 @@ The ALPN dispatch model simplifies this: every handler receives the same `AuthCo
|
||||
|
||||
## Decision
|
||||
|
||||
> **Note**: The original text of this decision described the handler
|
||||
> "enriching or replacing" the `AuthContext`. This was superseded by
|
||||
> ADR-011, which made `AuthContext` immutable in `handle()` (passed as
|
||||
> `&AuthContext`). Handlers resolve identity into a local variable and
|
||||
> store it on `Connection` via `set_identity()`. The text below has been
|
||||
> updated to reflect the ADR-011 model.
|
||||
|
||||
Authentication and identity resolution live in `alknet-core` as shared infrastructure. Each handler presents credentials differently, but all resolve through the same `IdentityProvider`:
|
||||
|
||||
```rust
|
||||
@@ -36,7 +43,7 @@ Auth resolution is **hybrid** — the endpoint resolves what it can, and handler
|
||||
|
||||
1. **Endpoint-level resolution** (before `handle()` is called): If the TLS handshake provides a client certificate, the endpoint resolves the fingerprint to an `Identity` and passes it in `AuthContext`. This is the case for SSH (where the key exchange happens at the protocol level, but the TLS layer may also provide information).
|
||||
|
||||
2. **Handler-level resolution** (inside `handle()`): For protocols that carry credentials in application frames (AuthToken in the first call frame, Bearer header in HTTP), the handler extracts the credential from the stream and calls `IdentityProvider` to resolve it. The handler then enriches or replaces the partial `AuthContext` with the fully resolved `Identity`.
|
||||
2. **Handler-level resolution** (inside `handle()`): For protocols that carry credentials in application frames (AuthToken in the first call frame, Bearer header in HTTP), the handler extracts the credential from the stream and calls `IdentityProvider` to resolve it. The handler then resolves the `Identity` into a local variable and stores it on the `Connection` via `set_identity()` for observability — it does **not** mutate the `AuthContext` (which is passed as `&AuthContext`, an immutable reference — see ADR-011). The per-request identity (for ACL) is resolved separately by the `CallAdapter` at `call.requested` time.
|
||||
|
||||
The `AuthContext` passed to `handle()` may be partial — containing only transport-level information if no TLS client certificate was provided. Handlers must not assume `AuthContext` contains a fully resolved `Identity`. Each handler knows its own credential extraction protocol and is responsible for completing authentication.
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ fn dispatch(connection) {
|
||||
match handler {
|
||||
Some(h) => {
|
||||
auth = AuthContext::from_connection(&connection)
|
||||
conn = Connection::new(connection)
|
||||
conn = Connection::from_quinn(connection) // or from_iroh
|
||||
tokio::spawn(h.handle(conn, &auth))
|
||||
}
|
||||
None => connection.close()
|
||||
|
||||
@@ -113,15 +113,23 @@ enumerate the internal call tree.
|
||||
|
||||
### 3. Handler identity is carried on OperationContext
|
||||
|
||||
`OperationContext` carries both the caller's identity (who invoked me) and the
|
||||
handler's identity (who am I acting as):
|
||||
> **Note**: This decision's `handler_identity: Option<Identity>` type was
|
||||
> superseded by ADR-022, which replaced `Identity` with
|
||||
> `CompositionAuthority` — a declared authority bundle that is not a peer
|
||||
> identity and is not resolvable through `IdentityProvider`. The core
|
||||
> decision (authority switch, not ACL skip) holds unchanged. See ADR-022
|
||||
> Decision 2 for the current type.
|
||||
|
||||
`OperationContext` carries both the caller's identity (who invoked me) and
|
||||
the handler's identity (who am I acting as):
|
||||
|
||||
```rust
|
||||
pub struct OperationContext {
|
||||
pub request_id: String,
|
||||
pub parent_request_id: Option<String>,
|
||||
pub identity: Option<Identity>, // Caller's identity (inbound)
|
||||
pub handler_identity: Option<Identity>, // Handler's identity (composition authority)
|
||||
// Type changed to Option<CompositionAuthority> by ADR-022:
|
||||
pub handler_identity: Option<CompositionAuthority>, // Handler's composition authority
|
||||
pub capabilities: Capabilities,
|
||||
pub metadata: HashMap<String, Value>,
|
||||
pub env: OperationEnv,
|
||||
@@ -273,11 +281,14 @@ Principle of least privilege.
|
||||
is more important than debuggability from the wire.
|
||||
|
||||
6. **The handler identity is a full `Identity` (with scopes), not a special
|
||||
principal type.** This reuses the existing `Identity` type and `IdentityProvider`
|
||||
infrastructure (ADR-004). If handler identities need different resolution
|
||||
semantics (e.g., not resolvable through `IdentityProvider`), a separate type
|
||||
may be needed. The assumption is that the existing identity infrastructure
|
||||
suffices.
|
||||
principal type.** ~~This reuses the existing `Identity` type and
|
||||
`IdentityProvider` infrastructure (ADR-004).~~ **Superseded by ADR-022
|
||||
Decision 2**: composition authority is a declared authority bundle
|
||||
(`CompositionAuthority`), not a peer `Identity`. It is not resolvable
|
||||
through `IdentityProvider` and does not represent an inbound caller. The
|
||||
distinction is necessary because a handler is not a network peer — its
|
||||
authority is declared by the assembly layer at registration, not resolved
|
||||
from credentials.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ abort — the handler that composes the child knows that, not the wire caller.
|
||||
Putting the policy on the wire payload would give the wire caller control
|
||||
over internal composition behavior it can't see.
|
||||
|
||||
**Why not per-operation declaration**: ADR-016 Assumption 5 says the policy
|
||||
**Why not per-operation declaration**: Assumption 5 says the policy
|
||||
is per-call, not per-operation. The same operation may need
|
||||
`abort-dependents` in one composition context and `continue-running` in
|
||||
another. A static property on `OperationSpec` can't express that.
|
||||
|
||||
@@ -113,10 +113,11 @@ pub async fn from_call(
|
||||
The adapter:
|
||||
1. Calls `services/list` on the remote node → gets the list of `External`
|
||||
operations
|
||||
2. Calls `services/schema` for each → gets the input/output JSON Schemas
|
||||
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:
|
||||
- The spec mirrors the remote operation's name, namespace, type, schemas,
|
||||
and access control
|
||||
- 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
|
||||
@@ -272,8 +273,8 @@ same as `from_openapi` receives HTTP credentials.
|
||||
remote call failures are handled the same as local handler failures.
|
||||
|
||||
4. **`from_call`-registered operations mirror the remote spec.** The imported
|
||||
`OperationSpec` has the same name, namespace, type, schemas, and access
|
||||
control as the remote operation. If the remote operation changes (new
|
||||
`OperationSpec` has the same name, namespace, type, schemas (input, output,
|
||||
and error_schemas per ADR-023), and access control as the remote operation. If the remote operation changes (new
|
||||
schema, renamed), the imported spec is stale until re-import. The
|
||||
assumption is that re-import happens on reconnection or is triggered
|
||||
explicitly. Hot-swapping imported specs is a two-way door.
|
||||
|
||||
@@ -123,9 +123,9 @@ impl VaultServiceHandle {
|
||||
```
|
||||
|
||||
`decrypt` now derives the key at the path **indicated by
|
||||
`encrypted.key_version`** — not always at `PATHS::ENCRYPTION`. This is
|
||||
the fix for the W1 drift issue from the vault review: the current source
|
||||
ignores `key_version` for key selection; the spec now makes it functional.
|
||||
`encrypted.key_version`** — not always at `PATHS::ENCRYPTION`. This corrects
|
||||
a source drift: the current source ignores `key_version` for key selection;
|
||||
the spec now makes it functional.
|
||||
|
||||
### 3. `rotate` method
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
@@ -120,7 +120,8 @@ pub enum OperationProvenance {
|
||||
FromOpenAPI,
|
||||
/// MCP forwarding stub (from_mcp), leaf — cannot compose.
|
||||
FromMCP,
|
||||
/// QUIC forwarding stub (from_call), leaf locally — cannot compose.
|
||||
/// QUIC forwarding stub (from_call). Leaf in the local registry —
|
||||
/// forwards calls to a remote node; cannot compose locally.
|
||||
FromCall,
|
||||
/// JSON Schema definition (from_jsonschema), no handler — schema only.
|
||||
FromJsonSchema,
|
||||
@@ -134,7 +135,7 @@ pub enum OperationProvenance {
|
||||
| `Local` | Yes | Yes — scopes set by assembly layer | External or Internal (assembly declares) | Trusted code |
|
||||
| `FromOpenAPI` | No (leaf) | No | Internal | HTTP endpoint trusted; handler is a forwarding stub |
|
||||
| `FromMCP` | No (leaf) | No | Internal | MCP server trusted; handler is a forwarding stub |
|
||||
| `FromCall` | No (leaf locally) | No | Internal | Remote node trusted; handler is a forwarding stub |
|
||||
| `FromCall` | No (leaf in local registry) | No | Internal | Remote node trusted; handler is a forwarding stub |
|
||||
| `FromJsonSchema` | N/A (no handler) | No | N/A | N/A |
|
||||
| `Session` | Yes (within sandbox) | Yes — scopes set by assembly layer at sandbox creation | Internal always | Untrusted code in sandbox |
|
||||
|
||||
@@ -178,6 +179,24 @@ pub struct CompositionAuthority {
|
||||
/// handler can reach in composition.
|
||||
pub resources: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
impl CompositionAuthority {
|
||||
/// `None` — for leaves that don't compose (convenience for
|
||||
/// `composition_authority: CompositionAuthority::none()`).
|
||||
pub fn none() -> Option<Self> { None }
|
||||
|
||||
/// Construct a composition authority with the given label and scopes.
|
||||
pub fn new(
|
||||
label: &str,
|
||||
scopes: impl IntoIterator<Item = String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
label: label.to_string(),
|
||||
scopes: scopes.into_iter().collect(),
|
||||
resources: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This supersedes ADR-015's Assumption 6. ADR-015's core decision (authority
|
||||
@@ -212,6 +231,23 @@ pub struct ScopedOperationEnv {
|
||||
/// parameterized-dispatch attack surface.
|
||||
pub allowed_operations: HashSet<String>,
|
||||
}
|
||||
|
||||
impl ScopedOperationEnv {
|
||||
/// Empty set — for leaves that don't compose (no reachable operations).
|
||||
pub fn empty() -> Self {
|
||||
Self { allowed_operations: HashSet::new() }
|
||||
}
|
||||
|
||||
/// Construct from an iterable of operation names.
|
||||
pub fn new(ops: impl IntoIterator<Item = String>) -> Self {
|
||||
Self { allowed_operations: ops.into_iter().collect() }
|
||||
}
|
||||
|
||||
/// Returns true if the given operation name is reachable.
|
||||
pub fn allows(&self, name: &str) -> bool {
|
||||
self.allowed_operations.contains(name)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. The registration bundle carries all three
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-20
|
||||
last_updated: 2026-06-22-20
|
||||
---
|
||||
|
||||
# Alknet Overview
|
||||
@@ -222,7 +222,7 @@ Open questions are tracked in [open-questions.md](open-questions.md). Key questi
|
||||
- **OQ-01**: BiStream type definition (resolved: trait, Connection parameter — see ADR-007)
|
||||
- **OQ-02**: AuthContext resolution timing (resolved: hybrid — see ADR-004)
|
||||
- **OQ-03**: ALPN string naming convention (resolved: see ADR-006)
|
||||
- **OQ-04**: Dynamic handler registration at runtime vs static at startup (two-way door, defer to implementation)
|
||||
- **OQ-04**: Dynamic handler registration (resolved: static at startup — see ADR-010)
|
||||
- **OQ-08**: Vault integration point (resolved: CLI-embedded, assembly-layer only — see ADR-008, ADR-014, ADR-018, ADR-019)
|
||||
- **OQ-16**: Safe vault operations for call protocol exposure (resolved: none for now — see ADR-014)
|
||||
- **OQ-20**: Encryption key derivation (resolved: HD derivation, not PBKDF2 — see ADR-020)
|
||||
@@ -234,11 +234,11 @@ Open questions are tracked in [open-questions.md](open-questions.md). Key questi
|
||||
| Failure | Behavior |
|
||||
|---------|----------|
|
||||
| ALPN negotiation fails (no intersection) | TLS handshake fails — correct behavior, the client and server have no protocol in common |
|
||||
| Handler `handle()` returns `HandlerError` | Endpoint logs the error, closes the QUIC stream. Other streams on the same connection are unaffected |
|
||||
| Handler panics | The handler's task is caught by tokio's panic handling. The stream is closed. Other streams and connections are unaffected |
|
||||
| `IdentityProvider` returns `None` | AuthContext is partial. If the handler requires authentication and cannot extract credentials from the stream, it closes the stream with an auth error |
|
||||
| Handler `handle()` returns `HandlerError` | Endpoint logs the error, closes the QUIC connection. Other connections are unaffected |
|
||||
| Handler panics | The handler's task is caught by tokio's panic handling. The connection is dropped. Other connections are unaffected |
|
||||
| `IdentityProvider` returns `None` | AuthContext is partial. If the handler requires authentication and cannot extract credentials from the stream, it closes the connection with an auth error |
|
||||
| Config reload fails | `ArcSwap<DynamicConfig>` keeps the previous valid config. Error is logged. No service interruption |
|
||||
| BiStream read/write error | QUIC stream-level error. The handler detects this as an I/O error and returns from `handle()`. The connection itself may remain open for other streams |
|
||||
| BiStream read/write error | QUIC stream-level error. The handler detects this as an I/O error and returns from `handle()`. The connection itself may remain open for other streams — but since each handler owns a full `Connection` (one ALPN per connection, ADR-006), a stream error typically causes the handler to return, closing the connection |
|
||||
|
||||
## What Stays from the Previous Implementation
|
||||
|
||||
|
||||
Reference in New Issue
Block a user