docs(architecture): rename trusted to internal, add OQ-17 abort cascade and OQ-18 privilege model
The 'trusted' flag on OperationContext was the wrong word — it implies a trust decision was made, but what actually happens is the call originated internally (from composition) not externally (from the wire). Renamed to 'internal' with clarified semantics: internal calls switch authority context to the handler's identity, not skip ACL. This prevents the privilege escalation vector where composition with 'trusted: true' bypassed all access control (buggy handler + parameterized dispatch). - Rename trusted -> internal across operation-registry.md, ADR-014 - Update OperationContext field description and LocalOperationEnv code - Add OQ-17: abort cascade for nested calls (call.aborted cascades to descendants, default abort-dependents, continue-running opt-in). One-way door on the protocol event schema; mechanism is a two-way door. - Add OQ-18: privilege model and authority context (internal = authority switch not ACL skip, External/Internal operation visibility, scoped composition env + handler identity). Needs agent crate in view. - Add abort cascade section and constraint to call-protocol.md - Update crates/call/README.md with OQ-17, OQ-18, and two new design principles - Update architecture README.md with OQ-17, OQ-18
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-18
|
||||
last_updated: 2026-06-19
|
||||
---
|
||||
|
||||
# Alknet Architecture
|
||||
@@ -70,6 +70,8 @@ See [open-questions.md](open-questions.md) for the full tracker.
|
||||
|
||||
**Open one-way doors (need ADR before implementation):**
|
||||
- **OQ-15**: Call protocol client and adapter contract — alknet-call needs both the server (CallAdapter) and client (call invocation over QUIC), plus the adapter contract traits (from_*, to_*) that enable composition. ADR-014 constrains the adapter contract: adapters take credential sources from the assembly layer, not static tokens.
|
||||
- **OQ-17**: Abort cascade semantics — `call.aborted` cascades to descendants. Default `abort-dependents`, `continue-running` opt-in. One-way door on the event schema; mechanism is a two-way door.
|
||||
- **OQ-18**: Privilege model and authority context — `internal` flag switches authority to handler identity, not blanket ACL skip. Operations have External/Internal visibility. Scoped composition env + handler identity. Needs agent crate in view.
|
||||
|
||||
**Deferred (not active):**
|
||||
- **OQ-09**: WASM target boundaries — design constraint, not deliverable
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-18
|
||||
last_updated: 2026-06-19
|
||||
---
|
||||
|
||||
# alknet-call
|
||||
@@ -39,6 +39,8 @@ Structured RPC over QUIC: operations, request/response, streaming subscriptions,
|
||||
| OQ-14 | Batch operation semantics | resolved | Correlated `call.requested` events is the correct protocol design |
|
||||
| OQ-15 | Call protocol client and adapter contract | open | ADR-014 constrains adapters: credential sources, not static tokens |
|
||||
| OQ-16 | Safe vault operations for call protocol exposure | resolved (ADR-014) | None exposed for now |
|
||||
| OQ-17 | Abort cascade semantics | open | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in. One-way door on event schema |
|
||||
| OQ-18 | Privilege model and authority context | open | `internal` flag switches authority to handler identity, not blanket ACL skip. External/Internal operation visibility. Scoped composition env + handler identity. Needs agent crate in view |
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
@@ -48,4 +50,6 @@ Structured RPC over QUIC: operations, request/response, streaming subscriptions,
|
||||
4. **Operation registry is static**: Operations are registered at startup by the CLI binary. The registry supports JSON Schema discovery.
|
||||
5. **irpc is one dispatch backend**: Local operations dispatch directly. irpc service calls (in-process, type-safe) are internal. The call protocol is the external interface.
|
||||
6. **Local dispatch only**: The operation registry dispatches to local handlers. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer, not a modification to alknet-call's path format.
|
||||
7. **No secret material on the wire**: The call protocol carries no private keys, API keys, mnemonics, or decrypted credentials. Handlers receive outbound credentials through `OperationContext.capabilities`, injected at the assembly layer. See ADR-014.
|
||||
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 OQ-17.
|
||||
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. See OQ-18.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-18
|
||||
last_updated: 2026-06-19
|
||||
---
|
||||
|
||||
# Call Protocol
|
||||
@@ -271,6 +271,16 @@ Local dispatch produces `ResponseEnvelope` with no serialization overhead. The `
|
||||
|
||||
**Error handling in `CallAdapter::handle()`**: If a handler panics, the stream is closed and the `PendingRequestMap` entry (if any) is cleaned up by the next sweeper pass. Other streams and the connection are unaffected.
|
||||
|
||||
### Abort Cascade and Nested Calls
|
||||
|
||||
When a handler composes other operations via `OperationEnv::invoke()`, it creates a call tree: a parent request (r1) spawns children (r1-a, r1-b), which may spawn their own children. The `parent_request_id` field on `OperationContext` records this tree.
|
||||
|
||||
When `call.aborted` arrives for a parent request, the protocol cascades the abort to all non-terminal descendants in the tree. The default policy is **`abort-dependents`**: aborting a request aborts everything downstream, regardless of branch. This is the correct default because aborted parent work has no consumer waiting for results — continuing is wasted work at best and unwanted side effects at worst (e.g., a `bash/exec` that keeps running after the caller stopped caring).
|
||||
|
||||
An opt-in **`continue-running`** policy is available for cases where long-running work should survive a parent's abort (e.g., a subscription that should keep streaming). The caller or handler specifies the policy at call time.
|
||||
|
||||
The one-way door is the protocol event schema: `call.aborted` must carry cascade semantics before implementation, because retrofitting cascade onto a non-cascading abort is a breaking protocol change. The mechanism — how the runtime discovers descendants and propagates cancellation (cancellation tokens, parent-indexed map, or a separate graph structure) — is a two-way door for implementation. See OQ-17.
|
||||
|
||||
## Constraints
|
||||
|
||||
- The call protocol does not depend on any database. `PendingRequestMap` is in-memory. Durable session storage is a consumer concern.
|
||||
@@ -279,6 +289,7 @@ Local dispatch produces `ResponseEnvelope` with no serialization overhead. The `
|
||||
- The call protocol is transport-agnostic at the envelope level. The `EventEnvelope` framing can run over QUIC streams, WebSocket frames, or Worker `postMessage`. The `CallAdapter` is the QUIC-specific implementation.
|
||||
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer. See ADR-005 and OQ-13.
|
||||
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials, raw tokens) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. The wire format carries `serde_json::Value` and cannot enforce this at the type level — the constraint is architectural, enforced by the operation registry and by convention. Operations that need to share public key material use a dedicated operation that returns only the public component. See ADR-014.
|
||||
- **Abort cascades to descendants.** `call.aborted` for a parent request cascades to all non-terminal descendants in the call tree. Default policy is `abort-dependents`; `continue-running` is an opt-in. See OQ-17.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
@@ -299,6 +310,8 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
- **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
|
||||
- **OQ-15** (open): Call protocol client and adapter contract. ADR-014 constrains the adapter contract: adapters take credential sources from the assembly layer, not static tokens.
|
||||
- **OQ-16** (resolved by ADR-014): No vault operations are exposed over the call protocol for now.
|
||||
- **OQ-17** (open): Abort cascade semantics — `call.aborted` cascades to descendants, default `abort-dependents`, `continue-running` opt-in. One-way door on the event schema; mechanism is a two-way door.
|
||||
- **OQ-18** (open): Privilege model and authority context — `internal` flag switches authority to handler identity, not blanket ACL skip. Operations have External/Internal visibility. Scoped composition env + handler identity. Needs agent crate in view.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-18
|
||||
last_updated: 2026-06-19
|
||||
---
|
||||
|
||||
# Operation Registry
|
||||
@@ -69,7 +69,7 @@ When a `call.requested` event arrives:
|
||||
|
||||
Operations with empty `AccessControl` (no required scopes, no resource checks) are accessible to all callers, including unauthenticated ones.
|
||||
|
||||
**Trusted calls skip ACL**: When a handler invokes another operation through `OperationEnv`, the nested call is marked `trusted: true` and skips access control checks. This prevents double-checking: if `/agent/chat` is allowed and it internally calls `/auth/verify`, the auth check is trusted.
|
||||
**Internal calls and authority context**: When a handler invokes another operation through `OperationEnv`, the nested call is marked `internal: true`, meaning it originated from composition (not from a wire request). The `internal` flag switches the authority context: the ACL check runs against the composing handler's identity (set at registration), not the caller's identity and not as a blanket skip. This prevents privilege escalation through composition — a handler can only compose operations its own identity is authorized for. See OQ-18 for the full privilege model, which is open pending the agent crate spec.
|
||||
|
||||
### Handler
|
||||
|
||||
@@ -95,7 +95,7 @@ pub struct OperationContext {
|
||||
pub capabilities: Capabilities,
|
||||
pub metadata: HashMap<String, Value>,
|
||||
pub env: OperationEnv,
|
||||
pub trusted: bool,
|
||||
pub internal: bool,
|
||||
}
|
||||
```
|
||||
|
||||
@@ -105,9 +105,9 @@ pub struct OperationContext {
|
||||
- `capabilities`: Outbound credentials the handler may use (decrypted API keys, scoped vault access) — see [Capability Injection](#capability-injection) below
|
||||
- `metadata`: Additional context (connection info, tracing IDs). **Must not hold secret material** — see ADR-014
|
||||
- `env`: The operation environment for composing calls to other operations
|
||||
- `trusted`: When `true`, ACL checks are skipped (set by `OperationEnv`, not by callers). The `trusted` field uses module-private construction — handlers construct `OperationContext` through `OperationEnv::invoke()` which sets `trusted: true`, or through the `CallAdapter` dispatch path which sets `trusted: false`. The field is not `pub` for writes; only `pub fn is_trusted(&self) -> bool` is exposed for reads.
|
||||
- `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: the ACL check runs against the composing handler's identity, not the caller's and not as a blanket skip. 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 OQ-18.
|
||||
|
||||
`identity` and `capabilities` are orthogonal: identity is inbound (resolved per-request from the caller's credentials), capabilities are outbound (provisioned by the assembly layer from the vault). See ADR-014 for the full rationale.
|
||||
`identity` and `capabilities` are orthogonal: identity is inbound (resolved per-request from the caller's credentials), capabilities are outbound (provisioned by the assembly layer from the vault). See ADR-014 for the full rationale. The `internal` flag governs which authority applies to composition — see OQ-18 for the privilege model.
|
||||
|
||||
### OperationRegistry
|
||||
|
||||
@@ -147,7 +147,7 @@ pub trait OperationEnv: Send + Sync {
|
||||
|
||||
`OperationEnv` is the universal composition mechanism. A handler calls `context.env.invoke("fs", "readFile", input, &context)` and gets a `ResponseEnvelope` back — regardless of whether the operation runs locally, via an irpc service, or on a remote node.
|
||||
|
||||
The `parent` parameter propagates the calling context: the nested call gets `parent_request_id: Some(parent.request_id)`, inherits `parent.identity`, and is marked `trusted: true`.
|
||||
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`.
|
||||
|
||||
**Local dispatch only.** The initial `OperationEnv` implementation dispatches directly through the local `OperationRegistry`:
|
||||
|
||||
@@ -167,7 +167,7 @@ impl OperationEnv for LocalOperationEnv {
|
||||
capabilities: parent.capabilities.clone(), // Inherit caller's capabilities
|
||||
metadata: parent.metadata.clone(), // Inherit caller's metadata
|
||||
env: self.clone(),
|
||||
trusted: true, // Nested calls skip ACL
|
||||
internal: true, // Nested calls use handler authority
|
||||
};
|
||||
self.registry.invoke(&name, input, context).await
|
||||
}
|
||||
@@ -268,7 +268,7 @@ Handler invocation (at call time):
|
||||
|
||||
The `Capabilities` type holds non-serializable, zeroized secret material. It does not implement `Serialize` — it cannot cross the call protocol wire even by accident. The concrete shape of the type (a typed map, a struct with named fields, a trait object) is a two-way door for implementation. The one-way constraints are fixed by ADR-014:
|
||||
|
||||
- Capabilities are populated by the assembly layer at handler construction (the common case: a static decrypted API key) or scoped per-request for trusted-internal-only flows. They are never populated from call protocol inputs.
|
||||
- Capabilities 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 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.
|
||||
|
||||
@@ -282,7 +282,7 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
|
||||
- Operation specs use JSON Schema. The call protocol's external interface is always JSON. irpc's postcard serialization is internal only.
|
||||
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer — not a prefix added to operation paths. irpc service dispatch is contracted but not built.
|
||||
- The call protocol does not depend on any database. Operation specs are in-memory, populated at startup.
|
||||
- `OperationContext.trusted` is set by `OperationEnv`, not by callers. A handler cannot mark its own call as trusted.
|
||||
- `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 OQ-18.
|
||||
- **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.
|
||||
|
||||
@@ -304,6 +304,8 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
- **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
|
||||
- **OQ-15** (open): Call protocol client and adapter contract. ADR-014 constrains the adapter contract: adapters take credential sources from the assembly layer, not static tokens.
|
||||
- **OQ-16** (resolved by ADR-014): No vault operations are exposed over the call protocol for now.
|
||||
- **OQ-17** (open): Abort cascade semantics — `call.aborted` cascades to descendants, default `abort-dependents`, `continue-running` opt-in. One-way door on the event schema; mechanism is a two-way door.
|
||||
- **OQ-18** (open): Privilege model and authority context — `internal` flag switches authority to handler identity, not blanket ACL skip. Operations have External/Internal visibility. Scoped composition env + handler identity. Needs agent crate in view.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ type is a two-way door — to be decided during implementation of the
|
||||
appear in `EventEnvelope` payloads.
|
||||
- Capabilities are injected at handler construction (the common case: a static
|
||||
decrypted API key held for the handler's lifetime) or scoped per-request for
|
||||
trusted-internal-only flows. They are never populated from call protocol
|
||||
internal-only flows. They are never populated from call protocol
|
||||
inputs.
|
||||
|
||||
**3. The call protocol carries no secret material.**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-18
|
||||
last_updated: 2026-06-19
|
||||
---
|
||||
|
||||
# Open Questions
|
||||
@@ -181,4 +181,43 @@ These questions are acknowledged but not active. They will be promoted to open w
|
||||
- **Door type**: One-way
|
||||
- **Priority**: high
|
||||
- **Resolution**: No vault operations are exposed over the call protocol for now. The vault is accessed only at the assembly layer (CLI binary at startup). Handlers receive secret material through `OperationContext.capabilities`, not by calling vault operations over the wire. The `operation-registry.md` spec previously showed `vault/derive`, `vault/unlock`, and `vault/decrypt` registered as call protocol operations — that was a contradiction with ADR-008's "capability source" model and has been corrected. If a future use case requires exposing a vault operation over the call protocol (e.g., a restricted `vault/public-key` operation that returns only public key material for identity verification), it would require its own ADR with an explicit threat model justification. See ADR-014.
|
||||
- **Cross-references**: ADR-008, ADR-014, [operation-registry.md](crates/call/operation-registry.md)
|
||||
- **Cross-references**: ADR-008, ADR-014, [operation-registry.md](crates/call/operation-registry.md)
|
||||
|
||||
### OQ-17: Abort Cascade Semantics for Nested Calls
|
||||
|
||||
- **Origin**: [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md)
|
||||
- **Status**: open
|
||||
- **Door type**: One-way (protocol schema), two-way (mechanism)
|
||||
- **Priority**: high
|
||||
- **Resolution**: When a handler composes other operations via `OperationEnv::invoke()`, it creates a call tree (parent → children via `parent_request_id`). When `call.aborted` arrives for a parent request, the protocol cascades the abort to all non-terminal descendants in the tree. The default policy is `abort-dependents`: aborting a request aborts everything downstream, regardless of branch. This is the correct default because aborted parent work has no consumer waiting for results — continuing is wasted work at best and unwanted side effects at worst (e.g., a `bash/exec` that keeps running after the caller stopped caring). An opt-in `continue-running` policy is available for cases where long-running work should survive a parent's abort (e.g., a subscription that should keep streaming).
|
||||
|
||||
The one-way door is the protocol event schema: `call.aborted` must carry cascade semantics before implementation, because retrofitting cascade onto a non-cascading abort is a breaking protocol change (existing clients send `call.aborted` for one ID, the server processes one ID). The mechanism — how the runtime discovers descendants and propagates cancellation (cancellation tokens propagated through `OperationContext`, a parent-indexed map in `PendingRequestMap`, or a separate graph structure consuming call events) — is a two-way door for implementation. The `@alkdev/flowgraph` TypeScript package demonstrates a reactive call-graph approach (directed graph with `descendants()`, `FailurePolicy: "abort-dependents" | "continue-running"`, signal-based status propagation); a Rust adaptation could use `petgraph` for the graph structure or tokio `CancellationToken` for a simpler implicit tree. The flowgraph may live as a separate crate consuming call events (as the TS version does), not necessarily inside alknet-call.
|
||||
|
||||
The agent use case (parameterized dispatch via LLM tool selection, potentially deep and dynamic call trees, quickjs runtime for agents writing their own operations) makes this urgent — deep nested calls that can't be cleanly aborted would leak resources and produce unwanted side effects.
|
||||
|
||||
This OQ will be resolved with an ADR before alknet-call implementation begins.
|
||||
- **Cross-references**: ADR-012, [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md)
|
||||
|
||||
### OQ-18: Privilege Model and Authority Context
|
||||
|
||||
- **Origin**: [operation-registry.md](crates/call/operation-registry.md)
|
||||
- **Status**: open
|
||||
- **Door type**: One-way (ACL model), two-way (specific APIs)
|
||||
- **Priority**: high
|
||||
- **Resolution**: The `internal` flag on `OperationContext` marks calls that originated from composition (a handler calling another operation via `OperationEnv`), as opposed to external calls that arrived as `call.requested` from a wire client. The `internal` flag switches the authority context: the ACL check runs against the composing handler's identity (set at registration), not the caller's identity and not as a blanket skip. This replaces the previous `trusted` flag, which skipped ACL entirely — a privilege escalation vector.
|
||||
|
||||
The model has two analogies: kernel/user mode (external operations are syscalls — curated entry points; internal operations are kernel functions — composition-only), and domain/integration events (external operations are integration events — cross-boundary; internal operations are domain events — within the bounded context).
|
||||
|
||||
Three controls prevent privilege escalation through composition:
|
||||
1. **Operation visibility**: `OperationSpec` has a `Visibility` field (`External` — callable from the wire, or `Internal` — composition-only). When a `call.requested` arrives from a client, the registry checks visibility: an `Internal` operation returns `NOT_FOUND` (not `FORBIDDEN` — don't leak that it exists). `services/list` only returns `External` operations to remote callers.
|
||||
2. **Handler identity**: Each handler has its own `Identity` with scopes scoped to its composition needs, set at registration by the assembly layer. Internal calls use the handler's identity for ACL, not the caller's. The handler's identity has only the scopes it needs (least privilege), not blanket root and not the caller's scopes.
|
||||
3. **Scoped composition env**: The `OperationEnv` given to a handler can only invoke a declared set of operations. This bounds the parameterized-dispatch attack surface — an LLM picking tools picks from the declared tool set, not from the entire registry.
|
||||
|
||||
Two escalation vectors that this model addresses:
|
||||
- **Buggy handler**: a handler accidentally calls an operation it shouldn't. With handler identity + scoped env, the call either isn't reachable (scoped env) or fails ACL (handler identity lacks the scope). Under the old `trusted` model, ACL was skipped entirely.
|
||||
- **Parameterized dispatch**: a handler takes caller input that determines which internal operation to call (the agent/LLM tool selection case). With scoped env, the handler can only reach declared operations. With handler identity, the ACL checks against the handler's scopes, not the caller's. The caller's scopes only gate entry to the external operation; they don't propagate into the composition.
|
||||
|
||||
The one-way door is the ACL model (internal = authority context switch, not skip; visibility = External/Internal; handler identity + scoped env). The specific APIs — how handler identity is declared, how the scoped env trait works, how visibility interacts with `services/list` filtering — are two-way doors. These should be resolved with the agent crate spec in view, because the composition patterns (sequential tools, parallel tools, dynamic tool selection) affect which mechanisms fit.
|
||||
|
||||
This OQ will be resolved with an ADR before alknet-call implementation begins.
|
||||
- **Cross-references**: ADR-014, [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md)
|
||||
Reference in New Issue
Block a user