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:
2026-06-22 05:46:37 +00:00
parent 8f8a8a48f9
commit c62a6adc7b
21 changed files with 257 additions and 66 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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