Files
alknet/tasks/call/scoped-peer-env.md

196 lines
11 KiB
Markdown

---
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<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:
```rust
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.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<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` (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<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_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<String>` and `peer_pinned: HashSet<String>`
- [ ] `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<ScopedPeerEnv>` (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.