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:
@@ -1,13 +1,13 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-06-19
|
last_updated: 2026-06-20
|
||||||
---
|
---
|
||||||
|
|
||||||
# Alknet Architecture
|
# Alknet Architecture
|
||||||
|
|
||||||
## Current State
|
## 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 (001–021) 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 (001–022) 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.
|
**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 |
|
| [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 |
|
| [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 |
|
| [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
|
## 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-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-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-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):**
|
**Deferred (not active):**
|
||||||
- **OQ-09**: WASM target boundaries — design constraint, not deliverable
|
- **OQ-09**: WASM target boundaries — design constraint, not deliverable
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-06-21
|
last_updated: 2026-06-22
|
||||||
---
|
---
|
||||||
|
|
||||||
# alknet-call
|
# 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 |
|
| [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 |
|
| [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 |
|
| [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
|
## 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.
|
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.
|
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.
|
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.
|
||||||
@@ -262,24 +262,30 @@ The `CallAdapter` receives an `AuthContext` from the endpoint. The call protocol
|
|||||||
|
|
||||||
### Root OperationContext Construction
|
### 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
|
```rust
|
||||||
// CallAdapter dispatch path — root context for an incoming wire request
|
// CallAdapter dispatch path — root context for an incoming wire request
|
||||||
fn build_root_context(
|
fn build_root_context(
|
||||||
&self,
|
&self,
|
||||||
request_id: String,
|
request_id: String,
|
||||||
identity: Option<Identity>, // resolved per-request above
|
operation_name: &str, // looked up in registry for the registration bundle
|
||||||
capabilities: Capabilities, // the CallAdapter's own capabilities (if any)
|
identity: Option<Identity>, // resolved per-request above (caller's identity)
|
||||||
) -> OperationContext {
|
) -> OperationContext {
|
||||||
|
let registration = self.registry.registration(operation_name);
|
||||||
OperationContext {
|
OperationContext {
|
||||||
request_id,
|
request_id,
|
||||||
parent_request_id: None, // wire request — top of the call tree
|
parent_request_id: None, // wire request — top of the call tree
|
||||||
identity: identity.clone(), // caller's identity (inbound)
|
identity: identity.clone(), // caller's identity (inbound — gate credential)
|
||||||
handler_identity: None, // no composition authority — wire call
|
// Composition authority from the registration bundle (ADR-022).
|
||||||
capabilities,
|
// 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
|
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
|
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 |
|
| 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 |
|
| 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 |
|
| 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
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
### AccessControl
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ pub struct OperationContext {
|
|||||||
pub request_id: String,
|
pub request_id: String,
|
||||||
pub parent_request_id: Option<String>,
|
pub parent_request_id: Option<String>,
|
||||||
pub identity: Option<Identity>, // Caller's identity (inbound — who invoked me)
|
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 capabilities: Capabilities,
|
||||||
pub metadata: HashMap<String, Value>,
|
pub metadata: HashMap<String, Value>,
|
||||||
pub env: OperationEnv,
|
pub env: OperationEnv,
|
||||||
@@ -119,46 +119,74 @@ impl OperationContext {
|
|||||||
- `request_id`: Correlates with the `call.requested` event's `id` field
|
- `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)
|
- `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()`)
|
- `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
|
- `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`.
|
- `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.
|
- `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
|
### OperationRegistry
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub struct OperationRegistry {
|
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
|
- `register(registration)`: Add an operation at startup
|
||||||
- `lookup(name)`: Find an operation by name, returning spec and handler
|
- `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
|
- `invoke(name, input, context)`: Look up, check ACL, invoke handler, return result
|
||||||
- `list_operations()`: Return all registered specs (for `/services/list`)
|
- `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
|
```rust
|
||||||
let registry = OperationRegistryBuilder::new()
|
let registry = OperationRegistryBuilder::new()
|
||||||
.with(services_list_spec(), Arc::new(services_list_handler))
|
// Built-in service discovery (Local, no composition)
|
||||||
.with(services_schema_spec(), Arc::new(schema_handler))
|
.with_local(services_list_spec(), Arc::new(services_list_handler),
|
||||||
.with(agent_chat_spec(), Arc::new(agent_chat_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();
|
.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
|
||||||
|
|
||||||
`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.
|
`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.
|
**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 {
|
impl OperationEnv for LocalOperationEnv {
|
||||||
async fn invoke(&self, namespace: &str, operation: &str, input: Value, parent: &OperationContext) -> ResponseEnvelope {
|
async fn invoke(&self, namespace: &str, operation: &str, input: Value, parent: &OperationContext) -> ResponseEnvelope {
|
||||||
let name = format!("{namespace}/{operation}");
|
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 {
|
let context = OperationContext {
|
||||||
// Unique per invocation — a counter, UUID, or parent_id + suffix.
|
// Unique per invocation — a counter, UUID, or parent_id + suffix.
|
||||||
// A deterministic ID (e.g. format!("env-{name}")) collides across
|
// 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.
|
// (ADR-016), which is indexed by parent_request_id.
|
||||||
request_id: generate_request_id(),
|
request_id: generate_request_id(),
|
||||||
parent_request_id: Some(parent.request_id.clone()),
|
parent_request_id: Some(parent.request_id.clone()),
|
||||||
identity: parent.handler_identity.clone(), // Parent's handler identity becomes the caller
|
// Parent's composition authority becomes the caller for the child.
|
||||||
handler_identity: parent.handler_identity.clone(), // Inherit handler authority for ACL
|
// 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
|
capabilities: parent.capabilities.clone(), // Inherit caller's capabilities
|
||||||
metadata: HashMap::new(), // Fresh — does NOT propagate parent metadata (ADR-014)
|
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
|
internal: true, // Nested calls use handler authority
|
||||||
};
|
};
|
||||||
self.registry.invoke(&name, input, context).await
|
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.
|
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.
|
**`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
|
### 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
|
```rust
|
||||||
// Assembly layer: unlock vault, derive credentials, construct handlers
|
// Assembly layer: unlock vault, derive credentials
|
||||||
let vault = VaultServiceHandle::new();
|
let vault = VaultServiceHandle::new();
|
||||||
vault.unlock(&mnemonic, passphrase.as_deref())?;
|
vault.unlock(&mnemonic, passphrase.as_deref())?;
|
||||||
let google_api_key = vault.decrypt(&google_key_blob)?;
|
let google_api_key = vault.decrypt(&google_key_blob)?;
|
||||||
let github_signing_key = vault.derive_ed25519(PATHS::GITHUB_SIGNING)?;
|
let github_signing_key = vault.derive_ed25519(PATHS::GITHUB_SIGNING)?;
|
||||||
|
let vastai_credentials = Capabilities::new().with_http_token("vastai", vastai_token);
|
||||||
// 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
|
// Register operations — vault operations are NOT registered here
|
||||||
let registry = OperationRegistryBuilder::new()
|
let registry = OperationRegistryBuilder::new()
|
||||||
// Built-in service discovery
|
// Built-in service discovery (Local, no composition)
|
||||||
.with(services_list_spec(), Arc::new(services_list_handler))
|
.with_local(services_list_spec(), Arc::new(services_list_handler),
|
||||||
.with(services_schema_spec(), Arc::new(schema_handler))
|
CompositionAuthority::none(), ScopedOperationEnv::empty())
|
||||||
// Agent and GitHub handlers (constructed with injected capabilities)
|
.with_local(services_schema_spec(), Arc::new(schema_handler),
|
||||||
.with(agent_chat_spec(), agent_handler)
|
CompositionAuthority::none(), ScopedOperationEnv::empty())
|
||||||
.with(github_authenticate_spec(), github_handler)
|
// 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();
|
.build();
|
||||||
|
|
||||||
let call_adapter = CallAdapter::new(Arc::new(registry), identity_provider);
|
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
|
### 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:
|
The flow is:
|
||||||
|
|
||||||
@@ -280,31 +338,34 @@ The flow is:
|
|||||||
Assembly layer (CLI startup):
|
Assembly layer (CLI startup):
|
||||||
1. Unlock vault (local, mnemonic from secure prompt or file)
|
1. Unlock vault (local, mnemonic from secure prompt or file)
|
||||||
2. Derive / decrypt the credentials each handler needs
|
2. Derive / decrypt the credentials each handler needs
|
||||||
3. Construct handlers with those credentials
|
3. Construct HandlerRegistration bundles with capabilities from the vault
|
||||||
4. Register operations with the constructed handlers
|
4. Register the bundles in the OperationRegistry
|
||||||
5. Start the endpoint
|
5. Start the endpoint
|
||||||
|
|
||||||
Handler invocation (at call time):
|
Handler invocation (at call time):
|
||||||
call.requested → OperationContext { capabilities, identity, ... }
|
call.requested → CallAdapter looks up registration by op name
|
||||||
handler reads capabilities → uses the credential for its outbound call
|
→ 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:
|
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.
|
- 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.
|
- 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.
|
**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` 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.
|
**`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
|
## 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.
|
- 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.
|
- `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.
|
- 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 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-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-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-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
|
## References
|
||||||
|
|
||||||
|
|||||||
@@ -67,9 +67,10 @@ credential encryption:
|
|||||||
decrypting multiple credentials at startup.
|
decrypting multiple credentials at startup.
|
||||||
|
|
||||||
4. **Domain separation via paths.** Different encryption purposes can use
|
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'`
|
different derivation paths (`m/74'/2'/0'/0'` for v2, `m/74'/2'/0'/1'`
|
||||||
for a future v2). PBKDF2 has no equivalent — the only versioning knob is
|
for a future v3). PBKDF2 has no equivalent — the only versioning knob is
|
||||||
the iteration count or the password.
|
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
|
5. **The salt becomes unnecessary for key derivation.** HD derivation
|
||||||
doesn't need a salt — the path provides domain separation. The salt
|
doesn't need a salt — the path provides domain separation. The salt
|
||||||
|
|||||||
@@ -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 C1–C4) 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
|
||||||
|
C1–C4, 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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-06-19
|
last_updated: 2026-06-20
|
||||||
---
|
---
|
||||||
|
|
||||||
# Open Questions
|
# Open Questions
|
||||||
@@ -292,3 +292,12 @@ These questions are acknowledged but not active. They will be promoted to open w
|
|||||||
- **Priority**: medium
|
- **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.
|
- **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)
|
- **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 (C1–C4), [operation-registry.md](crates/call/operation-registry.md), [call-protocol.md](crates/call/call-protocol.md)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-06-19
|
last_updated: 2026-06-20
|
||||||
---
|
---
|
||||||
|
|
||||||
# Alknet Overview
|
# 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 |
|
| [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 |
|
| [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 |
|
| [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
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user