Files
alknet/tasks/call/registry/operation-context.md

210 lines
9.7 KiB
Markdown

---
id: call/registry/operation-context
name: Implement OperationContext, AbortPolicy, CompositionAuthority, and ScopedOperationEnv
status: completed
depends_on: [call/registry/operation-spec, core/core-types]
scope: broad
risk: high
impact: component
level: implementation
---
## Description
Implement the operation context types in `src/registry/context.rs`. This is
the highest-density task in the call crate — `OperationContext` has 10 fields,
each tied to an ADR. The authority-switch semantics (`internal: true` → ACL
against `handler_identity`, not `identity`) is where ADR-015, ADR-022, and
ADR-024 converge.
**Read ADR-015, ADR-022, and ADR-024 before starting this task.**
### OperationContext
```rust
pub struct OperationContext {
pub request_id: String,
pub parent_request_id: Option<String>,
pub identity: Option<Identity>, // Caller's identity (inbound)
pub handler_identity: Option<CompositionAuthority>, // Handler's composition authority (ADR-022)
pub capabilities: Capabilities,
pub metadata: HashMap<String, Value>,
pub scoped_env: ScopedOperationEnv, // Reachability set (data, ADR-022)
pub env: Arc<dyn OperationEnv + Send + Sync>, // Composition dispatch trait (ADR-024)
pub abort_policy: AbortPolicy, // ADR-016 Decision 6
pub deadline: Option<Instant>,
pub(crate) internal: bool, // Module-private for writes (ADR-015)
}
```
Field-by-field:
- `request_id`: correlates with `call.requested` event's `id` field. For wire
calls, this is the client-generated ID. For composed calls, generated by
`OperationEnv::invoke()` via `generate_request_id()` (UUID v4 or
`parent_id + "-" + counter`). **Deterministic IDs must not be used** — they
collide across concurrent invocations, corrupting PendingRequestMap and the
abort-cascade tree.
- `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, who sent `call.requested`. For
internal calls, the parent handler's `handler_identity` (propagated through
`OperationEnv::invoke()`).
- `handler_identity`: the composition authority of the handler processing this
call. `None` for leaves (FromOpenAPI/FromMCP/FromCall) — they don't compose.
`Some(...)` for Local/Session ops. For internal calls (`internal: true`), ACL
checks against this authority (ADR-015, ADR-022). This is NOT a peer Identity
— it's a declared authority bundle set at registration.
- `capabilities`: outbound credentials the handler may use (decrypted API keys,
scoped vault access). From the registration bundle (ADR-022).
- `metadata`: request-scoped context (tracing IDs, connection info). **Must not
hold secret material** (ADR-014). **Does not propagate through
`OperationEnv::invoke()`** — nested calls get fresh metadata. The tracing
link is `parent_request_id`, not metadata propagation.
- `scoped_env`: the reachability set — operations this handler may compose.
Populated from the registration bundle (ADR-022). This is *data* (a struct),
not a dispatch trait. `None`/empty for leaves.
- `env`: the composition dispatch trait (`Arc<dyn OperationEnv + Send + Sync>`).
A handler calls `context.env.invoke(...)` to compose children. This is a
trait object, not a concrete struct — enables registry layering (ADR-024).
- `abort_policy`: for this call's descendants (ADR-016 Decision 6). Default
`AbortDependents`. `ContinueRunning` is opt-in for long-running work. Set by
the composing handler via `invoke()`, not by the wire caller.
- `deadline`: for this call and all descendants. Set by `build_root_context`
to `now + CallAdapter.default_timeout` (default 30s). Composed calls inherit
the parent's deadline (children do NOT get a fresh 30s). `None` = unbounded
(long-running subscriptions).
- `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`. Module-private for writes; read via `is_internal()`. Only set by
`OperationEnv::invoke()` (true) or `CallAdapter` dispatch path (false).
### AbortPolicy
```rust
pub enum AbortPolicy {
AbortDependents, // default — abort cascades to all non-terminal descendants
ContinueRunning, // opt-in — started descendants continue, unstarted aborted
}
impl Default for AbortPolicy {
fn default() -> Self { Self::AbortDependents }
}
```
### CompositionAuthority
```rust
pub struct CompositionAuthority {
pub label: String, // e.g., "agent-chat" — not a peer id
pub scopes: Vec<String>, // e.g., ["llm:call", "fs:read"]
pub resources: HashMap<String, Vec<String>>, // e.g., {"service": ["vastai"]}
}
impl CompositionAuthority {
pub fn none() -> Option<Self> { None } // Convenience for leaves
pub fn new(label: &str, scopes: impl IntoIterator<Item = String>) -> Self { ... }
pub fn as_identity(&self) -> Option<Identity> { ... } // Synthetic Identity for ACL
}
```
The declared authority 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.
`as_identity()` produces a synthetic `Identity` from the authority (label as
id, scopes, resources) for ACL checking against `AccessControl`.
### ScopedOperationEnv
```rust
pub struct ScopedOperationEnv {
allowed: HashSet<String>, // operation names this handler may reach
}
impl ScopedOperationEnv {
pub fn empty() -> Self;
pub fn new(ops: impl IntoIterator<Item = impl Into<String>>) -> Self;
pub fn allows(&self, name: &str) -> bool; // is this op in the reachability set?
}
```
The reachability set — the operations this handler may reach via `env.invoke()`.
Populated from the registration bundle (ADR-022). This is *data*, not a dispatch
trait. The reachability check in `OperationEnv::invoke()` consults
`scoped_env.allows(&name)`. `None`/empty for leaves.
### OperationContext methods
```rust
impl OperationContext {
pub fn is_internal(&self) -> bool { self.internal }
}
```
The `internal` field is `pub(crate)` — only `OperationEnv::invoke()` and the
`CallAdapter` dispatch path can set it. Handlers read via `is_internal()`.
### generate_request_id
```rust
pub(crate) fn generate_request_id() -> String {
// UUID v4 — must be unique across concurrent invocations
// Deterministic IDs (e.g., format!("env-{name}")) MUST NOT be used
}
```
Use the `uuid` crate (already a dependency). This is module-internal — called
by `OperationEnv::invoke()` for composed calls.
## Acceptance Criteria
- [ ] `OperationContext` struct with all 10 fields
- [ ] `internal` field is `pub(crate)` (module-private for writes)
- [ ] `is_internal()` method exposes read access
- [ ] `AbortPolicy` enum with AbortDependents, ContinueRunning
- [ ] `Default for AbortPolicy` returns `AbortDependents`
- [ ] `CompositionAuthority` struct with label, scopes, resources
- [ ] `CompositionAuthority::none()` returns `None`
- [ ] `CompositionAuthority::new(label, scopes)` constructor
- [ ] `CompositionAuthority::as_identity()` produces synthetic Identity for ACL
- [ ] `ScopedOperationEnv` struct with allowed set
- [ ] `ScopedOperationEnv::empty()`, `new()`, `allows()` methods
- [ ] `generate_request_id()` produces UUID v4 (unique, non-deterministic)
- [ ] Unit test: ScopedOperationEnv::allows (in set → true, not in set → false)
- [ ] Unit test: CompositionAuthority::as_identity produces correct Identity
- [ ] Unit test: AbortPolicy default is AbortDependents
- [ ] `cargo test -p alknet-call` succeeds
- [ ] `cargo clippy -p alknet-call` succeeds with no warnings
## References
- docs/architecture/crates/call/operation-registry.md — OperationContext, AbortPolicy, CompositionAuthority, ScopedOperationEnv
- docs/architecture/decisions/015-privilege-model-and-authority-context.md — ADR-015 (internal flag, authority switch)
- docs/architecture/decisions/016-abort-cascade-for-nested-calls.md — ADR-016 (AbortPolicy)
- docs/architecture/decisions/022-handler-registration-provenance-and-composition-authority.md — ADR-022 (CompositionAuthority, ScopedOperationEnv)
- docs/architecture/decisions/024-operation-registry-layering.md — ADR-024 (env as trait object)
## Notes
> **Read ADR-015, ADR-022, and ADR-024 before starting.** This is the
> highest-density task in the call crate. OperationContext has 10 fields, each
> tied to an ADR. The authority-switch semantics (internal: true → ACL against
> handler_identity, not identity) is where three ADRs converge. The `internal`
> field is module-private for writes — only OperationEnv::invoke() and the
> CallAdapter dispatch path set it. Metadata does NOT propagate through
> composition (security constraint, ADR-014). Request IDs must be unique
> (UUID v4) — deterministic IDs corrupt PendingRequestMap and abort-cascade tree.
## Summary
Implemented `OperationContext` (10 fields, `internal` pub(crate) + `is_internal()`),
`AbortPolicy` (AbortDependents default), `CompositionAuthority` (none/new/as_identity
for ACL), `ScopedOperationEnv` (empty/new/allows), and `generate_request_id` (UUID v4)
in `registry/context.rs`. Added minimal `OperationEnv` trait in `registry/env.rs`
(invoke/invoke_with_policy/contains) so the `env` field compiles — the operation-env
task will expand with `LocalOperationEnv` and `CompositeOperationEnv`. 37 unit tests
pass; clippy clean. Merged to develop.