--- 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, pub identity: Option, // Caller's identity (inbound) pub handler_identity: Option, // Handler's composition authority (ADR-022) pub capabilities: Capabilities, pub metadata: HashMap, pub scoped_env: ScopedOperationEnv, // Reachability set (data, ADR-022) pub env: Arc, // Composition dispatch trait (ADR-024) pub abort_policy: AbortPolicy, // ADR-016 Decision 6 pub deadline: Option, 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`). 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, // e.g., ["llm:call", "fs:read"] pub resources: HashMap>, // e.g., {"service": ["vastai"]} } impl CompositionAuthority { pub fn none() -> Option { None } // Convenience for leaves pub fn new(label: &str, scopes: impl IntoIterator) -> Self { ... } pub fn as_identity(&self) -> Option { ... } // 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, // operation names this handler may reach } impl ScopedOperationEnv { pub fn empty() -> Self; pub fn new(ops: impl IntoIterator>) -> 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.