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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
## Status
Proposed
Accepted
## Context

View File

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