diff --git a/docs/architecture/README.md b/docs/architecture/README.md index a20c564..89b62e2 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1,15 +1,15 @@ --- status: draft -last_updated: 2026-06-17 +last_updated: 2026-06-18 --- # Alknet Architecture ## Current State -**Pre-implementation.** The project has completed a pivot from a three-layer model to an ALPN-as-service model. The greenfield workspace contains only `alknet-vault` (stable) and research/reference material. Foundational ADRs (001–013) are in place, including the BiStream type definition (ADR-007), vault integration (ADR-008), ALPN router/endpoint (ADR-010), AuthContext structure (ADR-011), call protocol stream model (ADR-012), and Rust as canonical implementation language (ADR-013). The alknet-core and alknet-call crate specs are in draft. +**Pre-implementation.** The project has completed a pivot from a three-layer model to an ALPN-as-service model. The greenfield workspace contains only `alknet-vault` (stable) and research/reference material. Foundational ADRs (001–014) are in place, including the BiStream type definition (ADR-007), vault integration (ADR-008), ALPN router/endpoint (ADR-010), AuthContext structure (ADR-011), call protocol stream model (ADR-012), Rust as canonical implementation language (ADR-013), and secret material flow with capability injection (ADR-014). The alknet-core and alknet-call crate specs are in draft. -**Next step**: Review alknet-call spec documents, then begin implementation. OQ-11 (handler-level auth resolution observability) will be resolved during implementation. +**Next step**: Review alknet-call spec documents, then begin implementation. OQ-11 (handler-level auth resolution observability) and OQ-15 (call protocol client and adapter contract) will be resolved during implementation. ## Architecture Documents @@ -23,8 +23,8 @@ last_updated: 2026-06-17 | [crates/core/auth.md](crates/core/auth.md) | draft | AuthContext, Identity, IdentityProvider, AuthToken, resolution flow | | [crates/core/config.md](crates/core/config.md) | draft | StaticConfig, DynamicConfig, ArcSwap, ConfigReloadHandle | | [crates/call/README.md](crates/call/README.md) | draft | alknet-call crate index | -| [crates/call/call-protocol.md](crates/call/call-protocol.md) | draft | CallAdapter, EventEnvelope framing, stream model, PendingRequestMap, bidirectional calls | -| [crates/call/operation-registry.md](crates/call/operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, irpc integration | +| [crates/call/call-protocol.md](crates/call/call-protocol.md) | draft | CallAdapter, EventEnvelope framing, stream model, PendingRequestMap, bidirectional calls, streaming subscribe example | +| [crates/call/operation-registry.md](crates/call/operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, capability injection, service discovery, irpc integration | ## ADR Table @@ -43,6 +43,7 @@ last_updated: 2026-06-17 | [011](decisions/011-authcontext-structure.md) | AuthContext Structure and Resolution Flow | Accepted | | [012](decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | Accepted | | [013](decisions/013-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Accepted | +| [014](decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Accepted | ## Open Questions @@ -54,7 +55,8 @@ See [open-questions.md](open-questions.md) for the full tracker. - **OQ-03**: ALPN naming — `alknet/` prefix, no version (ADR-006) - **OQ-05**: Multi-connectivity endpoint — quinn + iroh, both feature-gated (ADR-010) - **OQ-06**: ALPN per connection, not per stream (ADR-006) -- **OQ-08**: Vault integration — CLI-embedded via call protocol (ADR-008) +- **OQ-08**: Vault integration — CLI-embedded, assembly-layer only (ADR-008, ADR-014) +- **OQ-16**: Safe vault operations for call protocol exposure — none for now (ADR-014) **Resolved two-way doors:** - **OQ-04**: Dynamic handler registration — static at startup (ADR-010) @@ -67,7 +69,7 @@ See [open-questions.md](open-questions.md) for the full tracker. - **OQ-11**: Handler-level auth resolution observability — decide during implementation **Open one-way doors (need ADR before implementation):** -- **OQ-15**: Call protocol client and adapter contract — alknet-call needs both the server (CallAdapter) and client (call invocation over QUIC), plus the adapter contract traits (from_*, to_*) that enable composition +- **OQ-15**: Call protocol client and adapter contract — alknet-call needs both the server (CallAdapter) and client (call invocation over QUIC), plus the adapter contract traits (from_*, to_*) that enable composition. ADR-014 constrains the adapter contract: adapters take credential sources from the assembly layer, not static tokens. **Deferred (not active):** - **OQ-09**: WASM target boundaries — design constraint, not deliverable diff --git a/docs/architecture/crates/call/README.md b/docs/architecture/crates/call/README.md index ae4179c..8556bbc 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-17 +last_updated: 2026-06-18 --- # alknet-call @@ -25,8 +25,10 @@ Structured RPC over QUIC: operations, request/response, streaming subscriptions, | [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 | | [007](../../decisions/007-bistream-type-definition.md) | BiStream Type Definition | CallAdapter receives Connection, not BiStream | -| [008](../../decisions/008-secret-service-integration.md) | Vault Integration Point | Vault operations exposed via call protocol | +| [008](../../decisions/008-secret-service-integration.md) | Vault Integration Point | Vault accessed at assembly layer, not on the wire | +| [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Static handler registration | | [012](../../decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | Bidirectional streams, EventEnvelope, ID-based correlation | +| [014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Call protocol carries no secret material; capabilities injected at assembly layer | ## Relevant Open Questions @@ -35,12 +37,15 @@ Structured RPC over QUIC: operations, request/response, streaming subscriptions, | OQ-07 | Call protocol scope within a connection | resolved (ADR-012) | Stream model, multiplexing, scope | | OQ-13 | Operation path format and routing scope | resolved | `/{service}/{op}` is the correct design; remote dispatch is a separate layer | | OQ-14 | Batch operation semantics | resolved | Correlated `call.requested` events is the correct protocol design | +| OQ-15 | Call protocol client and adapter contract | open | ADR-014 constrains adapters: credential sources, not static tokens | +| OQ-16 | Safe vault operations for call protocol exposure | resolved (ADR-014) | None exposed for now | ## Key Design Principles 1. **One connection, full access**: An `alknet/call` connection gives access to the entire operation registry — calls, subscriptions, batch, schema. 2. **Protocol is symmetric**: Both sides can initiate calls. The server calling a client uses the same EventEnvelope format and correlation. 3. **Stream-agnostic correlation**: PendingRequestMap correlates by request ID, not by stream. The protocol works with any stream arrangement. -4. **Operation registry is dynamic**: Operations are registered at startup by the CLI binary. The registry supports JSON Schema discovery. -5. **irpc is one dispatch backend**: Local operations dispatch directly. irpc service calls (vault, auth) are internal. The call protocol is the external interface. -6. **Local dispatch only**: The operation registry dispatches to local handlers. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer, not a modification to alknet-call's path format. \ No newline at end of file +4. **Operation registry is static**: Operations are registered at startup by the CLI binary. The registry supports JSON Schema discovery. +5. **irpc is one dispatch backend**: Local operations dispatch directly. irpc service calls (in-process, type-safe) are internal. The call protocol is the external interface. +6. **Local dispatch only**: The operation registry dispatches to local handlers. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer, not a modification to alknet-call's path format. +7. **No secret material on the wire**: The call protocol carries no private keys, API keys, mnemonics, or decrypted credentials. Handlers receive outbound credentials through `OperationContext.capabilities`, injected at the assembly layer. See ADR-014. \ No newline at end of file diff --git a/docs/architecture/crates/call/call-protocol.md b/docs/architecture/crates/call/call-protocol.md index a2c748b..e7ec413 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-17 +last_updated: 2026-06-18 --- # Call Protocol @@ -158,6 +158,29 @@ The server calls client operations using the same `PendingRequestMap` and the sa This enables patterns where the server pushes notifications, requests configuration from the client, or orchestrates workflows that require the client to perform operations. +### Streaming Subscribe Example: LLM Chat + +The subscribe operation pattern maps naturally to LLM streaming. An agent handler exposing `/agent/chat` as a subscription receives a `call.requested` event and streams `call.responded` events back as the LLM generates tokens. The output payloads use a normalized streaming UI format (e.g., Vercel AI SDK UI chunks — text-delta, tool-input-delta, etc.): + +``` +Client Server (agent handler) + │ │ + │── open_bi() → stream ──────────────────────────────▶│ + │── call.requested { id: "c1", │ + │ operationId: "/agent/chat", │ + │ input: { messages, model } } │ + │ │ handler reads capabilities (API key) + │ │ handler makes HTTP request to LLM provider + │ │ handler normalizes provider SSE → UI chunks + │←─ call.responded { id: "c1", output: { type: "text-start", ... } } │ + │←─ call.responded { id: "c1", output: { type: "text-delta", delta: "Hel" } }│ + │←─ call.responded { id: "c1", output: { type: "text-delta", delta: "lo" } } │ + │←─ call.responded { id: "c1", output: { type: "text-end", ... } } │ + │←─ call.completed { id: "c1" } │ +``` + +The API key used for the outbound LLM HTTP request comes from `OperationContext.capabilities`, not from the call protocol input and not from environment variables. See ADR-014 and [operation-registry.md → Capability Injection](operation-registry.md#capability-injection). + ### PendingRequestMap Manages in-flight calls and subscriptions. Correlates `call.responded` events back to the original `call.requested`: @@ -255,6 +278,7 @@ Local dispatch produces `ResponseEnvelope` with no serialization overhead. The ` - Batch is not a protocol primitive — multiple `call.requested` events with correlated IDs provide equivalent semantics. See OQ-14. - The call protocol is transport-agnostic at the envelope level. The `EventEnvelope` framing can run over QUIC streams, WebSocket frames, or Worker `postMessage`. The `CallAdapter` is the QUIC-specific implementation. - `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer. See ADR-005 and OQ-13. +- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials, raw tokens) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. The wire format carries `serde_json::Value` and cannot enforce this at the type level — the constraint is architectural, enforced by the operation registry and by convention. Operations that need to share public key material use a dedicated operation that returns only the public component. See ADR-014. ## Design Decisions @@ -264,6 +288,8 @@ Local dispatch produces `ResponseEnvelope` with no serialization overhead. The ` | Call protocol stream model | [ADR-012](../../decisions/012-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation | | ALPN per connection | [ADR-006](../../decisions/006-alpn-convention-and-connection-model.md) | `alknet/call` is a distinct ALPN, one connection per ALPN | | ProtocolHandler receives Connection | [ADR-007](../../decisions/007-bistream-type-definition.md) | CallAdapter gets Connection, can accept/open multiple streams | +| Vault integration point | [ADR-008](../../decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time | +| Secret material flow | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Call protocol carries no secret material; capabilities injected at assembly layer | ## Open Questions @@ -271,6 +297,8 @@ See [open-questions.md](../../open-questions.md) for full details. - **OQ-13** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix. - **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive. +- **OQ-15** (open): Call protocol client and adapter contract. ADR-014 constrains the adapter contract: adapters take credential sources from the assembly layer, not static tokens. +- **OQ-16** (resolved by ADR-014): No vault operations are exposed over the call protocol for now. ## References diff --git a/docs/architecture/crates/call/operation-registry.md b/docs/architecture/crates/call/operation-registry.md index 96ff65d..93f446a 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-17 +last_updated: 2026-06-18 --- # Operation Registry @@ -31,8 +31,8 @@ Every registered operation has a spec that declares its name, type, schemas, and ```rust pub struct OperationSpec { - pub name: String, // e.g., "fs/readFile", "vault/derive" (no leading slash) - pub namespace: String, // e.g., "fs", "vault" + pub name: String, // e.g., "fs/readFile", "agent/chat" (no leading slash) + pub namespace: String, // e.g., "fs", "agent" pub op_type: OperationType, // Query, Mutation, Subscription pub input_schema: Value, // JSON Schema for input pub output_schema: Value, // JSON Schema for output @@ -41,14 +41,14 @@ pub struct OperationSpec { pub enum OperationType { Query, // Read-only, idempotent (e.g., "fs/readFile", "services/list") - Mutation, // Side effects (e.g., "bash/exec", "vault/unlock") - Subscription, // Streaming (e.g., "events/subscribe") + Mutation, // Side effects (e.g., "bash/exec", "github/authenticate") + Subscription, // Streaming (e.g., "agent/chat", "events/subscribe") } ``` -Operation names use slash-based paths without a leading slash, aligned with URL path conventions: `fs/readFile`, `vault/derive`, `services/list`. The leading slash is added when needed for display (`spec.path()` returns `/fs/readFile`) and for wire format (the `call.requested` payload uses `/fs/readFile`). See OQ-13 for the path format decision (single-node `service/op` vs head/worker `node/service/op`). +Operation names use slash-based paths without a leading slash, aligned with URL path conventions: `fs/readFile`, `agent/chat`, `services/list`. The leading slash is added when needed for display (`spec.path()` returns `/fs/readFile`) and for wire format (the `call.requested` payload uses `/fs/readFile`). See OQ-13 for the path format decision (single-node `service/op` vs head/worker `node/service/op`). -The `namespace` field is derived from the name: for `fs/readFile` it's `fs`, for `vault/derive` it's `vault`. It's a convenience accessor for ACL matching and service grouping. +The `namespace` field is derived from the name: for `fs/readFile` it's `fs`, for `agent/chat` it's `agent`. It's a convenience accessor for ACL matching and service grouping. ### AccessControl @@ -77,7 +77,7 @@ Operations with empty `AccessControl` (no required scopes, no resource checks) a pub type Handler = Arc Pin + Send>> + Send + Sync>; ``` -Handlers are async — many operations (vault key derivation, file I/O, irpc service calls) are inherently asynchronous. The handler receives an `async` runtime context and returns a `Future`. +Handlers are async — many operations (file I/O, HTTP service calls, irpc service calls) are inherently asynchronous. The handler receives an `async` runtime context and returns a `Future`. A handler receives: - `input: Value` — the deserialized `payload` from the `call.requested` event (always `serde_json::Value`) @@ -92,6 +92,7 @@ pub struct OperationContext { pub request_id: String, pub parent_request_id: Option, pub identity: Option, + pub capabilities: Capabilities, pub metadata: HashMap, pub env: OperationEnv, pub trusted: bool, @@ -100,11 +101,14 @@ pub struct OperationContext { - `request_id`: Correlates with the `call.requested` event's `id` field - `parent_request_id`: Set when this call was initiated by another operation (via `OperationEnv`) -- `identity`: The authenticated identity making the call (from `IdentityProvider`) -- `metadata`: Additional context (connection info, tracing IDs) +- `identity`: The authenticated identity making the call (from `IdentityProvider`) — inbound auth (who is calling me) +- `capabilities`: Outbound credentials the handler may use (decrypted API keys, scoped vault access) — see [Capability Injection](#capability-injection) below +- `metadata`: Additional context (connection info, tracing IDs). **Must not hold secret material** — see ADR-014 - `env`: The operation environment for composing calls to other operations - `trusted`: When `true`, ACL checks are skipped (set by `OperationEnv`, not by callers). The `trusted` field uses module-private construction — handlers construct `OperationContext` through `OperationEnv::invoke()` which sets `trusted: true`, or through the `CallAdapter` dispatch path which sets `trusted: false`. The field is not `pub` for writes; only `pub fn is_trusted(&self) -> bool` is exposed for reads. +`identity` and `capabilities` are orthogonal: identity is inbound (resolved per-request from the caller's credentials), capabilities are outbound (provisioned by the assembly layer from the vault). See ADR-014 for the full rationale. + ### OperationRegistry ```rust @@ -126,12 +130,11 @@ The `OperationRegistryBuilder` provides a fluent API for constructing the regist let registry = OperationRegistryBuilder::new() .with(services_list_spec(), Arc::new(services_list_handler)) .with(services_schema_spec(), Arc::new(schema_handler)) - .with(vault_derive_spec(), Arc::new(vault_derive_handler)) - .with(vault_unlock_spec(), Arc::new(vault_unlock_handler)) + .with(agent_chat_spec(), Arc::new(agent_chat_handler)) .build(); ``` -The CLI binary (or assembly layer) constructs the registry and passes it to the `CallAdapter`. Once built, the registry is immutable. +The CLI binary (or assembly layer) constructs the registry and passes it to the `CallAdapter`. Handlers are constructed with injected capabilities (see [Capability Injection](#capability-injection)) before registration. Once built, the registry is immutable. ### OperationEnv @@ -142,7 +145,7 @@ pub trait OperationEnv: Send + Sync { } ``` -`OperationEnv` is the universal composition mechanism. A handler calls `context.env.invoke("vault", "derive", input, &context)` and gets a `ResponseEnvelope` back — regardless of whether the operation runs locally, via an irpc service, or on a remote node. +`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 `parent` parameter propagates the calling context: the nested call gets `parent_request_id: Some(parent.request_id)`, inherits `parent.identity`, and is marked `trusted: true`. @@ -160,10 +163,11 @@ impl OperationEnv for LocalOperationEnv { let context = OperationContext { request_id: format!("env-{name}"), parent_request_id: Some(parent.request_id.clone()), - identity: parent.identity.clone(), // Inherit caller's identity - metadata: parent.metadata.clone(), // Inherit caller's metadata + identity: parent.identity.clone(), // Inherit caller's identity + capabilities: parent.capabilities.clone(), // Inherit caller's capabilities + metadata: parent.metadata.clone(), // Inherit caller's metadata env: self.clone(), - trusted: true, // Nested calls skip ACL + trusted: true, // Nested calls skip ACL }; self.registry.invoke(&name, input, context).await } @@ -189,7 +193,7 @@ These are read-only — no admin operations are exposed through the call protoco { "operations": [ { "name": "fs/readFile", "namespace": "fs", "op_type": "query" }, - { "name": "vault/derive", "namespace": "vault", "op_type": "mutation" }, + { "name": "agent/chat", "namespace": "agent", "op_type": "subscription" }, { "name": "events/subscribe", "namespace": "events", "op_type": "subscription" } ] } @@ -207,33 +211,70 @@ irpc and the operation registry serve different scopes: | irpc services (internal) | `VaultProtocol` derive macro, `Service` trait | postcard (binary) | Rust-to-Rust, in-process or in-cluster | | Local dispatch (in-process) | Direct function call through `OperationRegistry` | None | Same process | -The call protocol can wrap irpc services. When `/vault/derive` receives a `call.requested` event, the handler: -1. Deserializes the JSON payload -2. Calls `VaultProtocol::DeriveEd25519` via irpc (in-process, type-safe, postcard) -3. Serializes the result back to JSON -4. Returns `call.responded` on the stream +irpc services are an internal dispatch mechanism — they are not directly exposed on the call protocol. The vault's `VaultProtocol` uses irpc for in-process, type-safe dispatch via `VaultServiceHandle` (postcard serialization for in-cluster, direct calls for in-process). The vault is accessed by the assembly layer (CLI binary) at startup, not by handlers at call time. See ADR-008 and ADR-014. -This layering preserves irpc's type safety for internal calls while keeping the external interface cross-language. +If a handler internally uses an irpc-based service, the handler bridges the two: it receives JSON input from the call protocol, calls the irpc service in-process (postcard, type-safe), and serializes the result back to JSON for the call protocol response. This layering preserves irpc's type safety for internal calls while keeping the external interface cross-language. ### Operation Registration at Startup -The CLI binary (or assembly layer) registers operations before starting the endpoint: +The CLI binary (or assembly layer) constructs handlers with the credentials they need (from the vault — see [Capability Injection](#capability-injection)), then registers them before starting the endpoint: ```rust +// Assembly layer: unlock vault, derive credentials, construct handlers +let vault = VaultServiceHandle::new(); +vault.unlock(&mnemonic, passphrase.as_deref())?; +let google_api_key = vault.decrypt(&google_key_blob)?; +let github_signing_key = vault.derive_ed25519(PATHS::GITHUB_SIGNING)?; + +// Construct handlers with injected capabilities +let agent_handler = Arc::new(agent_chat_handler(Capabilities::new() + .with_api_key("google", google_api_key))); +let github_handler = Arc::new(github_authenticate_handler(Capabilities::new() + .with_signing_key(github_signing_key))); + +// Register operations — vault operations are NOT registered here let registry = OperationRegistryBuilder::new() // Built-in service discovery .with(services_list_spec(), Arc::new(services_list_handler)) - .with(services_schema_spec(), Arc::new(services_schema_handler)) - // Vault operations (exposed via call protocol, backed by irpc) - .with(vault_derive_spec(), Arc::new(vault_derive_handler)) - .with(vault_unlock_spec(), Arc::new(vault_unlock_handler)) - .with(vault_lock_spec(), Arc::new(vault_lock_handler)) + .with(services_schema_spec(), Arc::new(schema_handler)) + // Agent and GitHub handlers (constructed with injected capabilities) + .with(agent_chat_spec(), agent_handler) + .with(github_authenticate_spec(), github_handler) .build(); let call_adapter = CallAdapter::new(Arc::new(registry), identity_provider); ``` -The registry is immutable after construction. Adding operations requires restarting the process. This is consistent with OQ-04 and the `HandlerRegistry` model in alknet-core. +The vault is used at construction time, not registered as call protocol operations. The registry is immutable after construction. Adding operations requires restarting the process. This is consistent with OQ-04, ADR-008, and ADR-014. + +### Capability Injection + +Handlers that need outbound credentials (LLM provider API keys, signing keys, HTTP service tokens) receive them through the `Capabilities` type on `OperationContext`, not by calling vault operations over the wire and not from environment variables. This is the mechanism that ADR-008 described in prose ("derived keys and decrypted credentials are injected into operation contexts at the assembly layer") and that ADR-014 specifies as a one-way door. + +The flow is: + +``` +Assembly layer (CLI startup): + 1. Unlock vault (local, mnemonic from secure prompt or file) + 2. Derive / decrypt the credentials each handler needs + 3. Construct handlers with those credentials + 4. Register operations with the constructed handlers + 5. Start the endpoint + +Handler invocation (at call time): + call.requested → OperationContext { capabilities, identity, ... } + handler reads capabilities → uses the credential for its outbound call +``` + +The `Capabilities` type holds non-serializable, zeroized secret material. It does not implement `Serialize` — it cannot cross the call protocol wire even by accident. The concrete shape of the type (a typed map, a struct with named fields, a trait object) is a two-way door for implementation. The one-way constraints are fixed by ADR-014: + +- Capabilities are populated by the assembly layer at handler construction (the common case: a static decrypted API key) or scoped per-request for trusted-internal-only flows. They are never populated from call protocol inputs. +- Capabilities hold secret material that does not implement `Serialize` and does not appear in `EventEnvelope` payloads. +- The call protocol carries no secret material. See [call-protocol.md](call-protocol.md) for the wire-level constraint. + +**No vault operations are registered in the call protocol.** The vault is assembly-layer only (ADR-008, ADR-014). A handler that needs a child key for a specific operation (e.g., signing for GitHub auth) receives a scoped capability that performs the derivation in-process — it never holds the master seed and never calls a network-exposed vault operation. + +**Adapters take credential sources.** The `from_openapi` and `from_jsonschema` adapter patterns (see OQ-15, constrained by ADR-014) register HTTP-backed operations. The credential the HTTP service needs (bearer token, API key) is provided by the assembly layer at registration time — the adapter receives a credential source, not a static token string. This is the integration point where the vault feeds credentials into HTTP-backed operations, including LLM providers that expose OpenAPI-compatible endpoints. ## Constraints @@ -242,6 +283,8 @@ The registry is immutable after construction. Adding operations requires restart - `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer — not a prefix added to operation paths. irpc service dispatch is contracted but not built. - The call protocol does not depend on any database. Operation specs are in-memory, populated at startup. - `OperationContext.trusted` is set by `OperationEnv`, not by callers. A handler cannot mark its own call as trusted. +- **No vault operations are registered in the call protocol.** The vault is assembly-layer only (ADR-008, ADR-014). Handlers receive secret material through `OperationContext.capabilities`, not by calling vault operations over the wire. +- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. See ADR-014. ## Design Decisions @@ -250,7 +293,8 @@ The registry is immutable after construction. Adding operations requires restart | irpc as call protocol foundation | [ADR-005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc provides framing and service dispatch | | Call protocol stream model | [ADR-012](../../decisions/012-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation | | Static handler registration | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Registry is immutable after construction | -| Vault integration via call protocol | [ADR-008](../../decisions/008-secret-service-integration.md) | Vault ops exposed as call protocol operations | +| Vault integration via assembly layer | [ADR-008](../../decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time | +| Secret material flow and capability injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Capabilities carry outbound credentials; call protocol carries no secret material | ## Open Questions @@ -258,6 +302,8 @@ See [open-questions.md](../../open-questions.md) for full details. - **OQ-13** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix. - **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive. +- **OQ-15** (open): Call protocol client and adapter contract. ADR-014 constrains the adapter contract: adapters take credential sources from the assembly layer, not static tokens. +- **OQ-16** (resolved by ADR-014): No vault operations are exposed over the call protocol for now. ## References diff --git a/docs/architecture/decisions/003-crate-decomposition.md b/docs/architecture/decisions/003-crate-decomposition.md index 2ca70c4..ff9e825 100644 --- a/docs/architecture/decisions/003-crate-decomposition.md +++ b/docs/architecture/decisions/003-crate-decomposition.md @@ -47,7 +47,7 @@ alknet-call ← alknet-napi No handler crate depends on another handler crate. Cross-handler communication goes through the call protocol (alknet-call) or through alknet-core's endpoint. -alknet-agent depends on alknet-call (not alknet-core directly) because it uses the call protocol client for tool dispatch and the operation registry for tool registration. It retrieves LLM provider keys through alknet-call → alknet-vault (via the call protocol), never from environment variables. +alknet-agent depends on alknet-call (not alknet-core directly) because it uses the call protocol client for tool dispatch and the operation registry for tool registration. It receives LLM provider keys through capabilities injected at the assembly layer (from alknet-vault), never from environment variables and never over the call protocol. See ADR-008 and ADR-014. alknet-napi is a thin projection layer — it exposes the Rust call protocol client to Node.js via NAPI. It does not contain business logic or adapter implementations. See ADR-013. diff --git a/docs/architecture/decisions/008-secret-service-integration.md b/docs/architecture/decisions/008-secret-service-integration.md index b71af32..849ee6b 100644 --- a/docs/architecture/decisions/008-secret-service-integration.md +++ b/docs/architecture/decisions/008-secret-service-integration.md @@ -28,18 +28,21 @@ The vault is a capability source, not a service endpoint. Operations that need p ## Decision -**Option 4: CLI-embedded with call protocol exposure.** +**Option 4: CLI-embedded, assembly-layer only.** The CLI binary (the `alknet` crate) is the integration point. It: 1. Instantiates `VaultServiceHandle` locally at startup (or on-demand with Unlock/Lock lifecycle). -2. Registers vault operations (DeriveEd25519, DeriveEncryptionKey, Encrypt, Decrypt, etc.) in the call protocol's operation registry. -3. Other handlers access vault capabilities by calling operations on `alknet/call` — they don't import alknet-vault directly. +2. Derives and decrypts the credentials each handler needs. +3. Injects those credentials into handler capabilities at construction time. +4. Other handlers access vault-derived material through their `OperationContext.capabilities` — they don't import alknet-vault directly and don't call vault operations over the wire. **alknet-vault does NOT get its own ALPN.** Key derivation is a local operation — the master seed never crosses the network. If a remote node needs derived public keys (e.g., for identity verification), they're shared through the call protocol, not through direct vault access. **The vault is accessed at the assembly layer, not by individual handlers.** The CLI (or a configuration middleware it sets up) is the only component that talks to the vault directly. Derived keys and decrypted credentials are injected into operation contexts — handlers receive the material they need, not a vault reference. +**No vault operations are registered in the call protocol.** The vault is not exposed over the wire. This is the mechanism this ADR described in prose ("derived keys and decrypted credentials are injected into operation contexts at the assembly layer"); ADR-014 specifies it as a one-way door with explicit constraints. See ADR-014. + This is analogous to the reverse-proxy admin key pattern (ADR-028 in the reverse-proxy project): the proxy reads the key file once at startup, hashes it, and individual handlers never see the file. Here, the CLI unlocks the vault once at startup, and individual handlers receive the results of vault operations through their contexts. ## Consequences @@ -63,6 +66,7 @@ This is analogous to the reverse-proxy admin key pattern (ADR-028 in the reverse - ADR-003: Crate decomposition (alknet-vault is standalone) - ADR-005: irpc as call protocol foundation - ADR-009: One-way door decision framework -- OQ-08: Secret service integration point (resolved by this ADR) +- ADR-014: Secret material flow and capability injection (specifies the mechanism this ADR described in prose) +- OQ-08: Secret service integration point (resolved by this ADR, refined by ADR-014) - alknet-vault implementation: `crates/alknet-vault/` - Reverse-proxy ADR-028: Admin HTTP API (analogous key management pattern) \ No newline at end of file diff --git a/docs/architecture/decisions/013-rust-canonical-implementation.md b/docs/architecture/decisions/013-rust-canonical-implementation.md index cd2658f..943bf82 100644 --- a/docs/architecture/decisions/013-rust-canonical-implementation.md +++ b/docs/architecture/decisions/013-rust-canonical-implementation.md @@ -35,7 +35,7 @@ The relationship between the TypeScript and Rust implementations: | Adapter patterns (from_*, to_*) | alknet-call defines adapter traits and Rust implementations | Browser-adapted implementations where needed | | Call protocol client | alknet-call (QUIC) | alknet-napi (QUIC via NAPI) or browser SDK (WebTransport) | | LLM provider integration | alknet-agent (forked aisdk, simplified) | Not applicable | -| Provider key management | alknet-vault via call protocol (no env vars) | Not applicable | +| Provider key management | alknet-vault via assembly-layer capabilities (no env vars) | Not applicable | **The adapter contract (from_openapi, from_mcp, from_call, to_openapi, to_mcp) lives in Rust.** These patterns convert external specifications or protocols into `OperationSpec + Handler` pairs that register in the local `OperationRegistry`. The TypeScript implementations serve as reference for browser adaptations, not as the source of truth. diff --git a/docs/architecture/decisions/014-secret-material-flow-and-capability-injection.md b/docs/architecture/decisions/014-secret-material-flow-and-capability-injection.md new file mode 100644 index 0000000..e091092 --- /dev/null +++ b/docs/architecture/decisions/014-secret-material-flow-and-capability-injection.md @@ -0,0 +1,203 @@ +# ADR-014: Secret Material Flow and Capability Injection + +## Status + +Accepted + +## Context + +alknet-vault holds the master seed and can derive keys and encrypt/decrypt +arbitrary data. ADR-008 established that the vault is a **capability source**: +"derived keys and decrypted credentials are injected into operation contexts +at the assembly layer, not passed as vault references to handlers." That +prose was correct but the mechanism was never specified. + +The result was a contradiction in the spec documents. ADR-008 said the master +seed never crosses the network, but `operation-registry.md` showed +`vault/derive`, `vault/unlock`, and `vault/decrypt` registered as call protocol +operations — directly on the wire. Those two statements cannot both be true. +The contradiction arose because no injection mechanism existed in the +architecture, so the only way the docs could show a handler obtaining a key was +to expose vault operations over the call protocol. + +This is a one-way door. Once secret material crosses the wire as a call +protocol operation, the attack surface is permanent: + +- `vault/unlock` accepts a BIP39 mnemonic — the root of trust — over QUIC. A + compromised peer, a logging accident, a tracing span, and the seed is gone. +- `vault/derive` returns a `DerivedKey`. The type redacts the private key in + JSON today, but the operation's existence means a serialization change, a + binary codec addition, or a wrapper change would leak it. The surface is + the risk, not the current implementation. +- `vault/decrypt` accepts an encrypted blob and returns plaintext. Any + authorized caller can decrypt any blob they possess. + +The broader problem this decision addresses is structural: the industry +default for storing LLM provider keys, API tokens, and other credentials is +plaintext config files and environment variables (e.g., the aisdk Rust port +reads `std::env::var("GOOGLE_API_KEY")` and the example backend calls +`dotenv::dotenv()`). alknet replaces that with a vault. But the vault only +solves the storage problem; the flow problem — how decrypted material reaches +the code that needs it without crossing the network — requires its own +decision. + +There is a separate, second axis that the current `OperationContext` conflates +with the secret-flow problem. A handler has two orthogonal credential concerns: + +- **Identity (inbound)**: who is calling me? Resolved per-request from + `AuthContext` (TLS client cert, auth token). Already in `OperationContext`. +- **Capabilities (outbound)**: what secrets can I use for outbound calls? This + is the missing axis. A handler calling Google's API needs a decrypted Google + API key. That is not the caller's identity — it is the handler's own outbound + credential, provisioned by the assembly layer. + +Mixing these two into one channel (e.g., stuffing secrets into +`OperationContext.metadata: HashMap`) is a leak risk: metadata +propagates through nested calls via `OperationEnv::invoke()`, so a secret +placed there by one handler would flow to every downstream operation. + +## Decision + +**1. The vault is assembly-layer only.** + +The CLI binary (the `alknet` crate, or an embedded assembly layer) is the sole +component that talks to `VaultServiceHandle` directly. It unlocks the vault at +startup, derives and decrypts what each handler needs, and constructs handlers +with the results. No vault operation (`derive`, `decrypt`, `unlock`, `lock`) +is registered as a call protocol operation. The vault has no ALPN. The master +seed and derived private keys never enter the call protocol. + +**2. Capabilities are the injection mechanism.** + +A `Capabilities` type carries outbound secret material from the assembly layer +into handlers. Capabilities are distinct from identity (inbound auth) and +distinct from per-request metadata. The concrete shape of the `Capabilities` +type is a two-way door — to be decided during implementation of the +`alknet-call` crate. The one-way constraint is: + +- Capabilities hold non-serializable, zeroized secret material. They cannot + cross the call protocol wire even by accident — they are not + `serde_json::Value`, they do not implement `Serialize`, and they do not + appear in `EventEnvelope` payloads. +- Capabilities are injected at handler construction (the common case: a static + decrypted API key held for the handler's lifetime) or scoped per-request for + trusted-internal-only flows. They are never populated from call protocol + inputs. + +**3. The call protocol carries no secret material.** + +This is a wire-level constraint on the call protocol, not a handler-level +convention. Secret material (private keys, API keys, mnemonics, decrypted +credentials, raw tokens) must not appear in: + +- `call.requested` payloads (inputs) +- `call.responded` payloads (outputs) +- `OperationContext.metadata` + +The wire format does not enforce this — it carries `serde_json::Value` — so the +constraint is architectural, enforced by the operation registry and by +convention. Operations that need to share public key material (e.g., for +identity verification) use a dedicated operation that returns only the public +component, never the private key. + +**4. Adapters take credential sources, not static tokens.** + +The `from_openapi` and `from_jsonschema` adapter patterns (defined in Rust in +alknet-call per ADR-013) register HTTP-backed operations. The TypeScript +`@alkdev/operations` `from_openapi` takes `config.auth: { token: "..." }` — a +static string. The Rust adapters take a credential source wired to the assembly +layer (a resolver, a capability handle, or an injected secret), not a literal +token. This is the integration point where the vault feeds credentials into +HTTP-backed operations: the assembly layer decrypts the token at startup and +provides it to the adapter at registration time. + +**5. Handlers that need per-request vault access receive a scoped capability.** + +The common case (a static decrypted API key) is covered by construction-time +injection. A narrower case — a handler that derives a child key for a specific +operation (e.g., signing for GitHub authentication) — receives a +scoped capability that can only derive at a restricted path set. This is still +not a vault reference: it is a restricted handle that performs a specific +derivation and returns the result to the handler, in-process. The handler +never sees the master seed. Whether this scoped capability is a distinct type +or modeled as a pre-derived key injected at construction is a two-way door +left to the `alknet-call` and `alknet-agent` crate specs. + +## Consequences + +**Positive:** + +- The master seed and derived private keys never cross the network. The attack + surface for the root of trust is local-only. +- The `OperationContext` gains a clean second axis (capabilities) instead of + overloading `metadata` for secrets, preventing accidental propagation of + secret material through nested calls. +- Handlers that need outbound credentials (the agent handler calling an LLM + provider) receive them directly — no indirection through a `vault/derive` + call, no latency, no failure mode where the vault must be reachable at call + time. +- The adapter contract (OQ-15) gains a concrete shape: adapters take a + credential source from the assembly layer, not a static token. This makes + the `from_openapi` / `from_jsonschema` / `from_call` patterns safe by + construction. +- The model is structurally incompatible with the env-var / plaintext-config + default. There is no `std::env::var("API_KEY")` path — the only way a handler + gets a credential is through a capability, and the only way a capability is + populated is through the assembly layer from the vault. + +**Negative:** + +- The assembly layer (CLI binary) has more construction-time responsibility: it + must know which handlers need which credentials and wire them. This is + expected — the CLI assembles everything (ADR-008). +- Adding a new handler that needs a new credential requires updating the + assembly layer, not just registering an operation. This is a feature, not a + bug: it forces an explicit decision about what secret material a handler + needs. +- Remote vault administration (unlock a running node's vault over the network) + is not supported by this decision. If that capability is needed in the + future, it would require a separate, heavily restricted mechanism (admin + scope, mTLS-only, never expose the mnemonic over an unauthenticated channel) + and its own ADR. This decision does not close that door; it simply does not + open it. +- The `Capabilities` type shape is not fully specified here. The one-way + constraint (non-serializable, zeroized, injection-only) is fixed; the + concrete API is a two-way door for the `alknet-call` spec. + +## Assumptions + +These are the load-bearing assumptions. If any of them breaks, the decision +should be revisited: + +1. **Handlers need credentials at construction time or at call time, not + dynamically discovered at call time.** If a handler needs to derive a key + at an unpredictable path determined by call input, the scoped-capability + model still covers it (the handler holds a scoped vault access), but the + surface area is larger. The assumption is that this case is rare. +2. **The call protocol's threat model excludes the assembly layer.** The CLI + binary is trusted to hold the vault handle and inject capabilities. If the + assembly layer is compromised, all handlers' capabilities are compromised. + This is the same trust boundary as ADR-008. +3. **No legitimate use case requires returning a private key over the wire.** + Public key sharing (identity verification, encryption to a recipient) is + the only cross-node key material flow. If a use case for returning a + private key emerges (e.g., a key-escrow service), it needs its own ADR and a + very different threat model. +4. **Adapters are registered at startup, not at call time.** The credential + source is wired to the adapter when the operation is registered, not when + the operation is invoked. This is consistent with OQ-04 (static + registration at startup). + +## References + +- ADR-003: Crate decomposition (alknet-vault is standalone) +- ADR-005: irpc as call protocol foundation +- ADR-008: Vault integration point (capability source — this ADR specifies the + mechanism that ADR-008 described in prose) +- ADR-009: One-way door decision framework +- ADR-013: Rust as canonical implementation language +- OQ-15: Call protocol client and adapter contract (this ADR constrains the + adapter contract: adapters take credential sources, not static tokens) +- OQ-16: Safe vault operations for call protocol exposure (resolved by this + ADR: none, for now) +- alknet-vault implementation: `crates/alknet-vault/` \ No newline at end of file diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index c199daa..22cd327 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-06-17 +last_updated: 2026-06-18 --- # Open Questions @@ -90,8 +90,8 @@ Door type classifications follow ADR-009: - **Status**: resolved - **Door type**: One-way - **Priority**: medium -- **Resolution**: CLI-embedded with call protocol exposure. The CLI binary instantiates `VaultServiceHandle` locally and registers vault operations in the call protocol's operation registry. alknet-vault has no ALPN and no alknet-core dependency. Key derivation is local-only; only public key material crosses the network via `alknet/call`. The vault is a capability source — derived keys and decrypted credentials are injected into operation contexts at the assembly layer, not passed as vault references to handlers. See ADR-008. -- **Cross-references**: ADR-003, ADR-005, ADR-008 +- **Resolution**: CLI-embedded, assembly-layer only. The CLI binary instantiates `VaultServiceHandle` locally at startup, derives and decrypts the credentials each handler needs, and injects them into handler capabilities. alknet-vault has no ALPN, no alknet-core dependency, and no operations registered in the call protocol. The master seed and derived private keys never cross the network. The vault is a capability source, not a network service. See ADR-008 and ADR-014. +- **Cross-references**: ADR-003, ADR-005, ADR-008, ADR-014 ## Deferred Questions @@ -151,7 +151,7 @@ These questions are acknowledged but not active. They will be promoted to open w - **Status**: resolved - **Door type**: Two-way - **Priority**: medium -- **Resolution**: alknet-call uses `/{service}/{op}` (e.g., `/vault/derive`, `/services/list`). This is the correct format for the alknet-call crate — it is not a "Phase 1 simplification" but the right design for this architecture. The `/{node}/{service}/{op}` pattern from the reference implementation served a head/worker routing model that is a separate architectural concern. Remote dispatch (federation / node-level routing) would be a different mechanism at a different layer, not a prefix added to alknet-call's operation paths. If remote dispatch is ever needed, it would be addressed by a separate crate or a routing layer above the operation registry, not by changing alknet-call's path format. Two-way door — the path format can be extended later if needed, but `/{service}/{op}` is the correct design now. +- **Resolution**: alknet-call uses `/{service}/{op}` (e.g., `/fs/readFile`, `/agent/chat`, `/services/list`). This is the correct format for the alknet-call crate — it is not a "Phase 1 simplification" but the right design for this architecture. The `/{node}/{service}/{op}` pattern from the reference implementation served a head/worker routing model that is a separate architectural concern. Remote dispatch (federation / node-level routing) would be a different mechanism at a different layer, not a prefix added to alknet-call's operation paths. If remote dispatch is ever needed, it would be addressed by a separate crate or a routing layer above the operation registry, not by changing alknet-call's path format. Two-way door — the path format can be extended later if needed, but `/{service}/{op}` is the correct design now. - **Cross-references**: ADR-005, ADR-012 ### OQ-14: Batch Operation Semantics @@ -171,5 +171,14 @@ These questions are acknowledged but not active. They will be promoted to open w - **Status**: open - **Door type**: One-way - **Priority**: high -- **Resolution**: alknet-call currently specifies only the server side (CallAdapter receives connections and dispatches to the operation registry). A call protocol client is needed for: (1) alknet-napi to expose remote invocation to Node.js, (2) alknet-agent to dispatch tool calls (call, batch, search, schema) to remote nodes, (3) the `from_call` adapter pattern that creates operations whose handlers invoke remote services. The adapter contract (from_openapi, from_mcp, from_call, to_openapi, to_mcp) determines how external specifications and protocols compose with the operation registry. These traits belong in alknet-call because they define how operations are produced and consumed — the same contract that enables an agent to register call/batch/search/schema as tools also enables from_openapi to register HTTP-backed operations. The TypeScript `@alkdev/operations` library demonstrated these patterns; the Rust implementation defines the canonical traits (ADR-013). Two-way door for the specific trait signatures, one-way door for the architectural commitment that the adapter contract lives in alknet-call. -- **Cross-references**: ADR-005, ADR-013, [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md) \ No newline at end of file +- **Resolution**: alknet-call currently specifies only the server side (CallAdapter receives connections and dispatches to the operation registry). A call protocol client is needed for: (1) alknet-napi to expose remote invocation to Node.js, (2) alknet-agent to dispatch tool calls (call, batch, search, schema) to remote nodes, (3) the `from_call` adapter pattern that creates operations whose handlers invoke remote services. The adapter contract (from_openapi, from_mcp, from_call, to_openapi, to_mcp) determines how external specifications and protocols compose with the operation registry. These traits belong in alknet-call because they define how operations are produced and consumed — the same contract that enables an agent to register call/batch/search/schema as tools also enables from_openapi to register HTTP-backed operations. The TypeScript `@alkdev/operations` library demonstrated these patterns; the Rust implementation defines the canonical traits (ADR-013). Two-way door for the specific trait signatures, one-way door for the architectural commitment that the adapter contract lives in alknet-call. ADR-014 constrains the adapter contract: adapters take credential sources from the assembly layer (wired to the vault), not static token strings — the `from_openapi` and `from_jsonschema` patterns receive credentials at registration time, not at call time. +- **Cross-references**: ADR-005, ADR-013, ADR-014, [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md) + +### OQ-16: Safe Vault Operations for Call Protocol Exposure + +- **Origin**: [operation-registry.md](crates/call/operation-registry.md), ADR-008 +- **Status**: resolved +- **Door type**: One-way +- **Priority**: high +- **Resolution**: No vault operations are exposed over the call protocol for now. The vault is accessed only at the assembly layer (CLI binary at startup). Handlers receive secret material through `OperationContext.capabilities`, not by calling vault operations over the wire. The `operation-registry.md` spec previously showed `vault/derive`, `vault/unlock`, and `vault/decrypt` registered as call protocol operations — that was a contradiction with ADR-008's "capability source" model and has been corrected. If a future use case requires exposing a vault operation over the call protocol (e.g., a restricted `vault/public-key` operation that returns only public key material for identity verification), it would require its own ADR with an explicit threat model justification. See ADR-014. +- **Cross-references**: ADR-008, ADR-014, [operation-registry.md](crates/call/operation-registry.md) \ No newline at end of file diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index aa3a838..8f46535 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-06-16 +last_updated: 2026-06-18 --- # Alknet Overview @@ -44,7 +44,7 @@ alknet-core ├── alknet-agent (depends on alknet-call) │ ├── LLM execution loop (forked aisdk, simplified) │ ├── Tool dispatch via call protocol -│ └── Provider key retrieval via vault (no env vars) +│ └── Provider credentials via capabilities (no env vars, no vault on the wire) │ ├── alknet-git (depends on alknet-core, gix) ├── alknet-sftp (depends on alknet-core, russh-sftp) @@ -96,16 +96,16 @@ See [ADR-002](decisions/002-protocol-handler-trait.md) and [ADR-007](decisions/0 |------|---------|-------------| | `alknet/ssh` | SshAdapter | SSH-2 handshake, channel multiplexing, SOCKS5, port forwarding | | `alknet/call` | CallAdapter | JSON-RPC via irpc: operations, streaming, pub/sub | -| `alknet/agent` | AgentAdapter | LLM agent service: tool dispatch via call protocol, provider key retrieval via vault | +| `alknet/agent` | AgentAdapter | LLM agent service: tool dispatch via call protocol, provider credentials via capabilities | | `alknet/git` | GitAdapter | Git smart protocol over QUIC (gix, pkt-line) | | `alknet/sftp` | SftpAdapter | SFTP protocol (russh-sftp core) | | `alknet/msg` | MessageAdapter | E2E encrypted messaging, mixnet | | `alknet/http` | HttpAdapter | axum REST API, dashboard, MCP endpoint | -| `alknet/dns` | DnsAdapter | DNS over QUIC/TLS, pkarr service discovery | +| `alknet/dns` | DnsAdapter | DNS over QUIC/TLS, pkrr service discovery | | `h3` | HttpAdapter (WebTransport upgrade) | Browser-compatible WebTransport, then ALPN upgrade | | `h2` / `http/1.1` | HttpAdapter | Standard HTTP for browsers, curl | -> **Note**: `alknet/vault` is not in the ALPN registry. alknet-vault is a standalone local key vault with no alknet-core dependency. The CLI binary embeds it and exposes its operations through `alknet/call`. The vault is a capability source — derived keys and decrypted credentials are injected into operation contexts at the assembly layer, not passed as vault references to handlers. See ADR-008 for the integration rationale. +> **Note**: `alknet/vault` is not in the ALPN registry. alknet-vault is a standalone local key vault with no alknet-core dependency. The CLI binary embeds it and accesses it at the assembly layer — unlocking the vault at startup, deriving and decrypting credentials, and injecting them into handler capabilities. The vault is not exposed over the call protocol. No vault operations are registered in the operation registry. See ADR-008 and ADR-014. ## Authentication @@ -122,6 +122,23 @@ Each handler extracts credentials differently (SSH key fingerprint, AuthToken, B See [ADR-004](decisions/004-auth-as-shared-core.md) for the full rationale. +## Security Model: Secret Material Flow + +Authentication (above) handles inbound identity — who is calling me. Secret material flow handles outbound credentials — what secrets a handler uses for its own outbound calls (LLM provider API keys, HTTP service tokens, signing keys). These are orthogonal concerns with different sources and lifetimes: + +| Axis | Question | Source | Lifetime | +|------|----------|--------|----------| +| Identity (inbound) | Who is the caller? | AuthContext, per-request (TLS cert, auth token) | Per-request | +| Capabilities (outbound) | What secrets can I use outbound? | Assembly layer, from vault, injected at construction | Handler lifetime | + +The vault (alknet-vault) holds the master seed and derives keys and decrypts credentials. It is accessed **only at the assembly layer** — the CLI binary unlocks it at startup, derives/decrypts what each handler needs, and injects the results into handler capabilities. The vault is not exposed over the call protocol. No vault operations are registered in the operation registry. The master seed and derived private keys never cross the network. + +This replaces the industry default of environment variables and plaintext config files for storing credentials. There is no `std::env::var("API_KEY")` path — the only way a handler gets a credential is through a capability, and the only way a capability is populated is through the assembly layer from the vault. + +The call protocol carries no secret material — not in request payloads, not in response payloads, not in operation metadata. Operations that need to share public key material use a dedicated operation that returns only the public component. + +See [ADR-008](decisions/008-secret-service-integration.md) and [ADR-014](decisions/014-secret-material-flow-and-capability-injection.md) for the full rationale. + ## Call Protocol alknet-call uses irpc as its foundation. The wire format is length-prefixed JSON (EventEnvelope framing). Operations are registered in an irpc registry with JSON Schema discovery. The call protocol supports request/response, streaming subscriptions, and pub/sub. @@ -132,21 +149,7 @@ See [ADR-005](decisions/005-irpc-as-call-protocol-foundation.md) for the full ra ## WASM Compatibility -WASM is not an immediate implementation target, but it is a **design constraint on one-way doors** (see ADR-009). Decisions that would permanently prevent WASM targets from participating as peers require explicit justification. - -This means: -- Core types (BiStream, Connection, ProtocolHandler, AuthContext) must not assume tokio or quinn -- Protocol parsers that are pure data transformations remain transport-agnostic -- The cost of keeping the WASM door open is low (trait vs concrete type, abstracted I/O) and the cost of closing it is high -- The call protocol's wire format (length-prefixed JSON EventEnvelope) is inherently cross-language and WASM-friendly - -The browser path is through a JavaScript SDK adapted from the existing TypeScript `@alkdev/operations` library, speaking the EventEnvelope wire format over WebTransport streams — not through Rust-to-WASM compilation of the full stack (see ADR-013). A browser gets a WebTransport stream and speaks the call protocol directly. - -Handlers with protocol-agnostic cores are particularly WASM-friendly: -- russh-sftp's protocol core is already transport-agnostic -- hickory-proto is `#![no_std]` with `wasm-bindgen` feature -- The call protocol's JSON framing is inherently cross-language -- Git's pkt-line is simple enough to implement anywhere +WASM is not an implementation target. It is a design constraint on one-way doors (see ADR-009): core types must not assume tokio or quinn, and protocol parsers that are pure data transformations remain transport-agnostic. The cost of keeping this door open is low (trait vs concrete type, abstracted I/O); the cost of closing it is irreversibly high. The browser path is through a JavaScript SDK adapted from the existing TypeScript `@alkdev/operations` library, speaking the EventEnvelope wire format over WebTransport streams — not through Rust-to-WASM compilation of the full stack (see ADR-013). Specific WASM targeting decisions are deferred to individual crate specs. See OQ-09. ## Shared Types @@ -158,9 +161,10 @@ The following types live in alknet-core and are used across handler crates: | `Connection` | QUIC connection (or mock) — handlers open/accept streams on it | | `BiStream` | Trait: `AsyncRead + AsyncWrite + Send + Unpin` — bidirectional byte stream | | `AuthContext` | Resolved identity for a connection (may be partial) | -| `Identity` | Authenticated peer identity | +| `Identity` | Authenticated peer identity (inbound) | | `IdentityProvider` | Trait for resolving credentials to identity | | `AuthToken` | Opaque authentication token | +| `Capabilities` | Outbound credentials injected by the assembly layer (non-serializable, zeroized) | | `StaticConfig` | Immutable configuration loaded at startup | | `DynamicConfig` | Hot-reloadable configuration (`ArcSwap`) | | `ConfigReloadHandle` | Handle for triggering config reloads | @@ -169,11 +173,7 @@ The following types live in alknet-core and are used across handler crates: ### One-Way and Two-Way Doors -Not all decisions carry the same reversal cost. One-way door decisions (BiStream type, crate independence) require ADRs and possibly POCs before commitment. Two-way door decisions (static vs dynamic registration, single vs multi-transport) can be decided during implementation — start simple, add complexity when needed. See [ADR-009](decisions/009-one-way-door-decision-framework.md). - -### WASM Door Preservation - -WASM compatibility is not an active deliverable, but it is a design constraint. Decisions that would permanently close the WASM door (e.g., concrete quinn types in public APIs) require explicit justification. The cost of keeping the door open is low; the cost of closing it is irreversibly high. +Not all decisions carry the same reversal cost. One-way door decisions (BiStream type, crate independence, secret material flow) require ADRs and possibly POCs before commitment. Two-way door decisions (static vs dynamic registration, single vs multi-transport) can be decided during implementation — start simple, add complexity when needed. WASM compatibility is a design constraint within this framework, not a separate principle: decisions that would permanently close the WASM door require explicit justification. See [ADR-009](decisions/009-one-way-door-decision-framework.md). ### One ALPN, One Connection, One Handler @@ -196,8 +196,13 @@ All design decisions are documented as ADRs in [decisions/](decisions/). | [005](decisions/005-irpc-as-call-protocol-foundation.md) | irpc as Call Protocol Foundation | Call protocol uses irpc for registry, framing, dispatch | | [006](decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention and Connection Model | `alknet/` prefix, one ALPN per connection | | [007](decisions/007-bistream-type-definition.md) | BiStream Type Definition | BiStream is a trait, handlers receive Connection not BiStream | -| [008](decisions/008-secret-service-integration.md) | Vault Integration Point | CLI-embedded, exposed via call protocol, vault is a capability source | +| [008](decisions/008-secret-service-integration.md) | Vault Integration Point | CLI-embedded, vault is a capability source accessed at assembly time | | [009](decisions/009-one-way-door-decision-framework.md) | One-Way Door Decision Framework | Classify decisions by reversal cost; one-way doors need ADRs | +| [010](decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | HandlerRegistry, accept loop, static registration | +| [011](decisions/011-authcontext-structure.md) | AuthContext Structure and Resolution Flow | AuthContext fields, hybrid resolution | +| [012](decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | Bidirectional streams, EventEnvelope, ID-based correlation | +| [013](decisions/013-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Rust canonical, TypeScript reference adaptation | +| [014](decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Capabilities carry outbound credentials; call protocol carries no secret material | ## Open Questions @@ -207,7 +212,8 @@ Open questions are tracked in [open-questions.md](open-questions.md). Key questi - **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-08**: Vault integration point (resolved: CLI-embedded via call protocol — see ADR-008) +- **OQ-08**: Vault integration point (resolved: CLI-embedded, assembly-layer only — see ADR-008, ADR-014) +- **OQ-16**: Safe vault operations for call protocol exposure (resolved: none for now — see ADR-014) ## Failure Modes