From 5c4feff468ccde2866f9d604169a8614415cab90 Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Tue, 30 Jun 2026 10:31:19 +0000 Subject: [PATCH] =?UTF-8?q?tasks:=20add=20call/scoped-peer-env=20=E2=80=94?= =?UTF-8?q?=20ScopedPeerEnv=20peer-pinned=20reachability=20(ADR-029=20?= =?UTF-8?q?=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tasks/call/scoped-peer-env.md | 196 ++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 tasks/call/scoped-peer-env.md diff --git a/tasks/call/scoped-peer-env.md b/tasks/call/scoped-peer-env.md new file mode 100644 index 0000000..545428c --- /dev/null +++ b/tasks/call/scoped-peer-env.md @@ -0,0 +1,196 @@ +--- +id: call/scoped-peer-env +name: Add ScopedPeerEnv peer-pinned reachability (ADR-029 §4) +status: pending +depends_on: [call/operation-env-invoke-peer] +scope: moderate +risk: medium +impact: component +level: implementation +--- + +## Description + +Extend the per-handler reachability set with peer-pinned entries, so a handler +can declare that an op is reachable *only* when routed to a specific peer +(`PeerRef::Specific`). This is the structural disambiguation mechanism that +replaces `FromCallConfig::namespace_prefix` as a reachability concern (the +prefix stays as optional local-naming sugar, ADR-029 §5). Per ADR-029 §4. + +Today the reachability set is `ScopedOperationEnv { allowed: HashSet }` +(`registry/context.rs:78`). It is peer-agnostic — a name in `allowed` is +reachable via any routing path (`PeerRef::Any` or `PeerRef::Specific(any)`). +The head→N-workers disambiguation case (head imports `/container/exec` from +worker A and worker B; a handler wants to *pin* its `container/exec` call to +worker A specifically) is not expressible in the reachability set. It can only +be expressed at the call site by passing `PeerRef::Specific("worker-a")` to +`invoke_peer`, which is a routing decision, not a declaration — the handler's +scoped env permits `container/exec` regardless of which peer serves it. + +ADR-029 §4 specifies the rename-and-extend: + +```rust +pub struct ScopedPeerEnv { + pub allowed_ops: HashSet, // peer-agnostic — reachable via PeerRef::Any + pub peer_pinned: HashSet, // "peer-id/op-name" — reachable only via PeerRef::Specific(that peer) +} +``` + +> The existing `ScopedOperationEnv.allowed` becomes the `allowed_ops` field; +> peer-pinning is additive. Unqualified reachability (peer-agnostic +> composition) stays the common case; peer-pinning is opt-in for the +> disambiguation case. — ADR-029 §4 + +### Rename + extend + +`ScopedOperationEnv` is renamed to `ScopedPeerEnv` and gains the `peer_pinned` +field. All call sites that construct `ScopedOperationEnv::new(["op"])` / +`ScopedOperationEnv::empty()` migrate to `ScopedPeerEnv::new(["op"])` / +`ScopedPeerEnv::empty()` (the `new`/`empty` constructors populate +`allowed_ops` and leave `peer_pinned` empty — the common case stays a +one-liner). A new constructor adds peer-pinned entries: + +```rust +impl ScopedPeerEnv { + pub fn empty() -> Self { Self { allowed_ops: HashSet::new(), peer_pinned: HashSet::new() } } + + pub fn new(ops: impl IntoIterator>) -> Self { + Self { allowed_ops: ops.into_iter().map(|s| s.into()).collect(), peer_pinned: HashSet::new() } + } + + /// Peer-pinned reachability: "peer-id/op-name". Reachable only via + /// PeerRef::Specific(that peer). Additive to `new` — call `new` for the + /// peer-agnostic set, then `with_pinned` for the pinned set. + pub fn with_pinned(mut self, pinned: impl IntoIterator>) -> Self { + self.peer_pinned = pinned.into_iter().map(|s| s.into()).collect(); + self + } + + /// Peer-agnostic reachability — unchanged from ScopedOperationEnv::allows. + /// A name here is reachable via any routing path (PeerRef::Any or Specific). + pub fn allows(&self, name: &str) -> bool { self.allowed_ops.contains(name) } + + /// Peer-pinned reachability — reachable only via PeerRef::Specific(peer). + /// The entry shape is "peer-id/op-name" (ADR-029 §4, OQ-33). + pub fn allows_pinned(&self, peer: &PeerId, name: &str) -> bool { + self.peer_pinned.contains(&format!("{peer}/{name}")) + } + + /// Does this scoped env permit `name` via `peer`? Used by the reachability + /// gate in invoke_peer / invoke_with_policy. + /// - PeerRef::Any → allows(name) + /// - PeerRef::Specific(peer) → allows(name) || allows_pinned(peer, name) + pub fn allows_via(&self, peer: &PeerRef, name: &str) -> bool { + match peer { + PeerRef::Any => self.allows(name), + PeerRef::Specific(p) => self.allows(name) || self.allows_pinned(p, name), + } + } +} +``` + +A name in `peer_pinned` but not in `allowed_ops` is reachable *only* via +`PeerRef::Specific(that peer)` — `PeerRef::Any` does NOT pick it up (the +pin is a restriction of the routing paths that satisfy reachability, not a +permission for any-path routing). This is the disambiguation guarantee: the +handler declared "this op is reachable only on worker A," so `Any` must not +silently route it to worker B. + +### Reachability gate + +The existing reachability gate (`scoped_env.allows(&name)`) at +`registry/env.rs:114` (`invoke_with_policy`) and `registry/env.rs:225,266` +(`invoke_peer` for `Specific` and `Any`) is extended to consult +`peer_pinned`: + +- `invoke_with_policy` (the `PeerRef::Any` path, `env.rs:114`): unchanged — + `scoped_env.allows(&name)`. Peer-pinned-only ops are NOT reachable via this + path. This preserves the existing peer-agnostic behavior: a handler that + pins `container/exec` to worker A in its scoped env cannot reach it via + `invoke_with_policy` (the `Any` fan-out); it must use `invoke_peer` with + `PeerRef::Specific("worker-a")`. +- `invoke_peer` (`env.rs:225` for `Specific`, `env.rs:266` for `Any`): + - `PeerRef::Specific(peer)`: `scoped_env.allows_via(&PeerRef::Specific(peer), &name)` + — true if the name is in `allowed_ops` (peer-agnostic) OR in `peer_pinned` + for this specific peer. False otherwise → NOT_FOUND. + - `PeerRef::Any`: `scoped_env.allows(&name)` — peer-pinned-only ops are NOT + reachable via `Any` (the pin restricts to `Specific`). Unchanged from + today for the peer-agnostic set. + +`connection.rs:266` (`CallConnection::invoke` — the client-side +`PeerRef::Any`-equivalent path) uses `scoped_env.allows(&name)` — unchanged +(it's the `Any` path; pinned-only ops aren't reachable there). + +### HandlerRegistration field type + +`HandlerRegistration.scoped_env: Option` +(`registry/registration.rs:34,44,149,235`) becomes +`Option`. The `FromCallConfig` path +(`client/from_call.rs`) populates `allowed_ops` with the imported op names +(unchanged from today) and leaves `peer_pinned` empty by default; a +`with_pinned` call is the opt-in for pinning an imported op to a specific peer. + +### What this task does NOT do + +- Does NOT change `from_call` collision behavior — that's already done + (`call/from-call-forwarded-for`: cross-peer collision dissolves, + same-peer collision is `SamePeerCollision`). `namespace_prefix` stays as + local-naming sugar (ADR-029 §5); it is not removed. +- Does NOT change `services/list` filtering — that's `AccessControl`-based + (`call/services-list-accesscontrol-filtered`). The scoped env is the + *composition reachability* set (which ops this handler may call), not the + *listing* set (which ops a peer sees). +- Does NOT introduce a `RoutingPolicy` (round-robin / least-loaded) — that's + OQ-30, out of scope. `PeerRef::Any` stays insertion-order first-match. + +## Acceptance Criteria + +- [ ] `ScopedPeerEnv` struct with `allowed_ops: HashSet` and `peer_pinned: HashSet` +- [ ] `ScopedOperationEnv` removed (renamed; all call sites migrated — `context.rs`, `registration.rs`, `adapter.rs`, `connection.rs`, `dispatch.rs`, `env.rs`, `from_call.rs`, `from_jsonschema.rs`, and all tests) +- [ ] `ScopedPeerEnv::empty()`, `new(ops)`, `with_pinned(pinned)` constructors +- [ ] `allows(name)` — peer-agnostic reachability (unchanged behavior) +- [ ] `allows_pinned(peer, name)` — peer-pinned reachability ("peer-id/op-name") +- [ ] `allows_via(peer, name)` — combined reachability check for the gate +- [ ] `invoke_with_policy` (`env.rs:114`) gate unchanged: `scoped_env.allows(&name)` (pinned-only ops NOT reachable via `Any`) +- [ ] `invoke_peer` `PeerRef::Specific` (`env.rs:225`) gate: `scoped_env.allows_via(&PeerRef::Specific(peer), &name)` +- [ ] `invoke_peer` `PeerRef::Any` (`env.rs:266`) gate: `scoped_env.allows(&name)` (pinned-only NOT reachable via `Any`) +- [ ] `CallConnection::invoke` (`connection.rs:266`) gate unchanged (`Any`-equivalent path) +- [ ] `HandlerRegistration.scoped_env: Option` (field type migrated) +- [ ] Name in `peer_pinned` but not `allowed_ops` → reachable ONLY via `PeerRef::Specific(that peer)`; `Any` returns NOT_FOUND +- [ ] Name in both `allowed_ops` and `peer_pinned` for a peer → reachable via both `Any` and `Specific(peer)` (allowed_ops is the permissive set; pin is additive restriction for the Specific case, not a narrowing of Any) +- [ ] `FromCallConfig` populates `allowed_ops` (default), `peer_pinned` empty by default +- [ ] Unit test: `ScopedPeerEnv::new` + `with_pinned` populates both fields +- [ ] Unit test: `allows` checks `allowed_ops` only (peer-agnostic, unchanged) +- [ ] Unit test: `allows_pinned` checks `peer_pinned` ("peer-id/op-name" shape) +- [ ] Unit test: `allows_via` — `Any` uses `allowed_ops`; `Specific(peer)` uses `allowed_ops || peer_pinned` +- [ ] Unit test: pinned-only op reachable via `PeerRef::Specific(peer)`, NOT reachable via `PeerRef::Any` (NOT_FOUND) +- [ ] Unit test: op in both `allowed_ops` and `peer_pinned` reachable via both `Any` and `Specific` +- [ ] Unit test: `invoke_peer` `Specific` with wrong peer for a pinned-only op → NOT_FOUND (pinned to peer A, routed to peer B) +- [ ] Unit test: `invoke_with_policy` (`Any` path) does not pick up pinned-only ops +- [ ] `cargo test --workspace` succeeds +- [ ] `cargo clippy --workspace --all-targets -- -D warnings` succeeds +- [ ] `cargo fmt --all -- --check` succeeds + +## References + +- docs/architecture/decisions/029-peer-graph-routing-model.md — ADR-029 §4 (ScopedPeerEnv), §5 (namespace_prefix becomes local-naming sugar) +- docs/architecture/decisions/030-peerentry-and-identity-id-decoupling.md — ADR-030 (PeerId source — peer_pinned entry shape uses PeerId = Identity.id) +- docs/architecture/crates/call/operation-registry.md — HandlerRegistration.scoped_env (field type migrated) +- docs/architecture/crates/call/client-and-adapters.md — invoke_peer signature and ScopedPeerEnv peer-qualified reachability (line 183) +- docs/architecture/open-questions.md — OQ-33 (PeerId logical id — the peer_pinned entry "peer-id/op-name" shape) + +## Notes + +> The §4 gap surfaced during a verification pass (not the original +> review-sync): ADR-029 §1-3, §5-6 were decomposed into tasks and completed, +> but §4 was never taskified — the specs reference `ScopedPeerEnv` (e.g. +> `client-and-adapters.md:183`) while the code still uses `ScopedOperationEnv`. +> This task closes that drift. The change is a rename + extend (not a redesign): +> `allowed_ops` is the existing `allowed` set, `peer_pinned` is the additive +> new field, and the reachability gate gains a `PeerRef`-aware check. The +> `PeerRef::Any` path is unchanged (pinned-only ops are NOT reachable via +> `Any` — the pin is a restriction, not a permission). The prefix-as-sugar +> remainder (ADR-029 §5) is untouched. Risk is medium, not high: the rename +> is mechanical (compiler-checked), the new gate logic is small, and the +> behavior of the common case (no `peer_pinned` entries) is identical to +> today. \ No newline at end of file