docs(architecture): add ADR-022, resolve OQ-23 — handler registration, provenance, and composition authority

ADR-022 wires the three controls ADR-015 specified but left without
registration paths (C1-C4 from review #001): composition authority,
scoped env, and capabilities now enter through a HandlerRegistration
bundle. Provenance (Local, FromOpenAPI, FromMCP, FromCall, Session)
determines which ops can compose — leaves don't get composition
authority. CompositionAuthority replaces handler_identity: Identity
(it's a declared authority bundle, not a peer identity). Capabilities
are per-request from the bundle (resolves closure-capture vs context
ambiguity). Kernel/user analogy: user's authority checked at External
gate; handler's composition authority used inside; scoped env bounds
reachability.

Also fixes W1 (stale ADR-020 path example) and W3 (from_mcp missing
from adapter lists in operation-registry.md).

Spec updates: operation-registry.md (OperationRegistry,
HandlerRegistration, OperationContext, OperationEnv, registration
example, capability injection), call-protocol.md (build_root_context),
README.md, overview.md, open-questions.md (OQ-23), call/README.md.
This commit is contained in:
2026-06-21 09:09:47 +00:00
parent ec315e9499
commit 1cedc4eeba
8 changed files with 708 additions and 64 deletions

View File

@@ -1,13 +1,13 @@
---
status: draft
last_updated: 2026-06-19
last_updated: 2026-06-20
---
# 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 — implementation exists) and research/reference material. Foundational ADRs (001021) 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), secret material flow with capability injection (ADR-014), privilege model with authority context (ADR-015), abort cascade for nested calls (ADR-016), call protocol client and adapter contract (ADR-017), vault standalone crate (ADR-018), vault assembly-layer-only access (ADR-019), HD derivation for encryption keys (ADR-020), and key rotation via version-indexed paths (ADR-021). The alknet-core, alknet-call, and alknet-vault 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 — implementation exists) and research/reference material. Foundational ADRs (001022) 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), secret material flow with capability injection (ADR-014), privilege model with authority context (ADR-015), abort cascade for nested calls (ADR-016), call protocol client and adapter contract (ADR-017), vault standalone crate (ADR-018), vault assembly-layer-only access (ADR-019), HD derivation for encryption keys (ADR-020), key rotation via version-indexed paths (ADR-021), and handler registration, provenance, and composition authority (ADR-022). The alknet-core, alknet-call, and alknet-vault crate specs are in draft.
**Next step**: Review the vault spec documents, then begin implementation. All open questions for the core and call crates are resolved; the vault crate has one deferred OQ (OQ-21, remote vault administration) that does not block implementation.
@@ -56,6 +56,7 @@ last_updated: 2026-06-19
| [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 |
## Open Questions
@@ -83,6 +84,7 @@ See [open-questions.md](open-questions.md) for the full tracker.
- **OQ-19**: Session-scoped registries — agent-written operations via `OperationEnv` trait layering; protocol doesn't need changes; `OperationEnv` must remain a trait
- **OQ-20**: Encryption key derivation — HD derivation from BIP39 seed, not PBKDF2; salt field unused in v2 (wire-format compat) (ADR-020)
- **OQ-22**: Key rotation — version-indexed derivation paths; `rotate` method re-encrypts (ADR-021)
- **OQ-23**: Handler identity registration path — registration bundle with provenance, composition authority, scoped env, capabilities (ADR-022)
**Deferred (not active):**
- **OQ-09**: WASM target boundaries — design constraint, not deliverable

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-21
last_updated: 2026-06-22
---
# alknet-call
@@ -32,6 +32,7 @@ Structured RPC over QUIC: operations, request/response, streaming subscriptions,
| [015](../../decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
| [016](../../decisions/016-abort-cascade-for-nested-calls.md) | Abort Cascade for Nested Calls | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
| [017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction |
| [022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | Handler Registration, Provenance, and Composition Authority | Registration bundle carries provenance, composition authority, scoped env, capabilities |
## Relevant Open Questions
@@ -53,4 +54,5 @@ Structured RPC over QUIC: operations, request/response, streaming subscriptions,
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.
8. **Abort cascades to descendants**: `call.aborted` for a parent request cascades to all non-terminal descendants. Default `abort-dependents`; `continue-running` opt-in. See ADR-016.
9. **Internal calls switch authority context, not skip ACL**: The `internal` flag marks composition-originated calls. ACL runs against the handler's identity, not the caller's and not as a blanket skip. Operations have External/Internal visibility. Scoped composition env bounds reachability. See ADR-015.
9. **Internal calls switch authority context, not skip ACL**: The `internal` flag marks composition-originated calls. ACL runs against the handler's composition authority, not the caller's and not as a blanket skip. Operations have External/Internal visibility. Scoped composition env bounds reachability. See ADR-015, ADR-022.
10. **Provenance determines composition capability**: Only `Local` and `Session` ops can compose. Leaves (`FromOpenAPI`, `FromMCP`, `FromCall`) are forwarding stubs — they don't get composition authority or a scoped env. The assembly layer is the sole grantor of composition authority. See ADR-022.

View File

@@ -262,24 +262,30 @@ The `CallAdapter` receives an `AuthContext` from the endpoint. The call protocol
### Root OperationContext Construction
When a `call.requested` arrives from the wire, the `CallAdapter` constructs the root `OperationContext` — the entry point of the call tree. This is the counterpart to `OperationEnv::invoke()` (which constructs nested contexts with `internal: true`): the wire path sets `internal: false`, meaning ACL runs against the caller's `identity`, not a handler's `handler_identity` (ADR-015).
When a `call.requested` arrives from the wire, the `CallAdapter` constructs the root `OperationContext` — the entry point of the call tree. This is the counterpart to `OperationEnv::invoke()` (which constructs nested contexts with `internal: true`): the wire path sets `internal: false`, meaning ACL runs against the caller's `identity`, not a handler's composition authority (ADR-015, ADR-022).
```rust
// CallAdapter dispatch path — root context for an incoming wire request
fn build_root_context(
&self,
request_id: String,
identity: Option<Identity>, // resolved per-request above
capabilities: Capabilities, // the CallAdapter's own capabilities (if any)
operation_name: &str, // looked up in registry for the registration bundle
identity: Option<Identity>, // resolved per-request above (caller's identity)
) -> OperationContext {
let registration = self.registry.registration(operation_name);
OperationContext {
request_id,
parent_request_id: None, // wire request — top of the call tree
identity: identity.clone(), // caller's identity (inbound)
handler_identity: None, // no composition authority — wire call
capabilities,
identity: identity.clone(), // caller's identity (inbound — gate credential)
// Composition authority from the registration bundle (ADR-022).
// None for leaves (FromOpenAPI/FromMCP/FromCall); Some for Local/Session.
// This is on the context for PROPAGATION to children via invoke(),
// not for the root's own ACL (which uses identity above).
handler_identity: registration.composition_authority.clone(),
capabilities: registration.capabilities.clone(), // from the registration bundle
metadata: HashMap::new(), // fresh per request
env: self.env.clone(), // LocalOperationEnv for composition
env: registration.scoped_env.clone()
.unwrap_or_else(ScopedOperationEnv::empty), // from the bundle, empty for leaves
internal: false, // external call — ACL against caller identity
}
}
@@ -349,6 +355,7 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
| Privilege model and authority context | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
| Abort cascade for nested calls | [ADR-016](../../decisions/016-abort-cascade-for-nested-calls.md) | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
| Call protocol client and adapter contract | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction |
| Handler registration, provenance, and composition authority | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
## Open Questions

View File

@@ -56,7 +56,7 @@ Operation names use slash-based paths without a leading slash, aligned with URL
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.
Visibility (ADR-015) controls whether an operation is callable from the wire. `External` operations are wire-facing — they appear in `services/list` and accept `call.requested` from clients. `Internal` operations are composition-only — they return `NOT_FOUND` (not `FORBIDDEN`) when called from the wire, and do not appear in `services/list`. The assembly layer declares visibility at registration. `from_openapi` and `from_jsonschema` adapters register operations as `Internal` by default (they're composition material, not directly callable); the handler that composes them is `External`.
Visibility (ADR-015) controls whether an operation is callable from the wire. `External` operations are wire-facing — they appear in `services/list` and accept `call.requested` from clients. `Internal` operations are composition-only — they return `NOT_FOUND` (not `FORBIDDEN`) when called from the wire, and do not appear in `services/list`. The assembly layer declares visibility at registration. All import adapters (`from_openapi`, `from_mcp`, `from_jsonschema`, `from_call`) register operations as `Internal` by default (they're composition material, not directly callable); the handler that composes them is `External`.
### AccessControl
@@ -101,7 +101,7 @@ pub struct OperationContext {
pub request_id: String,
pub parent_request_id: Option<String>,
pub identity: Option<Identity>, // Caller's identity (inbound — who invoked me)
pub handler_identity: Option<Identity>, // Handler's identity (composition authority — who am I acting as)
pub handler_identity: Option<CompositionAuthority>, // Handler's composition authority (ADR-022)
pub capabilities: Capabilities,
pub metadata: HashMap<String, Value>,
pub env: OperationEnv,
@@ -119,46 +119,74 @@ impl 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`). Records the agency chain — the call tree is the principal→agent chain (ADR-015)
- `identity`: The authenticated caller (from `IdentityProvider`) — inbound auth (who is calling me). For external calls, this is who sent the `call.requested`. For internal calls, this is the parent handler's `handler_identity` (propagated through `OperationEnv::invoke()`)
- `handler_identity`: The identity of the handler processing this call. Set at registration by the assembly layer. For internal calls (`internal: true`), the ACL check runs against this identity (ADR-015)
- `handler_identity`: The composition authority of the handler processing this call. `None` for leaves (`FromOpenAPI`, `FromMCP`, `FromCall`) — they don't compose. `Some(...)` for `Local` and `Session` ops that can compose children. For internal calls (`internal: true`), the ACL check runs against this authority (ADR-015, ADR-022). This is NOT a peer `Identity` — it's a declared authority bundle set at registration by the assembly layer
- `capabilities`: Outbound credentials the handler may use (decrypted API keys, scoped vault access) — see [Capability Injection](#capability-injection) below
- `metadata`: Request-scoped context (tracing IDs, connection info). **Must not hold secret material** — see ADR-014. **Does not propagate through `OperationEnv::invoke()`** — nested calls get fresh metadata. The tracing link between parent and child is `parent_request_id`, not metadata propagation. Anything a handler needs to pass to a child goes in the call `input`.
- `env`: The operation environment for composing calls to other operations. Scoped — the handler can only invoke a declared set of operations (ADR-015)
- `env`: The operation environment for composing calls to other operations. Scoped — the handler can only invoke a declared set of operations (ADR-015). `None`/empty for leaves.
- `internal`: When `true`, this call originated from composition (a handler calling another operation via `OperationEnv`), not from a wire request. This switches the authority context: ACL runs against `handler_identity`, not `identity`. The `internal` field uses module-private construction — handlers construct `OperationContext` through `OperationEnv::invoke()` which sets `internal: true`, or through the `CallAdapter` dispatch path which sets `internal: false`. The field is not `pub` for writes; only `pub fn is_internal(&self) -> bool` is exposed for reads. See ADR-015.
`identity` and `capabilities` are orthogonal: identity is inbound (who is calling me), capabilities are outbound (what credentials I can use). `identity` and `handler_identity` are the principal/agent pair: `identity` is the principal (who delegated), `handler_identity` is the agent (who is acting). See ADR-014 for capabilities and ADR-015 for the privilege model.
`identity` and `capabilities` are orthogonal: identity is inbound (who is calling me), capabilities are outbound (what credentials I can use). `identity` and `handler_identity` are the principal/agent pair: `identity` is the principal (who delegated), `handler_identity` is the agent (who is acting). See ADR-014 for capabilities, ADR-015 for the privilege model, and ADR-022 for the composition authority type.
### OperationRegistry
```rust
pub struct OperationRegistry {
operations: HashMap<String, (OperationSpec, Handler)>,
operations: HashMap<String, HandlerRegistration>,
}
```
The registry maps operation names to `(OperationSpec, Handler)` pairs. Key methods:
The registry maps operation names to `HandlerRegistration` bundles. See ADR-022 for the full registration model. Key methods:
- `register(spec, handler)`: Add an operation at startup
- `lookup(name)`: Find an operation by name, returning spec and handler
- `register(registration)`: Add an operation at startup
- `registration(name)`: Find a registration by operation name (returns spec, handler, provenance, composition authority, scoped env, capabilities)
- `invoke(name, input, context)`: Look up, check ACL, invoke handler, return result
- `list_operations()`: Return all registered specs (for `/services/list`)
The `OperationRegistryBuilder` provides a fluent API for constructing the registry at startup:
### HandlerRegistration
The registration bundle carries everything the dispatch path needs to construct an `OperationContext`. See ADR-022 for the full rationale.
```rust
pub struct HandlerRegistration {
pub spec: OperationSpec,
pub handler: Handler,
pub provenance: OperationProvenance,
pub composition_authority: Option<CompositionAuthority>, // None for leaves
pub scoped_env: Option<ScopedOperationEnv>, // None for leaves
pub capabilities: Capabilities,
}
```
- `provenance`: Where the op came from (`Local`, `FromOpenAPI`, `FromMCP`, `FromCall`, `FromJsonSchema`, `Session`). Determines composition capability, default visibility, and trust model. Only `Local` and `Session` ops can compose; leaves get `composition_authority: None` and `scoped_env: None`.
- `composition_authority`: The declared authority (label + scopes + resources) the handler operates under when composing children. `None` for leaves. This replaces ADR-015's `handler_identity: Identity` — it's not a peer identity, it's a declared authority bundle. See ADR-022.
- `scoped_env`: The set of operations this handler may reach via `env.invoke()`. `None` for leaves (empty env). The reachability control from ADR-015.
- `capabilities`: Outbound credentials (decrypted API keys, signing keys). Populated by the assembly layer from the vault at registration time. See [Capability Injection](#capability-injection).
The `OperationRegistryBuilder` provides a fluent API with convenience methods for common cases:
```rust
let registry = OperationRegistryBuilder::new()
.with(services_list_spec(), Arc::new(services_list_handler))
.with(services_schema_spec(), Arc::new(schema_handler))
.with(agent_chat_spec(), Arc::new(agent_chat_handler))
// Built-in service discovery (Local, no composition)
.with_local(services_list_spec(), Arc::new(services_list_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty())
.with_local(services_schema_spec(), Arc::new(schema_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty())
// Agent handler (Local, composes — has authority + scoped env)
.with_local(agent_chat_spec(), Arc::new(agent_chat_handler),
CompositionAuthority::new("agent-chat", ["llm:call", "fs:read", "vastai:query"]),
ScopedOperationEnv::new(["fs/readFile", "vastai/listMachines", "llm/generate"]))
// Imported ops (leaves — no authority, no scoped env)
.with_leaf(vastai_listMachines_spec(), Arc::new(vastai_handler), vastai_credentials)
.build();
```
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.
The CLI binary (or assembly layer) constructs the registry and passes it to the `CallAdapter`. Once built, the registry is immutable.
### 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 `parent` parameter propagates the calling context: the nested call gets `parent_request_id: Some(parent.request_id)`, inherits `parent.identity`, and is marked `internal: true`.
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`.
**Metadata does not propagate through composition.** Nested calls get fresh metadata (`HashMap::new()`), not the parent's metadata bag. This is a security constraint (ADR-014): `metadata: HashMap<String, Value>` accepts any `serde_json::Value`, including secret material. If metadata propagated through `env.invoke()`, a handler that accidentally placed a secret in metadata would leak it to every child operation — and if a child is a `from_call` operation (ADR-017), the metadata would cross the wire to the remote node. The tracing link between parent and child is `parent_request_id`, not metadata propagation. Anything a handler needs to pass to a child goes in the call `input`, not in ambient context.
@@ -173,6 +201,16 @@ pub struct LocalOperationEnv {
impl OperationEnv for LocalOperationEnv {
async fn invoke(&self, namespace: &str, operation: &str, input: Value, parent: &OperationContext) -> ResponseEnvelope {
let name = format!("{namespace}/{operation}");
// Reachability check (ADR-015, ADR-022): is this op in the parent's
// scoped env? If not, return NOT_FOUND. This bounds the
// parameterized-dispatch attack surface — a handler (or an LLM
// picking tools) can only reach declared ops.
if !parent.env.allows(&name) {
return ResponseEnvelope::not_found(name);
}
let registration = self.registry.registration(&name);
let context = OperationContext {
// Unique per invocation — a counter, UUID, or parent_id + suffix.
// A deterministic ID (e.g. format!("env-{name}")) collides across
@@ -181,11 +219,18 @@ impl OperationEnv for LocalOperationEnv {
// (ADR-016), which is indexed by parent_request_id.
request_id: generate_request_id(),
parent_request_id: Some(parent.request_id.clone()),
identity: parent.handler_identity.clone(), // Parent's handler identity becomes the caller
handler_identity: parent.handler_identity.clone(), // Inherit handler authority for ACL
// Parent's composition authority becomes the caller for the child.
// This is the authority switch: the child's ACL checks against
// the parent's authority, not the original wire caller's identity.
identity: parent.handler_identity.as_identity(),
// Child's own composition authority (from its registration).
// None for leaves — they don't compose, so this is never used
// for ACL on a grandchild.
handler_identity: registration.composition_authority.clone(),
capabilities: parent.capabilities.clone(), // Inherit caller's capabilities
metadata: HashMap::new(), // Fresh — does NOT propagate parent metadata (ADR-014)
env: self.clone(),
env: registration.scoped_env.clone()
.unwrap_or_else(ScopedOperationEnv::empty), // Child's own scoped env (empty for leaves)
internal: true, // Nested calls use handler authority
};
self.registry.invoke(&name, input, context).await
@@ -193,6 +238,11 @@ impl OperationEnv for LocalOperationEnv {
}
```
Two things happen in `invoke()`:
1. **Reachability check**: before constructing the child context, `invoke()` checks whether the requested op is in the parent's scoped env. If not, `NOT_FOUND`. This is the reachability control — a handler can only compose declared ops.
2. **Authority propagation**: the child's `identity` is the parent's `handler_identity` (the parent's composition authority becomes the caller). The child's `handler_identity` is the child's own registration's `composition_authority` — so if the child itself composes further, its children inherit the child's authority. This is the principal/agent chain from ADR-015, now wired via ADR-022.
Future work may add irpc service dispatch and remote call protocol dispatch as additional backends. The handler-facing API stays the same.
**`OperationEnv` must remain a trait.** This is a constraint, not a suggestion. The trait-based design enables session-scoped registries (OQ-19) — a session env wraps the global env (check session registry first, fall through to global). Making `OperationEnv` concrete or hardcoding the global registry into the dispatch path would close the session-overlay pattern. See OQ-19.
@@ -240,39 +290,47 @@ If a handler internally uses an irpc-based service, the handler bridges the two:
### Operation Registration at Startup
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:
The CLI binary (or assembly layer) constructs `HandlerRegistration` bundles with provenance, composition authority, scoped env, and capabilities (from the vault — see [Capability Injection](#capability-injection)), then registers them before starting the endpoint:
```rust
// Assembly layer: unlock vault, derive credentials, construct handlers
// Assembly layer: unlock vault, derive credentials
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)));
let vastai_credentials = Capabilities::new().with_http_token("vastai", vastai_token);
// 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(schema_handler))
// Agent and GitHub handlers (constructed with injected capabilities)
.with(agent_chat_spec(), agent_handler)
.with(github_authenticate_spec(), github_handler)
// Built-in service discovery (Local, no composition)
.with_local(services_list_spec(), Arc::new(services_list_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty())
.with_local(services_schema_spec(), Arc::new(schema_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty())
// Agent handler (Local, composes — has authority + scoped env + capabilities)
.with(HandlerRegistration {
spec: agent_chat_spec(),
handler: Arc::new(agent_chat_handler),
provenance: OperationProvenance::Local,
composition_authority: Some(CompositionAuthority::new(
"agent-chat", ["llm:call", "fs:read", "vastai:query"])),
scoped_env: Some(ScopedOperationEnv::new(
["fs/readFile", "vastai/listMachines", "llm/generate"])),
capabilities: Capabilities::new().with_api_key("google", google_api_key),
})
// Vastai ops (FromOpenAPI, leaves — no authority, no scoped env)
.with_leaf(vastai_listMachines_spec(), Arc::new(vastai_listMachines_handler),
vastai_credentials.clone())
.build();
let call_adapter = CallAdapter::new(Arc::new(registry), identity_provider);
```
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.
The vault is used at construction time to populate `capabilities` in the registration bundle, 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, ADR-014, and ADR-022.
### 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.
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. ADR-022 specifies the registration path: capabilities live on the `HandlerRegistration` bundle, and the dispatch path populates `OperationContext.capabilities` from the bundle at call time.
The flow is:
@@ -280,31 +338,34 @@ 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
3. Construct HandlerRegistration bundles with capabilities from the vault
4. Register the bundles in the OperationRegistry
5. Start the endpoint
Handler invocation (at call time):
call.requested → OperationContext { capabilities, identity, ... }
handler reads capabilities → uses the credential for its outbound call
call.requested → CallAdapter looks up registration by op name
→ build_root_context populates OperationContext.capabilities from registration.capabilities
→ handler reads context.capabilities → uses the credential for its outbound call
```
The handler closure does **not** capture capabilities — that was the pre-ADR-022 "Model A" that created a circular dependency with per-request `OperationContext.capabilities`. Capabilities live on the registration bundle, and the dispatch path populates the context from the bundle. One model, one wiring path. See ADR-022 Decision 6.
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 internal-only flows. They are never populated from call protocol inputs.
- Capabilities are populated by the assembly layer at registration (on the `HandlerRegistration` bundle). 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.
- **Capabilities are `Clone` and cloned through composition.** `OperationEnv::invoke()` calls `parent.capabilities.clone()` to pass capabilities to nested calls. This is intentional: a child handler needs the same outbound credentials as its parent (e.g., the `/agent/chat` handler composing `/fs/readFile` may need the same API key for an outbound LLM call). The security implication is that each composition step duplicates the secret material reference — but capabilities are scoped (the handler can only use what the assembly layer declared), and children run under the parent's handler authority (ADR-015). A clone is the same scoped handle, not a widening of scope. The concrete cloning semantics (reference-counted `Arc` vs deep copy of zeroized material) is a two-way door for implementation, but `Capabilities: Clone` is required by the composition model.
- **Capabilities are `Clone` and cloned through composition.** `OperationEnv::invoke()` calls `parent.capabilities.clone()` to pass capabilities to nested calls. This is intentional: a child handler needs the same outbound credentials as its parent (e.g., the `/agent/chat` handler composing `/fs/readFile` may need the same API key for an outbound LLM call). The security implication is that each composition step duplicates the secret material reference — but capabilities are scoped (the handler can only use what the assembly layer declared on the registration bundle), and children run under the parent's composition authority (ADR-015, ADR-022). A clone is the same scoped handle, not a widening of scope. The concrete cloning semantics (reference-counted `Arc` vs deep copy of zeroized material) is a two-way door for implementation, but `Capabilities: Clone` is required by the composition model.
**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`, `from_jsonschema`, and `from_call` adapter patterns (see ADR-017, constrained by ADR-014) register HTTP-backed or remote-call-backed operations. The credential each service needs (bearer token, API key, TLS identity for the remote connection) 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 backed operations, including LLM providers that expose OpenAPI-compatible endpoints. Adapter-registered operations are `Internal` by default (ADR-015) — they're composition material, not directly callable from the wire.
**Adapters take credential sources.** All import adapters (`from_openapi`, `from_mcp`, `from_jsonschema`, `from_call` see ADR-017, constrained by ADR-014) register HTTP-backed, MCP-backed, or remote-call-backed operations. The credential each service needs (bearer token, API key, TLS identity for the remote connection) 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 backed operations, including LLM providers that expose OpenAPI-compatible endpoints. Adapter-registered operations are `Internal` by default (ADR-015) — they're composition material, not directly callable from the wire.
**`from_call` imports remote operations.** The `from_call` adapter (ADR-017) discovers operations on a remote call protocol endpoint via `services/list` and `services/schema`, then registers them with handlers that forward calls over the QUIC connection. This makes cross-node composition transparent — a handler calling `env.invoke("worker", "exec", ...)` doesn't know whether the operation is local or remote. Connection direction (who opened the QUIC connection) is independent of call direction (who calls whom) — both sides can call each other once connected.
**`from_call` trust is transitive.** A `from_call`-imported operation executes the remote node's code, not yours. The scoped env (ADR-015) bounds *which* operations are reachable, but not *what* they do. A compromised remote node can do anything its operations are declared to do (and anything its handler bugs allow). This is inherent to remote composition — same as trusting any RPC endpoint — but it must be explicit in the threat model. `from_call` means "I trust the remote node as much as my own handlers." The scoping protects the caller from reaching arbitrary ops; it does not protect against what the reached op does.
**Scoped composition env.** The `OperationEnv` given to a handler is scoped — it can only invoke a declared set of operations, set at registration by the assembly layer. This bounds the parameterized-dispatch attack surface: a handler (or an LLM picking tools, or a quickjs sandbox) can only reach declared operations, not the entire registry. The scoped env is the reachability control; the handler identity is the authority control. Both are needed for least privilege. See ADR-015.
**Scoped composition env.** The `OperationEnv` given to a handler is scoped — it can only invoke a declared set of operations, set at registration on the `HandlerRegistration` bundle by the assembly layer (ADR-022). This bounds the parameterized-dispatch attack surface: a handler (or an LLM picking tools, or a quickjs sandbox) can only reach declared operations, not the entire registry. The scoped env is the reachability control; the composition authority is the authority control. Both are needed for least privilege. See ADR-015 and ADR-022.
## Constraints
@@ -312,12 +373,13 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
- Operation specs use JSON Schema. The call protocol's external interface is always JSON. irpc's postcard serialization is internal only.
- `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.internal` is set by `OperationEnv`, not by callers. A handler cannot mark its own call as internal. The `internal` flag switches authority context (handler identity for ACL), it does not skip ACL — see ADR-015.
- `OperationContext.internal` is set by `OperationEnv`, not by callers. A handler cannot mark its own call as internal. The `internal` flag switches authority context (composition authority for ACL), it does not skip ACL — see ADR-015, ADR-022.
- **Operations have External/Internal visibility.** `Internal` operations return `NOT_FOUND` when called from the wire and are excluded from `services/list`. The assembly layer declares visibility at registration. See ADR-015.
- **The composition env is scoped.** A handler can only invoke operations declared in its scoped env. This bounds parameterized-dispatch attack surface. See ADR-015.
- **The composition env is scoped.** A handler can only invoke operations declared in its scoped env (on the `HandlerRegistration` bundle). This bounds parameterized-dispatch attack surface. See ADR-015, ADR-022.
- **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.
- **Metadata does not propagate through composition.** `OperationEnv::invoke()` constructs fresh metadata for nested calls (`HashMap::new()`), not the parent's metadata. This prevents a handler that accidentally places a secret in metadata from leaking it to child operations — and if a child is a `from_call` operation (ADR-017), across the wire to a remote node. The tracing link is `parent_request_id`, not metadata propagation. See ADR-014.
- **Provenance determines composition capability.** Only `Local` and `Session` ops can compose. Leaves (`FromOpenAPI`, `FromMCP`, `FromCall`) get `composition_authority: None` and `scoped_env: None` — they don't compose, so they don't need authority or reachability bounds. See ADR-022.
## Design Decisions
@@ -328,7 +390,8 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
| Static handler registration | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Registry is immutable after construction |
| 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 |
| Privilege model and authority context | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
| Privilege model and authority context | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; composition authority + scoped env |
| Handler registration, provenance, and composition authority | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
## Open Questions
@@ -337,7 +400,7 @@ 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-16** (resolved by ADR-014): No vault operations are exposed over the call protocol for now.
- **OQ-19** (resolved): Session-scoped operation registries — agent-written operations overlaid on the global registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait.
- **OQ-19** (resolved): Session-scoped operation registries — agent-written operations overlaid on the global registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Session ops are `Session` provenance (ADR-022) — always `Internal`, compose under restricted authority scoped down at sandbox creation.
## References

View File

@@ -67,9 +67,10 @@ credential encryption:
decrypting multiple credentials at startup.
4. **Domain separation via paths.** Different encryption purposes can use
different derivation paths (`m/74'/2'/0'/0'` for v1, `m/74'/2'/1'/0'`
for a future v2). PBKDF2 has no equivalent — the only versioning knob is
the iteration count or the password.
different derivation paths (`m/74'/2'/0'/0'` for v2, `m/74'/2'/0'/1'`
for a future v3). PBKDF2 has no equivalent — the only versioning knob is
the iteration count or the password. See ADR-021 for the version-indexed
path scheme.
5. **The salt becomes unnecessary for key derivation.** HD derivation
doesn't need a salt — the path provides domain separation. The salt

View File

@@ -0,0 +1,559 @@
# ADR-022: Handler Registration, Provenance, and Composition Authority
## Status
Proposed
## Context
ADR-015 established the privilege model: the `internal` flag marks
composition-originated calls and switches the ACL from the caller's identity
to the handler's identity. This replaces the old `trusted: bool` flag, which
skipped ACL entirely — a privilege escalation vector. The core decision in
ADR-015 is sound: internal calls switch authority, they don't skip ACL.
However, ADR-015 left three things unspecified, which the pre-implementation
review (docs/reviews/001-pre-implementation-architecture-sanity-check.md,
findings C1C4) identified as critical gaps:
1. **`handler_identity` has no registration path.** ADR-015 says the handler's
identity is "set at registration by the assembly layer" (Assumption 2) and
that "ACL check runs against the handler's identity (set at registration)"
(Decision 1). But the registration API shown in operation-registry.md —
`register(spec, handler)` and `OperationRegistryBuilder::with(spec,
handler)` — accepts no identity. Tracing the dispatch path reveals that
`build_root_context` sets `handler_identity: None` for wire calls (correct
for the root), and `OperationEnv::invoke()` propagates
`parent.handler_identity.clone()` to children. Since the root's
`handler_identity` is `None`, every internal call gets `handler_identity:
None` — meaning ADR-015's "ACL runs against `handler_identity` for internal
calls" checks against `None`, which is the privilege-escalation gap ADR-015
was written to close.
2. **The scoped composition env has no registration/construction path.**
ADR-015 says the `OperationEnv` given to a handler is "scoped — it can
only invoke a declared set of operations, set at registration by the
assembly layer" (Decision 4, Assumption 3). But `register(spec, handler)`
takes no scoped-env declaration, `OperationSpec` has no field for it, and
the only `OperationEnv` implementation shown is `LocalOperationEnv` wrapping
the *full* registry — no scoping layer exists.
3. **`Capabilities` lives in two unconnected models.** ADR-014 and
operation-registry.md show two models for how a handler gets outbound
credentials: construction-time capture in the handler closure (Model A) and
per-request on `OperationContext.capabilities` propagated through
composition (Model B). The two don't connect: if the handler closure
captured capabilities at construction, `OperationContext.capabilities` is
either redundant or must be populated from the closure — but the closure
receives the context, it isn't passed it. An implementer would have to
invent the bridge, and the consuming crates (call, agent, napi) could
diverge.
Beyond these wiring gaps, there is a deeper issue with ADR-015's Assumption 6:
"the handler identity is a full `Identity` (with scopes), not a special
principal type." `Identity` was designed for **inbound peer identity** — who
is calling me from the network. A handler is not a peer. Its `id` field would
be something like `"agent-chat-handler"` — a label, not something resolvable
through `IdentityProvider`. Calling it an `Identity` implies it's a peer,
which it isn't. It's an authority bundle.
### The kernel/user analogy
This is structurally the same problem an operating system solves with
kernel/user mode:
- User calls `getaddrinfo()` — the syscall gate (an **External** op). The
kernel checks the user's capabilities at entry.
- `getaddrinfo` internally makes DNS queries, allocates sockets, reads
`/etc/hosts`**Internal** kernel functions. They don't check the user's
`CAP_NET_RAW`. They run under **kernel authority**.
- The user does NOT need `CAP_NET_RAW` to resolve DNS. The kernel does network
access on the user's behalf, under the kernel's own authority.
The key principle: **the user's authority is checked once at the gate. Inside,
the handler runs under its own authority. The user's authority does not
propagate into internal calls.**
This is exactly what ADR-015 specifies. The `internal` flag is the boundary
crossing. When `internal: true`, ACL switches from the caller's identity to
the handler's composition authority. The user's `[chat]` scope got them through
`/agent/chat`'s External ACL. Once inside, it's `/agent/chat`'s composition
authority that authorizes composing `/vastai/listMachines` — not the user's.
### The graph framing
Call trees and operation registries are graph-shaped. The TypeScript
`@alkdev/flowgraph` package models this explicitly with three graphs:
1. **Operation Graph** (static) — nodes are registered operations, edges are
type-compatibility relationships. Built from `OperationSpec`s at startup.
2. **Call Graph** (dynamic) — nodes are call invocations (request IDs), edges
are parent-child relationships (`parent_request_id`). Built from call
protocol events at runtime.
3. **Scoped Operation Subgraph** (per-handler, static) — the declared subset
of the operation graph that a handler may reach. This is what ADR-015 calls
the "scoped env," framed as a subgraph rather than a list of names.
This ADR uses the graph *model* as structural framing but does not mandate a
graph *library*. For v1, the operation graph can be implicit (a
`HashMap<String, OperationNode>`), the call graph can be implicit (the
`PendingRequestMap` indexed by `parent_request_id` *is* a call graph), and the
scoped env can be a `HashSet<String>` of reachable operation names. A
dedicated `alknet-flowgraph` crate (or folding graph structures into
`alknet-call`) is a future enhancement for workflow templates, type
compatibility validation, and call-graph observability — not a prerequisite
for the security model.
## Decision
### 1. Provenance is the primary registration axis
Every registered operation carries a provenance tag that classifies where it
came from. Provenance determines whether the operation can compose, whether it
has composition authority, its default visibility, and its trust model.
```rust
pub enum OperationProvenance {
/// Assembly-written, trusted code, can compose.
Local,
/// HTTP forwarding stub (from_openapi), leaf — cannot compose.
FromOpenAPI,
/// MCP forwarding stub (from_mcp), leaf — cannot compose.
FromMCP,
/// QUIC forwarding stub (from_call), leaf locally — cannot compose.
FromCall,
/// JSON Schema definition (from_jsonschema), no handler — schema only.
FromJsonSchema,
/// Agent-written, sandboxed, can compose within sandbox bounds.
Session,
}
```
| Provenance | Can compose? | Has composition authority? | Default visibility | Trust model |
|-----------|-------------|---------------------------|-------------------|-------------|
| `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 |
| `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 |
Only `Local` and `Session` ops get composition authority. Leaves
(`FromOpenAPI`, `FromMCP`, `FromCall`) don't compose, so they don't get one.
The assembly layer does not invent identities for leaves.
### 2. Composition authority replaces `handler_identity: Identity`
ADR-015's Assumption 6 said "the handler identity is a full `Identity` (with
scopes), not a special principal type." This ADR refines that: composition
authority is a declared authority bundle, not a peer `Identity`. It's only set
for ops that can compose (`Local`, `Session`). Leaves don't have one.
```rust
/// Authority under which a handler composes child operations.
///
/// This is NOT a peer `Identity` — it's not resolvable through
/// `IdentityProvider` and doesn't represent an inbound caller. It's the
/// declared authority (scopes + resources + label) that the assembly layer
/// grants a handler for composition. When the handler composes children via
/// `OperationEnv::invoke()`, the child's ACL runs against this authority,
/// not the caller's identity and not as a blanket skip.
///
/// Only ops that can compose (`Local`, `Session`) have one. Leaves
/// (`FromOpenAPI`, `FromMCP`, `FromCall`) have `None`.
pub struct CompositionAuthority {
/// Human-readable label for attribution and logging
/// (e.g., "agent-chat", "fs-handler"). Not a peer id — not resolvable
/// through IdentityProvider.
pub label: String,
/// Scopes the handler operates under for composition. When the handler
/// composes a child via `env.invoke()`, the child's ACL checks against
/// these scopes. Least privilege: the assembly layer grants only the
/// scopes the handler needs for its declared composition.
pub scopes: Vec<String>,
/// Named resource lists, same shape as `Identity.resources`. Optional.
/// e.g., {"service": ["vastai", "github"]} bounds which services the
/// handler can reach in composition.
pub resources: HashMap<String, Vec<String>>,
}
```
This supersedes ADR-015's Assumption 6. ADR-015's core decision (authority
switch, not ACL skip) holds unchanged — the only change is *what* the
authority is and which ops have it.
### 3. The scoped env is a declared subgraph (reachability control)
The scoped composition env from ADR-015 is the **reachability control**: it
bounds which operations a handler can reach via `env.invoke()`. ADR-015
specifies it as "a declared set of operations, set at registration by the
assembly layer." This ADR makes the registration path explicit and frames it
as a subgraph of the operation graph.
```rust
/// The set of operations a handler may reach via `env.invoke()`.
///
/// This is the reachability control from ADR-015: a handler (or an LLM
/// picking tools, or a quickjs sandbox) can only compose declared operations,
/// not the entire registry. Set at registration by the assembly layer for
/// composing ops (`Local`, `Session`). `None` for leaves — they don't
/// compose, so they get an empty/no-op env.
///
/// Conceptually a subgraph of the operation graph. For v1, implemented as a
/// set of operation names — the *model* is a subgraph (which nodes this
/// handler can reach), but type-compatibility edges between those nodes are
/// a future enhancement for static validation, not a v1 requirement.
pub struct ScopedOperationEnv {
/// Operation names this handler may compose (e.g., {"fs/readFile",
/// "vastai/listMachines"}). `env.invoke()` for any name not in this set
/// returns NOT_FOUND. This is the reachability boundary — it bounds the
/// parameterized-dispatch attack surface.
pub allowed_operations: HashSet<String>,
}
```
### 4. The registration bundle carries all three
The three controls from ADR-015 (visibility, composition authority, scoped
env) plus the capability injection from ADR-014 all enter the system at the
same boundary: the assembly layer hands the registry a `(spec, handler)` pair
*plus* the handler's runtime context material. This ADR makes that explicit
as a registration bundle.
```rust
pub struct HandlerRegistration {
pub spec: OperationSpec,
pub handler: Handler,
pub provenance: OperationProvenance,
/// Composition authority for this handler. `None` for leaves
/// (`FromOpenAPI`, `FromMCP`, `FromCall`) — they don't compose.
/// `Some(...)` for `Local` and `Session` ops that can compose children.
pub composition_authority: Option<CompositionAuthority>,
/// Scoped composition env. `None` for leaves — they get an empty
/// no-op env. `Some(...)` for composing ops.
pub scoped_env: Option<ScopedOperationEnv>,
/// Outbound credentials the handler may use (decrypted API keys, signing
/// keys, HTTP tokens). Populated by the assembly layer from the vault
/// at handler construction. See ADR-014.
pub capabilities: Capabilities,
}
```
The registry's `register` and builder's `with` accept a `HandlerRegistration`,
not a bare `(OperationSpec, Handler)` pair:
```rust
impl OperationRegistry {
pub fn register(&mut self, registration: HandlerRegistration);
}
impl OperationRegistryBuilder {
pub fn with(mut self, registration: HandlerRegistration) -> Self;
}
```
Adapter convenience methods (`from_openapi`, `from_mcp`, `from_call`)
construct `HandlerRegistration` with `composition_authority: None` and
`scoped_env: None` for the leaf ops they produce — the adapter doesn't grant
composition authority, and the assembly layer doesn't have to invent values
for leaves.
### 5. The dispatch path reads from the registration bundle
The CallAdapter's `build_root_context` and `OperationEnv::invoke()` read
composition authority, scoped env, and capabilities from the registration
bundle, looked up by operation name.
**`build_root_context` (wire-originated call, `internal: false`):**
```rust
fn build_root_context(
&self,
request_id: String,
operation_name: &str, // looked up in registry
identity: Option<Identity>, // resolved per-request from AuthContext/auth_token
) -> OperationContext {
let registration = self.registry.registration(operation_name);
OperationContext {
request_id,
parent_request_id: None,
identity, // caller's identity (inbound — gate credential)
handler_identity: registration.composition_authority, // C1: from bundle, None for leaves
capabilities: registration.capabilities.clone(), // C3: from bundle
metadata: HashMap::new(),
env: registration.scoped_env.clone()
.unwrap_or_else(ScopedOperationEnv::empty), // C2: from bundle, empty for leaves
internal: false, // wire call — ACL against caller identity
}
}
```
ACL for the root checks against `identity` (the caller's identity, resolved
per-request). `handler_identity` is on the context for *propagation* to
children, not for the root's own ACL.
**`OperationEnv::invoke()` (composition-originated call, `internal: true`):**
```rust
async fn invoke(&self, namespace: &str, operation: &str, input: Value,
parent: &OperationContext) -> ResponseEnvelope {
let name = format!("{namespace}/{operation}");
// Reachability check (C2): is this op in the parent's scoped env?
// If not, return NOT_FOUND. This is the reachability control.
if !parent.env.allows(&name) {
return ResponseEnvelope::not_found(name);
}
let registration = self.registry.registration(&name);
let context = OperationContext {
request_id: generate_request_id(),
parent_request_id: Some(parent.request_id.clone()),
identity: parent.handler_identity_as_identity(), // parent's authority becomes the caller
handler_identity: registration.composition_authority.clone(), // C1: child's own authority
capabilities: parent.capabilities.clone(), // C3: propagate through composition
metadata: HashMap::new(), // fresh — does NOT propagate (ADR-014)
env: registration.scoped_env.clone()
.unwrap_or_else(ScopedOperationEnv::empty), // C2: child's own scoped env
internal: true, // composition — ACL against handler_identity
};
self.registry.invoke(&name, input, context).await
}
```
Two things happen here:
1. **Reachability check**: before constructing the child context, `invoke()`
checks whether the requested op is in the parent's scoped env. If not,
`NOT_FOUND`. This bounds the parameterized-dispatch attack surface — a
handler (or an LLM picking tools) can only reach declared ops.
2. **Authority propagation**: the child's `identity` is the parent's
`handler_identity` (the parent's composition authority becomes the caller
for the child). The child's `handler_identity` is the *child's own*
registration's `composition_authority` — so if the child itself composes
further, its children inherit the child's authority. This is the
principal/agent chain from ADR-015, now wired.
ACL for the child checks against `handler_identity` (the child's composition
authority). For leaves, `handler_identity` is `None` — but leaves don't
compose, so their `handler_identity` is never used for ACL on a grandchild.
Leaves only have ACL checked against *themselves* (as the target of
composition), where the check is: does the parent's composition authority
satisfy the leaf's `AccessControl`?
### 6. Capabilities are per-request, populated from the bundle (Model A reconciled)
This ADR resolves the C3 ambiguity by adopting option (a) from the review:
capabilities are only per-request on `OperationContext`, populated by the
dispatch path from the per-handler capabilities in the registration bundle.
The construction-time "baking" described in ADR-014 L82 populates the
registration bundle's `capabilities` field — the handler closure does not
capture capabilities.
```rust
// Assembly layer: construct registration with capabilities from vault
let google_api_key = vault.decrypt(&google_key_blob)?;
let agent_registration = HandlerRegistration {
spec: agent_chat_spec(),
handler: Arc::new(agent_chat_handler), // closure captures nothing
provenance: OperationProvenance::Local,
composition_authority: Some(CompositionAuthority {
label: "agent-chat".into(),
scopes: vec!["llm:call".into(), "fs:read".into(), "vastai:query".into()],
resources: HashMap::new(),
}),
scoped_env: Some(ScopedOperationEnv {
allowed_operations: HashSet::from(["fs/readFile".into(), "vastai/listMachines".into(),
"llm/generate".into()]),
}),
capabilities: Capabilities::new()
.with_api_key("google", google_api_key), // C3: in the bundle, not the closure
};
```
The handler reads `context.capabilities` at call time. The dispatch path
populates it from `registration.capabilities`. Composition propagates it via
`parent.capabilities.clone()` in `invoke()`. No circular dependency, no
redundant models.
### 7. The three controls together (ADR-015's model, now wired)
| Control | What it gates | Where it's set | Without it |
|---------|--------------|----------------|-----------|
| Visibility (External/Internal) | Whether the op is callable from the wire | `OperationSpec.visibility` | Internal ops exposed to external callers |
| Composition authority | What authority internal calls run under | `HandlerRegistration.composition_authority` | ACL skipped or caller's scopes propagated (escalation) |
| Scoped env | What ops a handler can reach | `HandlerRegistration.scoped_env` | Handler can call anything in the registry (confused deputy) |
All three enter at registration. All three reach the dispatch path via the
registration bundle. The user's identity is the **gate credential** — checked
once at the External boundary. The composition authority is the **internal
credential** — used for all composition inside. The scoped env is the
**reachability boundary** — what the handler can even attempt to compose.
### 8. No intersection semantics
The user's authority does NOT limit internal calls. If the user has `chat` but
not `vastai:query`, `/agent/chat` composing `/vastai/listMachines` is NOT
denied because the user lacks `vastai:query`. The user's authority was
checked at the gate (`/agent/chat` requires `chat`, user has `chat`). Inside,
the handler runs under its own composition authority. The user's authority
does not propagate into internal calls.
This is the kernel/user model: `getaddrinfo` doesn't require the caller to
have `CAP_NET_RAW` to make DNS queries. The curated entry point exists
*because* it does things the user can't, on the user's behalf, under its own
authority.
If a handler *wants* to act on behalf of the user (e.g., a database proxy
that runs queries under the user's DB identity), that's a **handler-level
decision** — it reads `context.identity` and explicitly narrows its
behavior. That's delegated access, not automatic intersection. The system
shouldn't silently intersect; the handler should explicitly delegate.
## Consequences
**Positive:**
- The privilege model in ADR-015 is now implementable as specified. The
composition authority, scoped env, and capabilities all have registration
paths and dispatch-path wiring. No implementer has to invent the bridge.
- Leaves (`from_openapi`, `from_mcp`, `from_call`) don't get fake identities.
The assembly layer doesn't have to invent `Identity { id:
"vastai-listmachines-handler", scopes: [], resources: {} }` for forwarding
stubs that will never compose. `composition_authority: None` is natural for
leaves, not an oversight.
- External services can't self-grant composition authority. The OpenAPI spec
defines the operation interface (name, schemas, access control). The
*provenance* is set by the assembly layer when it runs `from_openapi`. The
*composition authority* is `None` for imported ops — the external service
can't grant itself scopes to compose into your registry. The assembly layer
is the sole grantor, and only for `Local` and `Session` ops.
- Capabilities have one model: per-request on `OperationContext`, populated
from the registration bundle. No closure-capture vs context duplication
ambiguity. The three consuming crates (call, agent, napi) can't diverge
because there's one wiring path.
- The graph model provides a precise structural framing without mandating a
graph library for v1. The operation graph, scoped subgraph, and call graph
are concepts that guide the API shape; HashMaps and HashSets are the v1
implementation. A future `alknet-flowgraph` crate can reify these as
petgraph structures when workflow templates and type-compatibility
validation are needed.
- The kernel/user analogy makes the security model legible. The user's
authority is the gate credential (checked once at External entry). The
composition authority is the internal credential (used for all
composition inside). The scoped env is the reachability boundary (what the
handler can attempt to compose). This is the same model every OS uses, and
it's been battle-tested.
**Negative:**
- The registration API changes from `register(spec, handler)` to
`register(HandlerRegistration)`. This is a breaking change to the API
surface shown in operation-registry.md, but since no implementation exists
yet, it's a spec edit, not a migration.
- `CompositionAuthority` is a new type, distinct from `Identity`. This adds a
type to alknet-call. It's not a peer identity — it's a declared authority
bundle. The distinction from `Identity` is intentional and necessary (a
handler is not a network peer), but it means the codebase has two
scope-bearing types. Mitigated: they serve different roles and don't
converge — `Identity` is inbound (resolved from credentials via
`IdentityProvider`), `CompositionAuthority` is declared (set by the
assembly layer at registration).
- The assembly layer has more registration-time responsibility: it must
declare each handler's provenance, composition authority, and scoped env.
This is expected — the assembly layer assembles everything (ADR-008), and
forcing explicit declaration of privilege is a feature, not a bug. An
`OperationRegistryBuilder` convenience API can reduce boilerplate for
common cases (e.g., `.with_local(spec, handler, authority, env,
capabilities)` vs `.with_leaf(spec, handler, capabilities)`).
- The dispatch path does a registry lookup per call (to fetch the
registration bundle's composition authority, scoped env, and capabilities).
This is a `HashMap` lookup — negligible cost. The alternative (baking
everything into the handler closure) creates the C3 ambiguity. The lookup
is the right trade.
**Validation strategy:**
The security model should be validated by fuzzing. A fuzzer that generates
call trees (valid and invalid compositions, different provenance mixes, edge
cases around the gate) and asserts "no path through the call graph lets a
user with scope X reach an operation requiring Y without going through a gate
that checks X" would catch the class of privilege-escalation bug this ADR is
designed to prevent. The typebox-rs fake data generator can produce valid and
invalid inputs from JSON Schemas; with minor edits it can output invalid
inputs or a mix of valid/invalid, enabling property-based testing of the ACL
model. This is a downstream concern — the spec needs to be right first, then
the fuzzer validates the implementation against the spec.
## Assumptions
1. **Internal calls should run under a different authority than external
calls, not skip ACL entirely.** Inherited from ADR-015. The escalation
vectors (buggy handler, parameterized dispatch) are real and must be
prevented.
2. **Provenance is knowable at registration time.** The assembly layer knows
whether an op is `Local`, `FromOpenAPI`, `FromMCP`, `FromCall`, or
`Session` when it registers the op — the adapter that produced the
`(OperationSpec, Handler)` pair knows its own type. If a future use case
requires provenance to be discovered at call time, the model needs
extension.
3. **Composition reachability is knowable at registration time.** The
assembly layer can declare which operations a handler may compose when it
registers the handler. If a use case requires fully dynamic scoping
(handler discovers at call time what it can compose), the model needs
extension — but the assumption is that composition reachability is
knowable at registration time for `Local` ops, and at sandbox creation
time for `Session` ops.
4. **The assembly layer is the trust boundary.** The assembly layer declares
provenance, composition authority, and scoped env. If the assembly layer
is compromised, all handler authority is compromised. This is the same
trust boundary as ADR-008 and ADR-014.
5. **Leaves don't compose.** `FromOpenAPI`, `FromMCP`, and `FromCall` ops are
forwarding stubs — they take input, forward it (over HTTP, MCP, or QUIC),
and return output. They don't call `env.invoke()`. If a future use case
requires an imported op to compose (e.g., a `from_call` op that locally
composes other ops before forwarding), its provenance would need to change
to `Local` (it's no longer a pure forwarding stub), or the model needs a
hybrid provenance.
6. **`Session` ops compose under restricted authority.** Session ops
(agent-written, OQ-19) get composition authority scoped down by the parent
handler at sandbox creation (ADR-015's "dynamic scoping at sandbox
creation"). The assembly layer grants the sandbox's parent handler a
composition authority; the parent handler scopes it down further when
creating the sandbox. The session op's composition authority is a subset
of the parent's.
## References
- ADR-014: Secret material flow and capability injection (capabilities are
orthogonal to identity — both set at registration; this ADR specifies the
registration path ADR-014 left as a two-way door)
- ADR-015: Privilege model and authority context (this ADR refines
Assumption 6 — composition authority is not a peer `Identity`; and wires
the three controls that ADR-015 specified but left without registration
paths)
- ADR-016: Abort cascade for nested calls (the call graph is the abort
cascade tree; `parent_request_id` indexes it)
- ADR-017: Call protocol client and adapter contract (adapter-registered
ops are `Internal` by default; this ADR's provenance makes that explicit)
- ADR-008: Vault integration point (assembly layer is the trust boundary)
- OQ-19: Session-scoped operation registries (session ops are `Session`
provenance, always `Internal`, compose under restricted authority)
- docs/reviews/001-pre-implementation-architecture-sanity-check.md (findings
C1C4, which this ADR resolves)
- `/workspace/@alkdev/flowgraph/README.md` — operation graph, call graph, and
scoped subgraph concepts (the graph model this ADR uses as framing)
- `/workspace/@alkdev/alknet-main/docs/architecture/flowgraph.md` — prior
Rust speccing of flowgraph (incomplete; this ADR uses the model, not the
crate)
- Kernel/user mode analogy: `getaddrinfo` runs under kernel authority, not
the caller's `CAP_NET_RAW`; the curated entry point exists to do things the
user can't, on the user's behalf

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-19
last_updated: 2026-06-20
---
# Open Questions
@@ -292,3 +292,12 @@ These questions are acknowledged but not active. They will be promoted to open w
- **Priority**: medium
- **Resolution**: Key rotation uses version-indexed derivation paths. Each key version maps to a distinct SLIP-0010 path: `m/74'/2'/0'/{version-2}'`. v2 (current) is at `m/74'/2'/0'/0'`; v3 is at `m/74'/2'/0'/1'`; etc. The `decrypt` method derives the key at the path indicated by `encrypted.key_version` (not always at `PATHS::ENCRYPTION`). The `rotate` method decrypts with the old version's key and re-encrypts with the new version's key — no new mnemonic needed. The assembly layer or a migration tool iterates stored blobs and calls `rotate` on each; the vault does not self-rotate. Partial rotation is safe (old keys remain derivable). See ADR-021.
- **Cross-references**: ADR-020, ADR-021, [encryption.md](crates/vault/encryption.md), [service.md](crates/vault/service.md)
### OQ-23: Handler Identity Registration Path and Composition Authority
- **Origin**: [operation-registry.md](crates/call/operation-registry.md), [call-protocol.md](crates/call/call-protocol.md), ADR-015
- **Status**: resolved
- **Door type**: One-way (security model), two-way (bundle shape)
- **Priority**: high
- **Resolution**: ADR-015 said handler identity was "set at registration by the assembly layer" but the registration API (`register(spec, handler)`) had no place for it — meaning every internal call would check ACL against `None`, reproducing the escalation gap ADR-015 was written to close. ADR-022 resolves this with a registration bundle (`HandlerRegistration`) carrying `provenance`, `composition_authority` (replacing `handler_identity: Identity` — it's a declared authority bundle, not a peer identity), `scoped_env`, and `capabilities`. The dispatch path (`build_root_context` and `OperationEnv::invoke()`) reads from the bundle. Provenance determines which ops can compose: only `Local` and `Session` get composition authority; leaves (`FromOpenAPI`, `FromMCP`, `FromCall`) get `None` — they don't compose, so they don't need it. Capabilities are per-request on `OperationContext`, populated from the bundle (resolving the closure-capture vs context ambiguity). The kernel/user analogy: user's authority checked once at the External gate; handler's composition authority used for all composition inside; scoped env bounds reachability. No intersection — the user's authority does not limit internal calls. See ADR-022.
- **Cross-references**: ADR-014, ADR-015, ADR-022, docs/reviews/001-pre-implementation-architecture-sanity-check.md (C1C4), [operation-registry.md](crates/call/operation-registry.md), [call-protocol.md](crates/call/call-protocol.md)

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-19
last_updated: 2026-06-20
---
# Alknet Overview
@@ -212,6 +212,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [019](decisions/019-vault-assembly-layer-only.md) | Vault Assembly-Layer-Only Access | The assembly layer (CLI binary) is the sole direct caller; handlers never hold a vault reference |
| [020](decisions/020-hd-derivation-for-encryption-keys.md) | HD Derivation for Encryption Keys | SLIP-0010 derivation from seed, not PBKDF2; salt field unused in v2 |
| [021](decisions/021-key-rotation-via-version-indexed-paths.md) | Key Rotation via Version-Indexed Paths | Version-indexed derivation paths; `rotate` re-encrypts between versions |
| [022](decisions/022-handler-registration-provenance-and-composition-authority.md) | Handler Registration, Provenance, and Composition Authority | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
## Open Questions