--- id: call/operation-env-invoke-peer name: Add invoke_peer/peer_contains/PeerRef to OperationEnv trait for peer-keyed routing (ADR-029 §2) status: completed depends_on: [call/peer-composite-env] scope: moderate risk: medium impact: component level: implementation --- ## Description Add the `invoke_peer` and `peer_contains` methods to the `OperationEnv` trait, with the `PeerRef` selector enum. Per ADR-029 §2. `PeerCompositeEnv` (built in `call/peer-composite-env`) overrides these with real peer-keyed routing; the default-impl preserves back-compat for single-layer envs (`LocalOperationEnv`, connection overlays) that don't route by peer. ### PeerRef enum ```rust pub enum PeerRef { Specific(PeerId), // route to this peer; NOT_FOUND if it doesn't serve the op Any, // first peer (insertion order) that serves it } ``` `PeerRef::Specific(PeerId)` routes to the named peer's overlay only — no fallthrough (explicit routing must be honored or fail loudly, ADR-029 §2). `PeerRef::Any` reuses `invoke_with_policy` (the insertion-order fan-out built in `call/peer-composite-env`). ### OperationEnv trait additions ```rust #[async_trait] pub trait OperationEnv: Send + Sync { // ... existing invoke, invoke_with_policy, contains ... /// Peer-routing composition (ADR-029 §2). Routes to a specific peer /// (`PeerRef::Specific`) or to the first peer that serves the op /// (`PeerRef::Any`). The default impl ignores the peer selector and /// delegates to `invoke_with_policy`, preserving back-compat for /// single-layer envs that don't route by peer. `PeerCompositeEnv` /// overrides with real peer-keyed routing. async fn invoke_peer( &self, peer: &PeerRef, namespace: &str, operation: &str, input: Value, parent: &OperationContext, policy: AbortPolicy, ) -> ResponseEnvelope { let _ = peer; // unused — single-layer envs don't route by peer self.invoke_with_policy(namespace, operation, input, parent, policy).await } /// Does this env contain the named op *on the named peer*? Used by /// `PeerCompositeEnv` to probe a specific peer's sub-overlay before /// dispatching via `invoke_peer` with `PeerRef::Specific`. Default impl /// delegates to `contains` (single-layer envs ignore the peer dimension). fn peer_contains(&self, _peer: &PeerId, name: &str) -> bool { self.contains(name) } } ``` ### PeerCompositeEnv overrides ```rust #[async_trait] impl OperationEnv for PeerCompositeEnv { // ... invoke_with_policy, contains from call/peer-composite-env ... async fn invoke_peer( &self, peer: &PeerRef, namespace: &str, operation: &str, input: Value, parent: &OperationContext, policy: AbortPolicy, ) -> ResponseEnvelope { let name = format!("{namespace}/{operation}"); if !parent.scoped_env.allows(&name) { return ResponseEnvelope::not_found(parent.request_id.clone(), &name); } match peer { PeerRef::Specific(peer_id) => { // Route to this peer's sub-overlay only. No fallthrough — // explicit routing must be honored or fail loudly (ADR-029 §2). match self.connections.get(peer_id) { Some(conn_env) if conn_env.contains(&name) => { conn_env.invoke_with_policy(namespace, operation, input, parent, policy).await } _ => ResponseEnvelope::not_found(parent.request_id.clone(), &name), } } PeerRef::Any => { // Same as invoke_with_policy: session → peers in order → base. self.invoke_with_policy(namespace, operation, input, parent, policy).await } } } fn peer_contains(&self, peer: &PeerId, name: &str) -> bool { self.connections.get(peer).map_or(false, |c| c.contains(name)) } } ``` ### Back-compat Existing impls (`LocalOperationEnv`, connection overlay envs) use the default `invoke_peer` (delegates to `invoke_with_policy`, ignores peer selector) and default `peer_contains` (delegates to `contains`). No changes needed to those impls — the trait surface grows, the behavior is preserved. ## Acceptance Criteria - [ ] `PeerRef` enum with `Specific(PeerId)` and `Any` variants - [ ] `OperationEnv::invoke_peer` method with default-impl (delegates to `invoke_with_policy`) - [ ] `OperationEnv::peer_contains` method with default-impl (delegates to `contains`) - [ ] `PeerCompositeEnv` overrides `invoke_peer` with real peer-keyed routing - [ ] `PeerRef::Specific` routes to named peer only (no fallthrough → NOT_FOUND if peer doesn't serve op) - [ ] `PeerRef::Any` reuses `invoke_with_policy` (insertion-order fan-out) - [ ] `PeerCompositeEnv` overrides `peer_contains` (checks specific peer's sub-overlay) - [ ] Reachability check (`scoped_env.allows`) gates before peer routing - [ ] `LocalOperationEnv` and overlay envs use default-impls (no changes) - [ ] Unit test: `PeerRef::Specific` routes to the named peer - [ ] Unit test: `PeerRef::Specific` → NOT_FOUND when peer doesn't serve the op (no fallthrough) - [ ] Unit test: `PeerRef::Any` routes to first peer (insertion order) that serves it - [ ] Unit test: `peer_contains` checks specific peer's overlay - [ ] Unit test: default-impl `invoke_peer` delegates to `invoke_with_policy` (back-compat) - [ ] `cargo test -p alknet-call` succeeds - [ ] `cargo clippy -p alknet-call` succeeds with no warnings ## References - docs/architecture/crates/call/operation-registry.md — OperationEnv, invoke_peer, PeerRef - docs/architecture/decisions/029-peer-graph-routing-model.md — ADR-029 §2 (PeerRef routing) ## Notes > The default-impl preserves back-compat — existing single-layer envs > (`LocalOperationEnv`, connection overlays) work unchanged. `PeerCompositeEnv` > overrides with real peer-keyed routing. `PeerRef::Specific` has no > fallthrough (explicit routing must be honored or fail loudly). `PeerRef::Any` > reuses the `invoke_with_policy` fan-out. The reachability check > (`scoped_env.allows`) gates before peer routing, same as before. ## Summary Added PeerRef enum (Specific/Any variants), invoke_peer trait method with default-impl delegating to invoke_with_policy, and PeerCompositeEnv override with real peer-keyed routing — PeerRef::Specific routes to named peer only (no fallthrough → NOT_FOUND if peer doesn't serve op), PeerRef::Any reuses invoke_with_policy. Reachability check (scoped_env.allows) gates before peer routing. peer_contains default delegates to contains; PeerCompositeEnv override checks specific peer's sub-overlay. 8 unit tests covering all acceptance criteria. 221 unit + 2 integration tests pass, clippy clean, fmt clean.