docs(architecture): add ADR-014 secret material flow, remove vault ops from call protocol

Resolve the contradiction between ADR-008's "capability source" model
and operation-registry.md showing vault operations on the wire. ADR-014
establishes: vault is assembly-layer only, capabilities carry outbound
credentials (distinct from inbound identity), call protocol carries no
secret material, adapters take credential sources not static tokens.

- Add ADR-014 (Secret Material Flow and Capability Injection)
- Remove vault/derive, vault/unlock, vault/decrypt from call protocol
  registration examples and all spec examples
- Add Capabilities field to OperationContext, propagate through
  LocalOperationEnv nested calls
- Add Capability Injection section to operation-registry.md
- Add no-secret-material wire constraint to call-protocol.md
- Add streaming subscribe example (LLM chat with Vercel UI chunks)
- Add Security Model section to overview.md (identity vs capabilities)
- Trim WASM treatment from ~20 lines to a design-constraint note
- Add OQ-16 (resolved: no vault ops on wire), update OQ-08, OQ-15
- Update ADR-003, ADR-008, ADR-013 to remove stale "via call protocol"
  vault references
This commit is contained in:
2026-06-18 03:16:45 +00:00
parent 6219a323b6
commit 6a7d4b9755
10 changed files with 388 additions and 85 deletions

View File

@@ -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<dyn Fn(Value, OperationContext) -> Pin<Box<dyn Future<Output = ResponseEnvelope> + 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<Output = ResponseEnvelope>`.
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<Output = ResponseEnvelope>`.
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<String>,
pub identity: Option<Identity>,
pub capabilities: Capabilities,
pub metadata: HashMap<String, Value>,
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