---
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.