diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 9bb1ba3..2f42cef 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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 diff --git a/docs/architecture/crates/call/README.md b/docs/architecture/crates/call/README.md index 8e599c3..4aeae76 100644 --- a/docs/architecture/crates/call/README.md +++ b/docs/architecture/crates/call/README.md @@ -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 | diff --git a/docs/architecture/crates/call/call-protocol.md b/docs/architecture/crates/call/call-protocol.md index 211ec35..370ac69 100644 --- a/docs/architecture/crates/call/call-protocol.md +++ b/docs/architecture/crates/call/call-protocol.md @@ -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 } } diff --git a/docs/architecture/crates/call/operation-registry.md b/docs/architecture/crates/call/operation-registry.md index be7ad68..b734bef 100644 --- a/docs/architecture/crates/call/operation-registry.md +++ b/docs/architecture/crates/call/operation-registry.md @@ -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, 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 diff --git a/docs/architecture/crates/core/README.md b/docs/architecture/crates/core/README.md index 44ba405..dc14420 100644 --- a/docs/architecture/crates/core/README.md +++ b/docs/architecture/crates/core/README.md @@ -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 diff --git a/docs/architecture/crates/core/config.md b/docs/architecture/crates/core/config.md index 9621f7f..dc44a27 100644 --- a/docs/architecture/crates/core/config.md +++ b/docs/architecture/crates/core/config.md @@ -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, - /// 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, - /// API keys for token-based auth. pub api_keys: Vec, } ``` -`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` (russh types) | `HashSet` (fingerprint strings) | | ListenerConfig | Enum with Stream/Http/Dns variants | Eliminated — single endpoint, ALPN dispatch | | TransportMode | Tcp/Tls/Iroh | Eliminated — always QUIC+TLS | diff --git a/docs/architecture/crates/core/core-types.md b/docs/architecture/crates/core/core-types.md index 0bc4e0f..aa29ffb 100644 --- a/docs/architecture/crates/core/core-types.md +++ b/docs/architecture/crates/core/core-types.md @@ -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 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 | diff --git a/docs/architecture/crates/core/endpoint.md b/docs/architecture/crates/core/endpoint.md index 03a3a53..9a4037d 100644 --- a/docs/architecture/crates/core/endpoint.md +++ b/docs/architecture/crates/core/endpoint.md @@ -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 diff --git a/docs/architecture/crates/vault/README.md b/docs/architecture/crates/vault/README.md index e028281..4455d80 100644 --- a/docs/architecture/crates/vault/README.md +++ b/docs/architecture/crates/vault/README.md @@ -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`) 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: diff --git a/docs/architecture/crates/vault/mnemonic-derivation.md b/docs/architecture/crates/vault/mnemonic-derivation.md index 5da83be..cef2179 100644 --- a/docs/architecture/crates/vault/mnemonic-derivation.md +++ b/docs/architecture/crates/vault/mnemonic-derivation.md @@ -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 diff --git a/docs/architecture/decisions/002-protocol-handler-trait.md b/docs/architecture/decisions/002-protocol-handler-trait.md index 9b331be..c6e53a1 100644 --- a/docs/architecture/decisions/002-protocol-handler-trait.md +++ b/docs/architecture/decisions/002-protocol-handler-trait.md @@ -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 diff --git a/docs/architecture/decisions/003-crate-decomposition.md b/docs/architecture/decisions/003-crate-decomposition.md index ff9e825..d3cd683 100644 --- a/docs/architecture/decisions/003-crate-decomposition.md +++ b/docs/architecture/decisions/003-crate-decomposition.md @@ -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 | diff --git a/docs/architecture/decisions/004-auth-as-shared-core.md b/docs/architecture/decisions/004-auth-as-shared-core.md index 3a6a725..f59f836 100644 --- a/docs/architecture/decisions/004-auth-as-shared-core.md +++ b/docs/architecture/decisions/004-auth-as-shared-core.md @@ -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. diff --git a/docs/architecture/decisions/010-alpn-router-and-endpoint.md b/docs/architecture/decisions/010-alpn-router-and-endpoint.md index 5b1e954..b391375 100644 --- a/docs/architecture/decisions/010-alpn-router-and-endpoint.md +++ b/docs/architecture/decisions/010-alpn-router-and-endpoint.md @@ -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() diff --git a/docs/architecture/decisions/015-privilege-model-and-authority-context.md b/docs/architecture/decisions/015-privilege-model-and-authority-context.md index 01cb1d4..0a72169 100644 --- a/docs/architecture/decisions/015-privilege-model-and-authority-context.md +++ b/docs/architecture/decisions/015-privilege-model-and-authority-context.md @@ -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` 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, pub identity: Option, // Caller's identity (inbound) - pub handler_identity: Option, // Handler's identity (composition authority) + // Type changed to Option by ADR-022: + pub handler_identity: Option, // Handler's composition authority pub capabilities: Capabilities, pub metadata: HashMap, 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 diff --git a/docs/architecture/decisions/016-abort-cascade-for-nested-calls.md b/docs/architecture/decisions/016-abort-cascade-for-nested-calls.md index 8c2d88a..5e92345 100644 --- a/docs/architecture/decisions/016-abort-cascade-for-nested-calls.md +++ b/docs/architecture/decisions/016-abort-cascade-for-nested-calls.md @@ -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. diff --git a/docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md b/docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md index a8ffcbe..ffa92b2 100644 --- a/docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md +++ b/docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md @@ -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. diff --git a/docs/architecture/decisions/021-key-rotation-via-version-indexed-paths.md b/docs/architecture/decisions/021-key-rotation-via-version-indexed-paths.md index 44df886..4521e6a 100644 --- a/docs/architecture/decisions/021-key-rotation-via-version-indexed-paths.md +++ b/docs/architecture/decisions/021-key-rotation-via-version-indexed-paths.md @@ -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 diff --git a/docs/architecture/decisions/022-handler-registration-provenance-and-composition-authority.md b/docs/architecture/decisions/022-handler-registration-provenance-and-composition-authority.md index b44271f..4d144ff 100644 --- a/docs/architecture/decisions/022-handler-registration-provenance-and-composition-authority.md +++ b/docs/architecture/decisions/022-handler-registration-provenance-and-composition-authority.md @@ -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>, } + +impl CompositionAuthority { + /// `None` — for leaves that don't compose (convenience for + /// `composition_authority: CompositionAuthority::none()`). + pub fn none() -> Option { None } + + /// Construct a composition authority with the given label and scopes. + pub fn new( + label: &str, + scopes: impl IntoIterator, + ) -> 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, } + +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) -> 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 diff --git a/docs/architecture/decisions/023-operation-error-schemas.md b/docs/architecture/decisions/023-operation-error-schemas.md index 1317027..301ff0f 100644 --- a/docs/architecture/decisions/023-operation-error-schemas.md +++ b/docs/architecture/decisions/023-operation-error-schemas.md @@ -2,7 +2,7 @@ ## Status -Proposed +Accepted ## Context diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 809a23a..39a5e5e 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -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` 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