11 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| call/scoped-peer-env | Add ScopedPeerEnv peer-pinned reachability (ADR-029 §4) | pending |
|
moderate | medium | component | 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<String> }
(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:
pub struct ScopedPeerEnv {
pub allowed_ops: HashSet<String>, // peer-agnostic — reachable via PeerRef::Any
pub peer_pinned: HashSet<String>, // "peer-id/op-name" — reachable only via PeerRef::Specific(that peer)
}
The existing
ScopedOperationEnv.allowedbecomes theallowed_opsfield; 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:
impl ScopedPeerEnv {
pub fn empty() -> Self { Self { allowed_ops: HashSet::new(), peer_pinned: HashSet::new() } }
pub fn new(ops: impl IntoIterator<Item = impl Into<String>>) -> 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<Item = impl Into<String>>) -> 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(thePeerRef::Anypath,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 pinscontainer/execto worker A in its scoped env cannot reach it viainvoke_with_policy(theAnyfan-out); it must useinvoke_peerwithPeerRef::Specific("worker-a").invoke_peer(env.rs:225forSpecific,env.rs:266forAny):PeerRef::Specific(peer):scoped_env.allows_via(&PeerRef::Specific(peer), &name)— true if the name is inallowed_ops(peer-agnostic) OR inpeer_pinnedfor this specific peer. False otherwise → NOT_FOUND.PeerRef::Any:scoped_env.allows(&name)— peer-pinned-only ops are NOT reachable viaAny(the pin restricts toSpecific). 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<ScopedOperationEnv>
(registry/registration.rs:34,44,149,235) becomes
Option<ScopedPeerEnv>. 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_callcollision behavior — that's already done (call/from-call-forwarded-for: cross-peer collision dissolves, same-peer collision isSamePeerCollision).namespace_prefixstays as local-naming sugar (ADR-029 §5); it is not removed. - Does NOT change
services/listfiltering — that'sAccessControl-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::Anystays insertion-order first-match.
Acceptance Criteria
ScopedPeerEnvstruct withallowed_ops: HashSet<String>andpeer_pinned: HashSet<String>ScopedOperationEnvremoved (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)constructorsallows(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 gateinvoke_with_policy(env.rs:114) gate unchanged:scoped_env.allows(&name)(pinned-only ops NOT reachable viaAny)invoke_peerPeerRef::Specific(env.rs:225) gate:scoped_env.allows_via(&PeerRef::Specific(peer), &name)invoke_peerPeerRef::Any(env.rs:266) gate:scoped_env.allows(&name)(pinned-only NOT reachable viaAny)CallConnection::invoke(connection.rs:266) gate unchanged (Any-equivalent path)HandlerRegistration.scoped_env: Option<ScopedPeerEnv>(field type migrated)- Name in
peer_pinnedbut notallowed_ops→ reachable ONLY viaPeerRef::Specific(that peer);Anyreturns NOT_FOUND - Name in both
allowed_opsandpeer_pinnedfor a peer → reachable via bothAnyandSpecific(peer)(allowed_ops is the permissive set; pin is additive restriction for the Specific case, not a narrowing of Any) FromCallConfigpopulatesallowed_ops(default),peer_pinnedempty by default- Unit test:
ScopedPeerEnv::new+with_pinnedpopulates both fields - Unit test:
allowschecksallowed_opsonly (peer-agnostic, unchanged) - Unit test:
allows_pinnedcheckspeer_pinned("peer-id/op-name" shape) - Unit test:
allows_via—Anyusesallowed_ops;Specific(peer)usesallowed_ops || peer_pinned - Unit test: pinned-only op reachable via
PeerRef::Specific(peer), NOT reachable viaPeerRef::Any(NOT_FOUND) - Unit test: op in both
allowed_opsandpeer_pinnedreachable via bothAnyandSpecific - Unit test:
invoke_peerSpecificwith wrong peer for a pinned-only op → NOT_FOUND (pinned to peer A, routed to peer B) - Unit test:
invoke_with_policy(Anypath) does not pick up pinned-only ops cargo test --workspacesucceedscargo clippy --workspace --all-targets -- -D warningssucceedscargo fmt --all -- --checksucceeds
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 usesScopedOperationEnv. This task closes that drift. The change is a rename + extend (not a redesign):allowed_opsis the existingallowedset,peer_pinnedis the additive new field, and the reachability gate gains aPeerRef-aware check. ThePeerRef::Anypath is unchanged (pinned-only ops are NOT reachable viaAny— 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 (nopeer_pinnedentries) is identical to today.