feat(call): add ScopedPeerEnv peer-pinned reachability (ADR-029 §4, call/scoped-peer-env)

This commit is contained in:
2026-06-30 11:07:41 +00:00
parent 5c4feff468
commit bfd1621b9b
10 changed files with 397 additions and 99 deletions

View File

@@ -581,7 +581,7 @@ mod tests {
}
fn test_context(identity: Option<Identity>) -> OperationContext {
use crate::registry::context::{AbortPolicy, ScopedOperationEnv};
use crate::registry::context::{AbortPolicy, ScopedPeerEnv};
use std::collections::HashMap;
use std::time::{Duration, Instant};
OperationContext {
@@ -592,7 +592,7 @@ mod tests {
forwarded_for: None,
capabilities: Capabilities::new(),
metadata: HashMap::new(),
scoped_env: ScopedOperationEnv::empty(),
scoped_env: ScopedPeerEnv::empty(),
env: Arc::new(NoopEnv),
abort_policy: AbortPolicy::default(),
deadline: Some(Instant::now() + Duration::from_secs(30)),

View File

@@ -73,7 +73,7 @@ impl OperationAdapter for FromJsonSchema {
mod tests {
use super::*;
use crate::client::from_jsonschema as from_jsonschema_fn;
use crate::registry::context::{AbortPolicy, ScopedOperationEnv};
use crate::registry::context::{AbortPolicy, ScopedPeerEnv};
use crate::registry::env::OperationEnv;
use crate::registry::spec::{AccessControl, OperationType, Visibility};
use std::collections::HashMap;
@@ -117,7 +117,7 @@ mod tests {
forwarded_for: None,
capabilities: Capabilities::new(),
metadata: HashMap::new(),
scoped_env: ScopedOperationEnv::empty(),
scoped_env: ScopedPeerEnv::empty(),
env: Arc::new(NoopEnv),
abort_policy: AbortPolicy::default(),
deadline: Some(std::time::Instant::now() + Duration::from_secs(30)),

View File

@@ -164,7 +164,7 @@ mod tests {
use crate::protocol::wire::{
CallError, EventEnvelope, EVENT_COMPLETED, EVENT_ERROR, EVENT_RESPONDED,
};
use crate::registry::context::{AbortPolicy, OperationContext, ScopedOperationEnv};
use crate::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
use crate::registry::env::OperationEnv;
use crate::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
@@ -419,7 +419,7 @@ mod tests {
#[tokio::test]
async fn build_root_context_carries_capabilities_and_scoped_env() {
let mut registry = OperationRegistry::new();
let scoped = ScopedOperationEnv::new(["fs/readFile"]);
let scoped = ScopedPeerEnv::new(["fs/readFile"]);
let caps = Capabilities::new().with_api_key("google", "k".to_string());
registry.register(HandlerRegistration::new(
external_spec("agent/run", AccessControl::default()),
@@ -562,7 +562,7 @@ mod tests {
let context =
adapter.build_root_context("req-5".to_string(), "fs/readFile", None, None, &conn);
let scoped = ScopedOperationEnv::new(["worker/exec"]);
let scoped = ScopedPeerEnv::new(["worker/exec"]);
let invoke_ctx = OperationContext {
request_id: "req-5".to_string(),
parent_request_id: None,
@@ -620,7 +620,7 @@ mod tests {
let context =
adapter.build_root_context("req-6".to_string(), "fs/readFile", None, None, &conn);
let scoped = ScopedOperationEnv::new(["worker/exec"]);
let scoped = ScopedPeerEnv::new(["worker/exec"]);
let invoke_ctx = OperationContext {
request_id: "req-6".to_string(),
parent_request_id: None,

View File

@@ -23,9 +23,7 @@ use super::wire::{
EVENT_ERROR, EVENT_RESPONDED,
};
use crate::protocol::wire::ResponseEnvelope;
use crate::registry::context::{
generate_request_id, AbortPolicy, OperationContext, ScopedOperationEnv,
};
use crate::registry::context::{generate_request_id, AbortPolicy, OperationContext, ScopedPeerEnv};
use crate::registry::env::OperationEnv;
use crate::registry::registration::{Handler, HandlerRegistration};
@@ -280,7 +278,7 @@ impl OperationEnv for OverlayOperationEnv {
scoped_env = registration
.scoped_env
.clone()
.unwrap_or_else(ScopedOperationEnv::empty);
.unwrap_or_else(ScopedPeerEnv::empty);
}
let context = OperationContext {
@@ -432,7 +430,7 @@ mod tests {
fn root_context(
request_id: &str,
scoped_env: ScopedOperationEnv,
scoped_env: ScopedPeerEnv,
env: Arc<dyn OperationEnv + Send + Sync>,
) -> OperationContext {
OperationContext {
@@ -487,7 +485,7 @@ mod tests {
conn.register_imported(imported_registration("worker/exec"));
let env = conn.overlay_env();
let scoped = ScopedOperationEnv::new(["worker/exec"]);
let scoped = ScopedPeerEnv::new(["worker/exec"]);
let ctx = root_context("root-1", scoped, env.clone());
let response = env
@@ -506,7 +504,7 @@ mod tests {
assert!(!env.contains("worker/missing"));
let scoped = ScopedOperationEnv::new(["worker/missing"]);
let scoped = ScopedPeerEnv::new(["worker/missing"]);
let ctx = root_context("root-2", scoped, env.clone());
let response = env
@@ -525,7 +523,7 @@ mod tests {
conn.register_imported(imported_registration("worker/exec"));
let env = conn.overlay_env();
let scoped = ScopedOperationEnv::empty();
let scoped = ScopedPeerEnv::empty();
let ctx = root_context("root-3", scoped, env.clone());
let response = env
@@ -562,7 +560,7 @@ mod tests {
));
let env = conn.overlay_env();
let scoped = ScopedOperationEnv::new(["worker/exec"]);
let scoped = ScopedPeerEnv::new(["worker/exec"]);
let ctx = root_context("root-4", scoped, env.clone());
let response = env

View File

@@ -28,7 +28,7 @@ use super::wire::{
EVENT_ABORTED, EVENT_REQUESTED,
};
use crate::protocol::adapter::SessionOverlaySource;
use crate::registry::context::{AbortPolicy, OperationContext, ScopedOperationEnv};
use crate::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
use crate::registry::env::{LocalOperationEnv, OperationEnv, PeerCompositeEnv};
use crate::registry::registration::OperationRegistry;
@@ -135,14 +135,12 @@ impl Dispatcher {
Some(r) => (
r.composition_authority.clone(),
r.capabilities.clone(),
r.scoped_env
.clone()
.unwrap_or_else(ScopedOperationEnv::empty),
r.scoped_env.clone().unwrap_or_else(ScopedPeerEnv::empty),
),
None => (
None,
alknet_core::types::Capabilities::new(),
ScopedOperationEnv::empty(),
ScopedPeerEnv::empty(),
),
};

View File

@@ -6,7 +6,7 @@ use alknet_core::auth::Identity;
use alknet_core::types::Capabilities;
use serde_json::Value;
use super::env::OperationEnv;
use super::env::{OperationEnv, PeerId, PeerRef};
pub struct OperationContext {
pub request_id: String,
@@ -25,7 +25,7 @@ pub struct OperationContext {
pub forwarded_for: Option<Identity>,
pub capabilities: Capabilities,
pub metadata: HashMap<String, Value>,
pub scoped_env: ScopedOperationEnv,
pub scoped_env: ScopedPeerEnv,
pub env: Arc<dyn OperationEnv + Send + Sync>,
pub abort_policy: AbortPolicy,
pub deadline: Option<Instant>,
@@ -75,29 +75,65 @@ impl CompositionAuthority {
}
#[derive(Debug, Clone)]
pub struct ScopedOperationEnv {
allowed: HashSet<String>,
pub struct ScopedPeerEnv {
/// Peer-agnostic reachability — reachable via `PeerRef::Any` or
/// `PeerRef::Specific(any)`. The common case (peer-agnostic composition).
pub allowed_ops: HashSet<String>,
/// Peer-pinned reachability — `"peer-id/op-name"`, reachable only via
/// `PeerRef::Specific(that peer)`. Additive to `allowed_ops`; opt-in for
/// the disambiguation case (ADR-029 §4).
pub peer_pinned: HashSet<String>,
}
impl ScopedOperationEnv {
impl ScopedPeerEnv {
pub fn empty() -> Self {
Self {
allowed: HashSet::new(),
allowed_ops: HashSet::new(),
peer_pinned: HashSet::new(),
}
}
pub fn new(ops: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
allowed: ops.into_iter().map(|s| s.into()).collect(),
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.contains(name)
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),
}
}
}
impl Default for ScopedOperationEnv {
impl Default for ScopedPeerEnv {
fn default() -> Self {
Self::empty()
}
@@ -114,24 +150,108 @@ mod tests {
#[test]
fn scoped_env_allows_in_set() {
let env = ScopedOperationEnv::new(["fs/readFile", "agent/chat"]);
let env = ScopedPeerEnv::new(["fs/readFile", "agent/chat"]);
assert!(env.allows("fs/readFile"));
assert!(env.allows("agent/chat"));
}
#[test]
fn scoped_env_disallows_not_in_set() {
let env = ScopedOperationEnv::new(["fs/readFile"]);
let env = ScopedPeerEnv::new(["fs/readFile"]);
assert!(!env.allows("agent/chat"));
assert!(!env.allows(""));
}
#[test]
fn scoped_env_empty_allows_nothing() {
let env = ScopedOperationEnv::empty();
let env = ScopedPeerEnv::empty();
assert!(!env.allows("fs/readFile"));
}
#[test]
fn scoped_peer_env_new_with_pinned_populates_both_fields() {
let env = ScopedPeerEnv::new(["fs/readFile"]).with_pinned(["worker-a/container/exec"]);
assert!(env.allowed_ops.contains("fs/readFile"));
assert!(env.peer_pinned.contains("worker-a/container/exec"));
assert!(!env.allowed_ops.contains("worker-a/container/exec"));
assert!(!env.peer_pinned.contains("fs/readFile"));
}
#[test]
fn scoped_peer_env_allows_checks_allowed_ops_only() {
let env = ScopedPeerEnv::empty().with_pinned(["worker-a/container/exec"]);
assert!(
!env.allows("container/exec"),
"pinned-only op not in allowed_ops"
);
let env2 = ScopedPeerEnv::new(["container/exec"]).with_pinned(["worker-a/container/exec"]);
assert!(
env2.allows("container/exec"),
"op in allowed_ops is allowed"
);
}
#[test]
fn scoped_peer_env_allows_pinned_checks_peer_pinned_shape() {
let env = ScopedPeerEnv::empty().with_pinned(["worker-a/container/exec"]);
assert!(env.allows_pinned(&"worker-a".to_string(), "container/exec"));
assert!(
!env.allows_pinned(&"worker-b".to_string(), "container/exec"),
"wrong peer"
);
assert!(
!env.allows_pinned(&"worker-a".to_string(), "other/op"),
"wrong op"
);
}
#[test]
fn scoped_peer_env_allows_via_any_uses_allowed_ops_only() {
let env = ScopedPeerEnv::new(["fs/readFile"]).with_pinned(["worker-a/container/exec"]);
assert!(
env.allows_via(&PeerRef::Any, "fs/readFile"),
"allowed op via Any"
);
assert!(
!env.allows_via(&PeerRef::Any, "container/exec"),
"pinned-only op NOT reachable via Any"
);
}
#[test]
fn scoped_peer_env_allows_via_specific_uses_allowed_ops_or_peer_pinned() {
let env = ScopedPeerEnv::new(["fs/readFile"]).with_pinned(["worker-a/container/exec"]);
assert!(
env.allows_via(&PeerRef::Specific("worker-a".to_string()), "container/exec"),
"pinned-only op reachable via Specific(pinned peer)"
);
assert!(
env.allows_via(&PeerRef::Specific("worker-a".to_string()), "fs/readFile"),
"allowed op reachable via Specific(any peer)"
);
assert!(
!env.allows_via(&PeerRef::Specific("worker-b".to_string()), "container/exec"),
"pinned-only op NOT reachable via Specific(wrong peer)"
);
}
#[test]
fn scoped_peer_env_op_in_both_sets_reachable_via_both_any_and_specific() {
let env = ScopedPeerEnv::new(["container/exec"]).with_pinned(["worker-a/container/exec"]);
assert!(
env.allows_via(&PeerRef::Any, "container/exec"),
"op in allowed_ops reachable via Any"
);
assert!(
env.allows_via(&PeerRef::Specific("worker-a".to_string()), "container/exec"),
"op in both sets reachable via Specific(peer)"
);
assert!(
env.allows_via(&PeerRef::Specific("worker-b".to_string()), "container/exec"),
"op in allowed_ops reachable via Specific(other peer) too"
);
}
#[test]
fn composition_authority_as_identity_correct() {
let mut resources = HashMap::new();

View File

@@ -323,7 +323,7 @@ pub fn services_schema_handler(registry: Arc<OperationRegistry>) -> Handler {
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::context::{CompositionAuthority, ScopedOperationEnv};
use crate::registry::context::{CompositionAuthority, ScopedPeerEnv};
use crate::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
use alknet_core::types::Capabilities;
use std::collections::HashMap;
@@ -389,7 +389,7 @@ mod tests {
forwarded_for: None,
capabilities: Capabilities::new(),
metadata: HashMap::new(),
scoped_env: ScopedOperationEnv::empty(),
scoped_env: ScopedPeerEnv::empty(),
env: noop_env(),
abort_policy: crate::registry::context::AbortPolicy::default(),
deadline: Some(std::time::Instant::now() + Duration::from_secs(30)),
@@ -409,7 +409,7 @@ mod tests {
forwarded_for: None,
capabilities: Capabilities::new(),
metadata: HashMap::new(),
scoped_env: ScopedOperationEnv::empty(),
scoped_env: ScopedPeerEnv::empty(),
env: noop_env(),
abort_policy: crate::registry::context::AbortPolicy::default(),
deadline: Some(std::time::Instant::now() + Duration::from_secs(30)),
@@ -674,7 +674,7 @@ mod tests {
list_handler,
OperationProvenance::Local,
CompositionAuthority::none(),
ScopedOperationEnv::empty().into(),
ScopedPeerEnv::empty().into(),
Capabilities::new(),
));
discovery_registry.register(HandlerRegistration::new(
@@ -682,7 +682,7 @@ mod tests {
schema_handler,
OperationProvenance::Local,
CompositionAuthority::none(),
ScopedOperationEnv::empty().into(),
ScopedPeerEnv::empty().into(),
Capabilities::new(),
));
let discovery = Arc::new(discovery_registry);
@@ -836,7 +836,7 @@ mod tests {
forwarded_for: None,
capabilities: Capabilities::new(),
metadata: HashMap::new(),
scoped_env: ScopedOperationEnv::empty(),
scoped_env: ScopedPeerEnv::empty(),
env,
abort_policy: crate::registry::context::AbortPolicy::default(),
deadline: Some(std::time::Instant::now() + Duration::from_secs(30)),
@@ -919,7 +919,7 @@ mod tests {
forwarded_for: None,
capabilities: Capabilities::new(),
metadata: HashMap::new(),
scoped_env: ScopedOperationEnv::empty(),
scoped_env: ScopedPeerEnv::empty(),
env,
abort_policy: crate::registry::context::AbortPolicy::default(),
deadline: Some(std::time::Instant::now() + Duration::from_secs(30)),

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use serde_json::Value;
use super::context::{generate_request_id, AbortPolicy, OperationContext, ScopedOperationEnv};
use super::context::{generate_request_id, AbortPolicy, OperationContext, ScopedPeerEnv};
use super::registration::OperationRegistry;
use crate::protocol::wire::ResponseEnvelope;
@@ -136,7 +136,7 @@ impl OperationEnv for LocalOperationEnv {
scoped_env: registration
.scoped_env
.clone()
.unwrap_or_else(ScopedOperationEnv::empty),
.unwrap_or_else(ScopedPeerEnv::empty),
env: parent.env.clone(),
internal: true,
};
@@ -263,19 +263,27 @@ impl OperationEnv for PeerCompositeEnv {
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) => 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
PeerRef::Specific(peer_id) => {
if !parent
.scoped_env
.allows_via(&PeerRef::Specific(peer_id.clone()), &name)
{
return ResponseEnvelope::not_found(parent.request_id.clone(), &name);
}
_ => ResponseEnvelope::not_found(parent.request_id.clone(), &name),
},
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 => {
if !parent.scoped_env.allows(&name) {
return ResponseEnvelope::not_found(parent.request_id.clone(), &name);
}
self.invoke_with_policy(namespace, operation, input, parent, policy)
.await
}
@@ -353,7 +361,7 @@ mod tests {
request_id: &str,
identity: Option<Identity>,
handler_identity: Option<CompositionAuthority>,
scoped_env: ScopedOperationEnv,
scoped_env: ScopedPeerEnv,
env: Arc<dyn OperationEnv + Send + Sync>,
) -> OperationContext {
root_context_with_forwarded_for(
@@ -371,7 +379,7 @@ mod tests {
identity: Option<Identity>,
handler_identity: Option<CompositionAuthority>,
forwarded_for: Option<Identity>,
scoped_env: ScopedOperationEnv,
scoped_env: ScopedPeerEnv,
env: Arc<dyn OperationEnv + Send + Sync>,
) -> OperationContext {
OperationContext {
@@ -395,7 +403,7 @@ mod tests {
spec_visibility: Visibility,
handler: crate::registry::registration::Handler,
composition_authority: Option<CompositionAuthority>,
scoped_env: Option<ScopedOperationEnv>,
scoped_env: Option<ScopedPeerEnv>,
) -> Arc<OperationRegistry> {
let mut registry = OperationRegistry::new();
registry.register(HandlerRegistration::new(
@@ -421,7 +429,7 @@ mod tests {
async fn local_env_invoke_allowed_op_dispatches() {
let registry = registry_with("echo/run", Visibility::External, echo_handler(), None, None);
let env = Arc::new(LocalOperationEnv::new(Arc::clone(&registry)));
let scoped = ScopedOperationEnv::new(["echo/run"]);
let scoped = ScopedPeerEnv::new(["echo/run"]);
let ctx = root_context("root-1", None, None, scoped, env.clone());
let response = env
.invoke("echo", "run", serde_json::json!({"hi": 1}), &ctx)
@@ -434,7 +442,7 @@ mod tests {
async fn local_env_invoke_disallowed_op_returns_not_found() {
let registry = registry_with("echo/run", Visibility::External, echo_handler(), None, None);
let env = Arc::new(LocalOperationEnv::new(Arc::clone(&registry)));
let scoped = ScopedOperationEnv::new(["other/op"]);
let scoped = ScopedPeerEnv::new(["other/op"]);
let ctx = root_context("root-2", None, None, scoped, env.clone());
let response = env.invoke("echo", "run", serde_json::json!({}), &ctx).await;
match response.result {
@@ -453,7 +461,7 @@ mod tests {
None,
);
let env = Arc::new(LocalOperationEnv::new(Arc::clone(&registry)));
let scoped = ScopedOperationEnv::new(["secret/op"]);
let scoped = ScopedPeerEnv::new(["secret/op"]);
let ctx = root_context("root-3", None, None, scoped, env.clone());
let response = env
.invoke("secret", "op", serde_json::json!({}), &ctx)
@@ -474,7 +482,7 @@ mod tests {
None,
);
let env = Arc::new(LocalOperationEnv::new(Arc::clone(&registry)));
let scoped = ScopedOperationEnv::new(["child/run"]);
let scoped = ScopedPeerEnv::new(["child/run"]);
let ctx = root_context(
"root-4",
Some(Identity {
@@ -503,7 +511,7 @@ mod tests {
None,
);
let env = Arc::new(LocalOperationEnv::new(Arc::clone(&registry)));
let scoped = ScopedOperationEnv::new(["child/run"]);
let scoped = ScopedPeerEnv::new(["child/run"]);
let mut ctx = root_context("root-5", None, None, scoped, env.clone());
ctx.metadata
.insert("secret".to_string(), Value::String("leak".into()));
@@ -524,7 +532,7 @@ mod tests {
None,
);
let env = Arc::new(LocalOperationEnv::new(Arc::clone(&registry)));
let scoped = ScopedOperationEnv::new(["child/run"]);
let scoped = ScopedPeerEnv::new(["child/run"]);
let forwarded = Identity {
id: "alice".to_string(),
scopes: vec![],
@@ -584,7 +592,7 @@ mod tests {
});
let composite = PeerCompositeEnv::new(base).with_session(session.clone());
let env: Arc<dyn OperationEnv + Send + Sync> = Arc::new(composite);
let scoped = ScopedOperationEnv::new(["agent/chat"]);
let scoped = ScopedPeerEnv::new(["agent/chat"]);
let ctx = root_context("root-6", None, None, scoped, env.clone());
let response = env
.invoke("agent", "chat", serde_json::json!({}), &ctx)
@@ -617,7 +625,7 @@ mod tests {
composite.attach_peer("worker-a".to_string(), worker_a.clone());
composite.attach_peer("worker-b".to_string(), worker_b.clone());
let env: Arc<dyn OperationEnv + Send + Sync> = Arc::new(composite);
let scoped = ScopedOperationEnv::new(["worker/exec"]);
let scoped = ScopedPeerEnv::new(["worker/exec"]);
let ctx = root_context("root-7", None, None, scoped, env.clone());
let response = env
.invoke("worker", "exec", serde_json::json!({}), &ctx)
@@ -650,7 +658,7 @@ mod tests {
let mut composite = PeerCompositeEnv::new(base.clone()).with_session(session);
composite.attach_peer("worker-a".to_string(), connection);
let env: Arc<dyn OperationEnv + Send + Sync> = Arc::new(composite);
let scoped = ScopedOperationEnv::new(["fs/readFile"]);
let scoped = ScopedPeerEnv::new(["fs/readFile"]);
let ctx = root_context("root-8", None, None, scoped, env.clone());
let response = env
.invoke("fs", "readFile", serde_json::json!({}), &ctx)
@@ -667,7 +675,7 @@ mod tests {
let base = Arc::new(NoopEnv { contains_op: true });
let composite = PeerCompositeEnv::new(base);
let env: Arc<dyn OperationEnv + Send + Sync> = Arc::new(composite);
let scoped = ScopedOperationEnv::empty();
let scoped = ScopedPeerEnv::empty();
let ctx = root_context("root-9", None, None, scoped, env.clone());
let response = env
.invoke("agent", "chat", serde_json::json!({}), &ctx)
@@ -716,7 +724,7 @@ mod tests {
composite.attach_peer("worker-a".to_string(), connection.clone());
composite.detach_peer(&"worker-a".to_string());
let env: Arc<dyn OperationEnv + Send + Sync> = Arc::new(composite);
let scoped = ScopedOperationEnv::new(["worker/exec"]);
let scoped = ScopedPeerEnv::new(["worker/exec"]);
let ctx = root_context("root-10", None, None, scoped, env.clone());
let response = env
.invoke("worker", "exec", serde_json::json!({}), &ctx)
@@ -742,7 +750,7 @@ mod tests {
composite.detach_peer(&"worker-a".to_string());
composite.attach_peer("worker-a".to_string(), connection.clone());
let env: Arc<dyn OperationEnv + Send + Sync> = Arc::new(composite);
let scoped = ScopedOperationEnv::new(["worker/exec"]);
let scoped = ScopedPeerEnv::new(["worker/exec"]);
let ctx = root_context("root-10b", None, None, scoped, env.clone());
let response = env
.invoke("worker", "exec", serde_json::json!({}), &ctx)
@@ -795,7 +803,7 @@ mod tests {
let mut composite = PeerCompositeEnv::new(base).with_session(session);
composite.attach_peer("worker-a".to_string(), connection.clone());
let env: Arc<dyn OperationEnv + Send + Sync> = Arc::new(composite);
let scoped = ScopedOperationEnv::new(["worker/exec"]);
let scoped = ScopedPeerEnv::new(["worker/exec"]);
let ctx = root_context("root-11", None, None, scoped, env.clone());
let response = env
.invoke("worker", "exec", serde_json::json!({}), &ctx)
@@ -811,7 +819,7 @@ mod tests {
async fn local_env_unknown_op_after_reachability_pass_returns_not_found() {
let registry = Arc::new(OperationRegistry::new());
let env = Arc::new(LocalOperationEnv::new(Arc::clone(&registry)));
let scoped = ScopedOperationEnv::new(["fs/readFile"]);
let scoped = ScopedPeerEnv::new(["fs/readFile"]);
let ctx = root_context("root-12", None, None, scoped, env.clone());
let response = env
.invoke("fs", "readFile", serde_json::json!({}), &ctx)
@@ -832,7 +840,7 @@ mod tests {
None,
);
let env = Arc::new(LocalOperationEnv::new(Arc::clone(&registry)));
let scoped = ScopedOperationEnv::new(["child/run"]);
let scoped = ScopedPeerEnv::new(["child/run"]);
let deadline = Instant::now() + Duration::from_secs(5);
let mut ctx = root_context("root-13", None, None, scoped, env.clone());
ctx.deadline = Some(deadline);
@@ -917,7 +925,7 @@ mod tests {
composite.attach_peer("worker-a".to_string(), worker_a.clone());
composite.attach_peer("worker-b".to_string(), worker_b.clone());
let env: Arc<dyn OperationEnv + Send + Sync> = Arc::new(composite);
let scoped = ScopedOperationEnv::new(["worker/exec"]);
let scoped = ScopedPeerEnv::new(["worker/exec"]);
let ctx = root_context("root-pr-1", None, None, scoped, env.clone());
let response = env
.invoke_peer(
@@ -952,7 +960,7 @@ mod tests {
let mut composite = PeerCompositeEnv::new(base.clone());
composite.attach_peer("worker-a".to_string(), worker_a.clone());
let env: Arc<dyn OperationEnv + Send + Sync> = Arc::new(composite);
let scoped = ScopedOperationEnv::new(["worker/exec"]);
let scoped = ScopedPeerEnv::new(["worker/exec"]);
let ctx = root_context("root-pr-2", None, None, scoped, env.clone());
let response = env
.invoke_peer(
@@ -992,7 +1000,7 @@ mod tests {
}),
);
let env: Arc<dyn OperationEnv + Send + Sync> = Arc::new(composite);
let scoped = ScopedOperationEnv::new(["worker/exec"]);
let scoped = ScopedPeerEnv::new(["worker/exec"]);
let ctx = root_context("root-pr-3", None, None, scoped, env.clone());
let response = env
.invoke_peer(
@@ -1032,7 +1040,7 @@ mod tests {
composite.attach_peer("worker-a".to_string(), worker_a.clone());
composite.attach_peer("worker-b".to_string(), worker_b.clone());
let env: Arc<dyn OperationEnv + Send + Sync> = Arc::new(composite);
let scoped = ScopedOperationEnv::new(["worker/exec"]);
let scoped = ScopedPeerEnv::new(["worker/exec"]);
let ctx = root_context("root-pr-4", None, None, scoped, env.clone());
let response = env
.invoke_peer(
@@ -1063,7 +1071,7 @@ mod tests {
let mut composite = PeerCompositeEnv::new(base);
composite.attach_peer("worker-a".to_string(), worker_a.clone());
let env: Arc<dyn OperationEnv + Send + Sync> = Arc::new(composite);
let scoped = ScopedOperationEnv::empty();
let scoped = ScopedPeerEnv::empty();
let ctx = root_context("root-pr-5", None, None, scoped, env.clone());
let response = env
.invoke_peer(
@@ -1111,7 +1119,7 @@ mod tests {
async fn default_invoke_peer_delegates_to_invoke_with_policy() {
let registry = registry_with("echo/run", Visibility::External, echo_handler(), None, None);
let env = Arc::new(LocalOperationEnv::new(Arc::clone(&registry)));
let scoped = ScopedOperationEnv::new(["echo/run"]);
let scoped = ScopedPeerEnv::new(["echo/run"]);
let ctx = root_context("root-pr-6", None, None, scoped, env.clone());
let response = env
.invoke_peer(
@@ -1133,4 +1141,178 @@ mod tests {
let env = LocalOperationEnv::new(registry);
assert!(env.peer_contains(&"any-peer".to_string(), "anything"));
}
// --- ADR-029 §4: peer-pinned reachability gate -------------------------
#[tokio::test]
async fn invoke_peer_specific_pinned_only_op_reaches_pinned_peer() {
let base = Arc::new(NoopEnv { contains_op: true });
let worker_a = Arc::new(ProbeEnv {
name: "worker-a".to_string(),
contains_set: vec!["container/exec".to_string()],
dispatched: std::sync::Mutex::new(None),
});
let mut composite = PeerCompositeEnv::new(base);
composite.attach_peer("worker-a".to_string(), worker_a.clone());
let env: Arc<dyn OperationEnv + Send + Sync> = Arc::new(composite);
let scoped = ScopedPeerEnv::empty().with_pinned(["worker-a/container/exec"]);
let ctx = root_context("root-pin-1", None, None, scoped, env.clone());
let response = env
.invoke_peer(
&PeerRef::Specific("worker-a".to_string()),
"container",
"exec",
serde_json::json!({}),
&ctx,
AbortPolicy::default(),
)
.await;
assert_eq!(response.result.unwrap(), Value::String("worker-a".into()));
assert_eq!(
worker_a.dispatched.lock().unwrap().as_deref(),
Some("container/exec")
);
}
#[tokio::test]
async fn invoke_peer_any_pinned_only_op_returns_not_found() {
let base = Arc::new(NoopEnv { contains_op: true });
let worker_a = Arc::new(ProbeEnv {
name: "worker-a".to_string(),
contains_set: vec!["container/exec".to_string()],
dispatched: std::sync::Mutex::new(None),
});
let mut composite = PeerCompositeEnv::new(base);
composite.attach_peer("worker-a".to_string(), worker_a.clone());
let env: Arc<dyn OperationEnv + Send + Sync> = Arc::new(composite);
let scoped = ScopedPeerEnv::empty().with_pinned(["worker-a/container/exec"]);
let ctx = root_context("root-pin-2", None, None, scoped, env.clone());
let response = env
.invoke_peer(
&PeerRef::Any,
"container",
"exec",
serde_json::json!({}),
&ctx,
AbortPolicy::default(),
)
.await;
match response.result {
Err(e) => assert_eq!(e.code, "NOT_FOUND", "pinned-only op NOT reachable via Any"),
other => panic!("expected NOT_FOUND via Any, got {other:?}"),
}
assert!(worker_a.dispatched.lock().unwrap().is_none());
}
#[tokio::test]
async fn invoke_with_policy_does_not_pick_up_pinned_only_ops() {
let base = Arc::new(NoopEnv { contains_op: true });
let worker_a = Arc::new(ProbeEnv {
name: "worker-a".to_string(),
contains_set: vec!["container/exec".to_string()],
dispatched: std::sync::Mutex::new(None),
});
let mut composite = PeerCompositeEnv::new(base);
composite.attach_peer("worker-a".to_string(), worker_a.clone());
let env: Arc<dyn OperationEnv + Send + Sync> = Arc::new(composite);
let scoped = ScopedPeerEnv::empty().with_pinned(["worker-a/container/exec"]);
let ctx = root_context("root-pin-3", None, None, scoped, env.clone());
let response = env
.invoke("container", "exec", serde_json::json!({}), &ctx)
.await;
match response.result {
Err(e) => assert_eq!(
e.code, "NOT_FOUND",
"invoke_with_policy (Any path) must NOT pick up pinned-only ops"
),
other => panic!("expected NOT_FOUND, got {other:?}"),
}
assert!(worker_a.dispatched.lock().unwrap().is_none());
}
#[tokio::test]
async fn invoke_peer_specific_wrong_peer_for_pinned_only_op_returns_not_found() {
let base = Arc::new(NoopEnv { contains_op: true });
let worker_a = Arc::new(ProbeEnv {
name: "worker-a".to_string(),
contains_set: vec!["container/exec".to_string()],
dispatched: std::sync::Mutex::new(None),
});
let worker_b = Arc::new(ProbeEnv {
name: "worker-b".to_string(),
contains_set: vec!["container/exec".to_string()],
dispatched: std::sync::Mutex::new(None),
});
let mut composite = PeerCompositeEnv::new(base);
composite.attach_peer("worker-a".to_string(), worker_a.clone());
composite.attach_peer("worker-b".to_string(), worker_b.clone());
let env: Arc<dyn OperationEnv + Send + Sync> = Arc::new(composite);
let scoped = ScopedPeerEnv::empty().with_pinned(["worker-a/container/exec"]);
let ctx = root_context("root-pin-4", None, None, scoped, env.clone());
let response = env
.invoke_peer(
&PeerRef::Specific("worker-b".to_string()),
"container",
"exec",
serde_json::json!({}),
&ctx,
AbortPolicy::default(),
)
.await;
match response.result {
Err(e) => assert_eq!(
e.code, "NOT_FOUND",
"pinned to worker-a, routed to worker-b → NOT_FOUND"
),
other => panic!("expected NOT_FOUND, got {other:?}"),
}
assert!(worker_a.dispatched.lock().unwrap().is_none());
assert!(worker_b.dispatched.lock().unwrap().is_none());
}
#[tokio::test]
async fn invoke_peer_op_in_both_sets_reachable_via_both_any_and_specific() {
let base = Arc::new(NoopEnv { contains_op: true });
let worker_a = Arc::new(ProbeEnv {
name: "worker-a".to_string(),
contains_set: vec!["container/exec".to_string()],
dispatched: std::sync::Mutex::new(None),
});
let mut composite = PeerCompositeEnv::new(base);
composite.attach_peer("worker-a".to_string(), worker_a.clone());
let env: Arc<dyn OperationEnv + Send + Sync> = Arc::new(composite);
let scoped =
ScopedPeerEnv::new(["container/exec"]).with_pinned(["worker-a/container/exec"]);
let ctx = root_context("root-pin-5", None, None, scoped, env.clone());
let response_any = env
.invoke_peer(
&PeerRef::Any,
"container",
"exec",
serde_json::json!({}),
&ctx,
AbortPolicy::default(),
)
.await;
assert!(
response_any.result.is_ok(),
"op in allowed_ops reachable via Any"
);
let response_specific = env
.invoke_peer(
&PeerRef::Specific("worker-a".to_string()),
"container",
"exec",
serde_json::json!({}),
&ctx,
AbortPolicy::default(),
)
.await;
assert!(
response_specific.result.is_ok(),
"op in both sets reachable via Specific(peer)"
);
}
}

View File

@@ -6,7 +6,7 @@ use std::sync::Arc;
use alknet_core::types::Capabilities;
use serde_json::Value;
use super::context::{CompositionAuthority, OperationContext, ScopedOperationEnv};
use super::context::{CompositionAuthority, OperationContext, ScopedPeerEnv};
use super::spec::{AccessResult, OperationSpec, Visibility};
use crate::protocol::wire::ResponseEnvelope;
@@ -31,7 +31,7 @@ pub struct HandlerRegistration {
pub handler: Handler,
pub provenance: OperationProvenance,
pub composition_authority: Option<CompositionAuthority>,
pub scoped_env: Option<ScopedOperationEnv>,
pub scoped_env: Option<ScopedPeerEnv>,
pub capabilities: Capabilities,
}
@@ -41,7 +41,7 @@ impl HandlerRegistration {
handler: Handler,
provenance: OperationProvenance,
composition_authority: Option<CompositionAuthority>,
scoped_env: Option<ScopedOperationEnv>,
scoped_env: Option<ScopedPeerEnv>,
capabilities: Capabilities,
) -> Self {
Self {
@@ -146,7 +146,7 @@ impl OperationRegistryBuilder {
spec: OperationSpec,
handler: Handler,
composition_authority: Option<CompositionAuthority>,
scoped_env: Option<ScopedOperationEnv>,
scoped_env: Option<ScopedPeerEnv>,
capabilities: Capabilities,
) -> Self {
let registration = HandlerRegistration::new(
@@ -247,7 +247,7 @@ mod tests {
identity: Option<Identity>,
handler_identity: Option<CompositionAuthority>,
internal: bool,
scoped_env: ScopedOperationEnv,
scoped_env: ScopedPeerEnv,
) -> OperationContext {
OperationContext {
request_id: request_id.to_string(),
@@ -320,7 +320,7 @@ mod tests {
None,
Capabilities::new(),
));
let ctx = root_context("req-1", None, None, false, ScopedOperationEnv::empty());
let ctx = root_context("req-1", None, None, false, ScopedPeerEnv::empty());
let response = registry
.invoke("echo", serde_json::json!({"hi": 1}), ctx)
.await;
@@ -339,7 +339,7 @@ mod tests {
None,
Capabilities::new(),
));
let ctx = root_context("req-2", None, None, false, ScopedOperationEnv::empty());
let ctx = root_context("req-2", None, None, false, ScopedPeerEnv::empty());
let response = registry.invoke("secret", serde_json::json!({}), ctx).await;
match response.result {
Err(e) => {
@@ -361,7 +361,7 @@ mod tests {
None,
Capabilities::new(),
));
let ctx = root_context("req-3", None, None, true, ScopedOperationEnv::empty());
let ctx = root_context("req-3", None, None, true, ScopedPeerEnv::empty());
let response = registry
.invoke("secret", serde_json::json!({"x": 2}), ctx)
.await;
@@ -372,7 +372,7 @@ mod tests {
#[tokio::test]
async fn unknown_op_returns_not_found() {
let registry = OperationRegistry::new();
let ctx = root_context("req-4", None, None, false, ScopedOperationEnv::empty());
let ctx = root_context("req-4", None, None, false, ScopedPeerEnv::empty());
let response = registry.invoke("missing", serde_json::json!({}), ctx).await;
match response.result {
Err(e) => assert_eq!(e.code, "NOT_FOUND"),
@@ -402,7 +402,7 @@ mod tests {
Some(identity_with_scopes("caller", &["admin"])),
None,
false,
ScopedOperationEnv::empty(),
ScopedPeerEnv::empty(),
);
let response = registry.invoke("admin", serde_json::json!({}), ctx).await;
assert!(response.result.is_ok());
@@ -430,7 +430,7 @@ mod tests {
Some(identity_with_scopes("caller", &["user"])),
None,
false,
ScopedOperationEnv::empty(),
ScopedPeerEnv::empty(),
);
let response = registry.invoke("admin", serde_json::json!({}), ctx).await;
match response.result {
@@ -459,7 +459,7 @@ mod tests {
None,
Capabilities::new(),
));
let ctx = root_context("req-7", None, None, false, ScopedOperationEnv::empty());
let ctx = root_context("req-7", None, None, false, ScopedPeerEnv::empty());
let response = registry.invoke("admin", serde_json::json!({}), ctx).await;
match response.result {
Err(e) => {
@@ -493,7 +493,7 @@ mod tests {
Some(identity_with_scopes("user", &["user"])),
Some(composing_authority),
true,
ScopedOperationEnv::empty(),
ScopedPeerEnv::empty(),
);
let response = registry.invoke("secret", serde_json::json!({}), ctx).await;
assert!(
@@ -525,7 +525,7 @@ mod tests {
Some(identity_with_scopes("user", &["admin"])),
Some(weak_authority),
true,
ScopedOperationEnv::empty(),
ScopedPeerEnv::empty(),
);
let response = registry.invoke("secret", serde_json::json!({}), ctx).await;
match response.result {
@@ -560,7 +560,7 @@ mod tests {
Some(identity_with_scopes("user", &["user"])),
Some(CompositionAuthority::new("agent", ["admin".to_string()])),
false,
ScopedOperationEnv::empty(),
ScopedPeerEnv::empty(),
);
let response = registry.invoke("gate", serde_json::json!({}), ctx).await;
match response.result {
@@ -604,7 +604,7 @@ mod tests {
None,
Capabilities::new(),
));
let ctx = root_context("req-11", None, None, false, ScopedOperationEnv::empty());
let ctx = root_context("req-11", None, None, false, ScopedPeerEnv::empty());
let response = registry.invoke("boom", serde_json::json!({}), ctx).await;
match response.result {
Err(e) => assert_eq!(e.code, "INTERNAL"),
@@ -619,7 +619,7 @@ mod tests {
external_spec("echo", AccessControl::default()),
echo_handler(),
CompositionAuthority::none(),
ScopedOperationEnv::empty().into(),
ScopedPeerEnv::empty().into(),
Capabilities::new(),
)
.build();
@@ -636,7 +636,7 @@ mod tests {
external_spec("agent", AccessControl::default()),
echo_handler(),
Some(CompositionAuthority::new("agent", ["fs:read".to_string()])),
Some(ScopedOperationEnv::new(["fs/readFile"])),
Some(ScopedPeerEnv::new(["fs/readFile"])),
Capabilities::new(),
)
.build();
@@ -687,7 +687,7 @@ mod tests {
echo_handler(),
OperationProvenance::Session,
Some(CompositionAuthority::new("sandbox", [])),
Some(ScopedOperationEnv::new(["fs/readFile"])),
Some(ScopedPeerEnv::new(["fs/readFile"])),
Capabilities::new(),
);
let registry = OperationRegistryBuilder::new().with(registration).build();
@@ -715,7 +715,7 @@ mod tests {
fn registration_lookup_returns_bundle_fields() {
let mut registry = OperationRegistry::new();
let authority = CompositionAuthority::new("agent", ["fs:read".to_string()]);
let scoped = ScopedOperationEnv::new(["fs/readFile"]);
let scoped = ScopedPeerEnv::new(["fs/readFile"]);
let caps = Capabilities::new().with_api_key("google", "k".to_string());
registry.register(HandlerRegistration::new(
external_spec("agent", AccessControl::default()),

View File

@@ -233,7 +233,7 @@ async fn two_node_call_round_trip() {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn from_call_discovers_and_forwards_over_quic_loopback() {
use alknet_call::client::{from_call, FromCallConfig};
use alknet_call::registry::context::ScopedOperationEnv;
use alknet_call::registry::context::ScopedPeerEnv;
let server_registry = build_server_registry();
let (server_addr, server_fingerprint, _server_join) =
@@ -284,7 +284,7 @@ async fn from_call_discovers_and_forwards_over_quic_loopback() {
// Build a minimal parent context to invoke the overlay env (mirrors how a
// composing handler dispatches a child).
let scoped = ScopedOperationEnv::new(["server/echo"]);
let scoped = ScopedPeerEnv::new(["server/echo"]);
let parent = alknet_call::registry::context::OperationContext {
request_id: "parent-1".to_string(),
parent_request_id: None,