Skip to main content

lc_a2a/
scale.rs

1//! P2-9: scale to ~1000 agents.
2//!
3//! This module provides the building blocks for large agent fleets, where
4//! enumerating every agent card, letting workers talk to each other, or
5//! retrying an unguarded call are all no longer viable:
6//!
7//! - [`SkillIndex`] — retrieve agents by *semantic* relevance over skill
8//!   descriptions (bag-of-words cosine) instead of enumerating 1000 cards.
9//! - [`HierarchyPolicy`] — enforce Orchestrator→Worker delegation and forbid
10//!   Worker↔Worker fan-out.
11//! - [`DelegationGuard`] — depth limit for delegation chains (default 10 hops).
12//! - [`TaskSharder`] — stable task-ID hash → shard mapping for state split.
13//! - [`CircuitBreaker`] — per-agent failure circuit breaker.
14//! - [`StickyRouter`] — hash-based routing so a stateful agent is pinned per
15//!   conversation key.
16//! - [`TaskGraph`] — global parent/child task graph with cycle detection, so a
17//!   delegation loop cannot grow unboundedly.
18//!
19//! Task TTL already exists (P1-2), so the scale story is: TTL + hop limit + ring
20//! detection + sharding + per-agent circuit breaking + sticky routing.
21
22use std::collections::{HashMap, HashSet, VecDeque};
23use std::time::{Duration, Instant};
24
25use crate::protocol::{AgentCard, AgentSkill};
26
27/// Errors raised by the scale-guard components.
28#[derive(Debug, thiserror::Error)]
29#[non_exhaustive]
30pub enum ScaleError {
31    /// A delegation chain exceeded the configured hop limit.
32    #[error("delegation would exceed the {max} hop limit at hop {hops}")]
33    HopLimitExceeded {
34        /// The delegation depth at which the limit was hit.
35        hops: usize,
36        /// The configured maximum delegation depth.
37        max: usize,
38    },
39    /// A worker tried to delegate (Worker↔Worker fan-out is forbidden).
40    #[error("worker agents may not delegate to other agents")]
41    WorkerToWorker,
42    /// Linking a task edge would create a cycle in the global task graph.
43    #[error("task delegation {parent} -> {child} would create a cycle")]
44    CycleDetected {
45        /// The parent task attempting the delegation.
46        parent: String,
47        /// The child task that would create the cycle.
48        child: String,
49    },
50}
51
52/// An indexed skill and the agent that offers it (P2-9).
53#[derive(Debug, Clone)]
54pub struct SkillEntry {
55    /// The advertised skill.
56    pub skill: AgentSkill,
57    /// The agent that offers this skill.
58    pub agent_url: String,
59}
60
61impl SkillEntry {
62    /// Create an index entry.
63    pub fn new(skill: AgentSkill, agent_url: impl Into<String>) -> Self {
64        Self {
65            skill,
66            agent_url: agent_url.into(),
67        }
68    }
69}
70
71/// Semantic skill index: retrieve agents by relevance instead of enumerating
72/// every card (P2-9).
73///
74/// Relevance is a bag-of-words cosine similarity over skill descriptions and
75/// the query. This is deliberately dependency-free and deterministic — a fleet
76/// that later adopts real embeddings can swap the scorer behind the same API.
77#[derive(Debug, Default)]
78pub struct SkillIndex {
79    entries: Vec<SkillEntry>,
80}
81
82impl SkillIndex {
83    /// An empty index.
84    pub fn new() -> Self {
85        Self::default()
86    }
87
88    /// Index a single skill/agent pair.
89    pub fn with_entry(mut self, entry: SkillEntry) -> Self {
90        self.entries.push(entry);
91        self
92    }
93
94    /// Index every skill advertised on an agent card.
95    pub fn index_card(&mut self, card: &AgentCard) {
96        for skill in &card.skills {
97            self.entries
98                .push(SkillEntry::new(skill.clone(), card.url.clone()));
99        }
100    }
101
102    /// The indexed entries, in insertion order.
103    pub fn entries(&self) -> &[SkillEntry] {
104        &self.entries
105    }
106
107    /// Search for agents matching `query`, returning the top `limit` entries
108    /// ranked by relevance (best first). Entries with zero overlap are dropped.
109    pub fn search(&self, query: &str, limit: usize) -> Vec<SkillEntry> {
110        let query_vec = tokens(query);
111        let mut scored: Vec<(f64, &SkillEntry)> = self
112            .entries
113            .iter()
114            .map(|e| (cosine(&query_vec, &tokens(&e.skill.description)), e))
115            .filter(|(score, _)| *score > 0.0)
116            .collect();
117        scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
118        scored.truncate(limit);
119        scored.into_iter().map(|(_, e)| e.clone()).collect()
120    }
121}
122
123/// Tokenize into lowercase alphanumeric words.
124fn tokens(text: &str) -> Vec<String> {
125    text.to_lowercase()
126        .split(|c: char| !c.is_alphanumeric())
127        .filter(|w| !w.is_empty())
128        .map(String::from)
129        .collect()
130}
131
132/// Bag-of-words cosine similarity between two token lists (0.0 if either is
133/// empty).
134fn cosine(a: &[String], b: &[String]) -> f64 {
135    let mut counts_a: HashMap<&str, usize> = HashMap::new();
136    let mut counts_b: HashMap<&str, usize> = HashMap::new();
137    for t in a {
138        *counts_a.entry(t).or_insert(0) += 1;
139    }
140    for t in b {
141        *counts_b.entry(t).or_insert(0) += 1;
142    }
143    if counts_a.is_empty() || counts_b.is_empty() {
144        return 0.0;
145    }
146    let mut dot = 0.0_f64;
147    let mut norm_a = 0.0_f64;
148    let mut norm_b = 0.0_f64;
149    for (tok, ca) in &counts_a {
150        let cb = counts_b.get(tok).copied().unwrap_or(0);
151        dot += (*ca as f64) * (cb as f64);
152        norm_a += (*ca as f64) * (*ca as f64);
153    }
154    for cb in counts_b.values() {
155        norm_b += (*cb as f64) * (*cb as f64);
156    }
157    if norm_a == 0.0 || norm_b == 0.0 {
158        return 0.0;
159    }
160    dot / (norm_a.sqrt() * norm_b.sqrt())
161}
162
163/// Role of an agent in a delegation hierarchy (P2-9).
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum AgentTier {
166    /// Top-level coordinator; may delegate work down.
167    Orchestrator,
168    /// Leaf executor; must not fan out further (no Worker↔Worker).
169    Worker,
170}
171
172/// Whether an agent at `from` tier may delegate to an agent at `to` tier.
173///
174/// Orchestrators may delegate to other orchestrators (sub-orchestration) and
175/// to workers; workers may never delegate — the hierarchy fan-out rule.
176pub fn may_delegate(from: AgentTier, to: AgentTier) -> bool {
177    use AgentTier::{Orchestrator, Worker};
178    matches!(
179        (from, to),
180        (Orchestrator, Orchestrator) | (Orchestrator, Worker)
181    )
182}
183
184/// Tier membership map for a fleet, enforcing the Orchestrator→Worker rule
185/// (P2-9).
186#[derive(Debug, Default)]
187pub struct HierarchyPolicy {
188    orchestrators: HashSet<String>,
189    workers: HashSet<String>,
190}
191
192impl HierarchyPolicy {
193    /// An empty policy (every agent is treated as a least-privilege worker).
194    pub fn new() -> Self {
195        Self::default()
196    }
197
198    /// Mark an agent URL as an orchestrator.
199    pub fn with_orchestrator(mut self, url: impl Into<String>) -> Self {
200        self.orchestrators.insert(url.into());
201        self
202    }
203
204    /// Mark an agent URL as a worker.
205    pub fn with_worker(mut self, url: impl Into<String>) -> Self {
206        self.workers.insert(url.into());
207        self
208    }
209
210    /// The tier of an agent; unknown agents default to [`AgentTier::Worker`]
211    /// (least privilege).
212    pub fn tier(&self, url: &str) -> AgentTier {
213        if self.orchestrators.contains(url) {
214            AgentTier::Orchestrator
215        } else {
216            AgentTier::Worker
217        }
218    }
219
220    /// Check that `from` is allowed to delegate to `to`.
221    pub fn check_delegation(&self, from: &str, to: &str) -> Result<(), ScaleError> {
222        if may_delegate(self.tier(from), self.tier(to)) {
223            Ok(())
224        } else {
225            Err(ScaleError::WorkerToWorker)
226        }
227    }
228}
229
230/// Depth limit for delegation chains (P2-9). Defaults to the A2A scale budget
231/// of 10 hops.
232#[derive(Debug, Clone, Copy)]
233pub struct DelegationGuard {
234    max_hops: usize,
235}
236
237impl Default for DelegationGuard {
238    fn default() -> Self {
239        Self { max_hops: 10 }
240    }
241}
242
243impl DelegationGuard {
244    /// A guard allowing up to `max_hops` delegation hops.
245    pub fn new(max_hops: usize) -> Self {
246        Self { max_hops }
247    }
248
249    /// The configured hop ceiling.
250    pub fn max_hops(&self) -> usize {
251        self.max_hops
252    }
253
254    /// Validate a chain depth of `hops` (0 = the root call, not a delegation).
255    pub fn check(&self, hops: usize) -> Result<(), ScaleError> {
256        if hops > self.max_hops {
257            Err(ScaleError::HopLimitExceeded {
258                hops,
259                max: self.max_hops,
260            })
261        } else {
262            Ok(())
263        }
264    }
265}
266
267/// FNV-1a hash — deterministic and cheap, good for shard/slot assignment.
268fn fnv1a(bytes: &[u8]) -> u64 {
269    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
270    for b in bytes {
271        hash ^= u64::from(*b);
272        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
273    }
274    hash
275}
276
277/// Task-state sharding by stable task-ID hash (P2-9).
278///
279/// A 1000-agent fleet cannot keep every task in one store; this maps each
280/// task id to a shard so state is distributed deterministically and collocated
281/// lookups stay local.
282#[derive(Debug, Clone, Copy)]
283pub struct TaskSharder {
284    num_shards: usize,
285}
286
287impl TaskSharder {
288    /// A sharder over `num_shards` shards (clamped to at least 1).
289    pub fn new(num_shards: usize) -> Self {
290        Self {
291            num_shards: num_shards.max(1),
292        }
293    }
294
295    /// The shard index (0-based) responsible for `task_id`.
296    pub fn shard(&self, task_id: &str) -> usize {
297        (fnv1a(task_id.as_bytes()) % self.num_shards as u64) as usize
298    }
299
300    /// Shard assignments for many task ids, preserving order.
301    pub fn shards<'a>(&self, task_ids: impl IntoIterator<Item = &'a str>) -> Vec<usize> {
302        task_ids.into_iter().map(|id| self.shard(id)).collect()
303    }
304
305    /// The number of shards.
306    pub fn num_shards(&self) -> usize {
307        self.num_shards
308    }
309}
310
311/// State of a per-agent [`CircuitBreaker`].
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum BreakerState {
314    /// Normal operation; calls are allowed.
315    Closed,
316    /// Too many recent failures; calls are rejected.
317    Open,
318    /// A trial call is let through after `open_duration` to test recovery.
319    HalfOpen,
320}
321
322/// Configuration for a [`CircuitBreaker`].
323#[derive(Debug, Clone, Copy)]
324pub struct CircuitBreakerConfig {
325    /// Consecutive failures before the breaker trips open.
326    pub failure_threshold: usize,
327    /// How long the breaker stays open before allowing a trial call.
328    pub open_duration: Duration,
329}
330
331impl Default for CircuitBreakerConfig {
332    fn default() -> Self {
333        Self {
334            failure_threshold: 5,
335            open_duration: Duration::from_secs(30),
336        }
337    }
338}
339
340/// A per-agent circuit breaker (P2-9).
341///
342/// `Closed` → `Open` after `failure_threshold` consecutive failures. While
343/// `Open`, calls are rejected; after `open_duration` a single trial call is
344/// admitted (`HalfOpen`); a success resets to `Closed`, another failure trips
345/// it straight back `Open`. Shared via the interior lock, so a fleet of
346/// concurrent callers is throttled collectively.
347pub struct CircuitBreaker {
348    config: CircuitBreakerConfig,
349    inner: std::sync::Mutex<BreakerInner>,
350}
351
352#[derive(Debug)]
353struct BreakerInner {
354    state: BreakerState,
355    consecutive_failures: usize,
356    opened_at: Option<Instant>,
357}
358
359impl CircuitBreaker {
360    /// A breaker with the given config.
361    pub fn new(config: CircuitBreakerConfig) -> Self {
362        Self {
363            config,
364            inner: std::sync::Mutex::new(BreakerInner {
365                state: BreakerState::Closed,
366                consecutive_failures: 0,
367                opened_at: None,
368            }),
369        }
370    }
371
372    /// Whether a call may currently proceed.
373    ///
374    /// A closed breaker admits everything. An open breaker rejects until
375    /// `open_duration` has elapsed, then admits exactly one trial call and
376    /// transitions to `HalfOpen`.
377    pub fn allow_request(&self) -> bool {
378        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
379        if inner.state == BreakerState::Open {
380            let reopened = inner
381                .opened_at
382                .is_some_and(|at| at.elapsed() >= self.config.open_duration);
383            if reopened {
384                inner.state = BreakerState::HalfOpen;
385                return true;
386            }
387            return false;
388        }
389        true
390    }
391
392    /// Record a successful call: resets the breaker to `Closed`.
393    pub fn record_success(&self) {
394        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
395        inner.state = BreakerState::Closed;
396        inner.consecutive_failures = 0;
397        inner.opened_at = None;
398    }
399
400    /// Record a failed call; trips `Open` once the threshold is reached.
401    pub fn record_failure(&self) {
402        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
403        inner.consecutive_failures += 1;
404        if inner.consecutive_failures >= self.config.failure_threshold {
405            inner.state = BreakerState::Open;
406            inner.opened_at = Some(Instant::now());
407        }
408    }
409
410    /// The current breaker state.
411    pub fn state(&self) -> BreakerState {
412        self.inner.lock().unwrap_or_else(|e| e.into_inner()).state
413    }
414}
415
416/// Hash-based sticky routing for stateful agents (P2-9).
417///
418/// The same conversation key (e.g. task id or owner) always maps to the same
419/// slot, so a stateful agent — memory, session, tool state — is pinned to one
420/// backend for the life of the conversation. The caller maps a stable slot →
421/// agent instance and keeps that mapping while the agent is healthy.
422#[derive(Debug, Clone, Copy)]
423pub struct StickyRouter {
424    slots: usize,
425}
426
427impl StickyRouter {
428    /// A router over `slots` agent instances (clamped to at least 1).
429    pub fn new(slots: usize) -> Self {
430        Self {
431            slots: slots.max(1),
432        }
433    }
434
435    /// The slot responsible for `key` — deterministic for a given key.
436    pub fn route(&self, key: &str) -> usize {
437        (fnv1a(key.as_bytes()) % self.slots as u64) as usize
438    }
439
440    /// The number of routable slots.
441    pub fn slots(&self) -> usize {
442        self.slots
443    }
444}
445
446/// Global task graph with cycle detection (P2-9).
447///
448/// Tracks `parent task → child task` delegation edges. [`TaskGraph::link`]
449/// refuses any edge that would close a cycle (a delegation loop that could
450/// otherwise run forever), and [`TaskGraph::is_acyclic`] validates the whole
451/// graph via a topological sweep.
452#[derive(Debug, Default)]
453pub struct TaskGraph {
454    children: HashMap<String, Vec<String>>,
455}
456
457impl TaskGraph {
458    /// An empty graph.
459    pub fn new() -> Self {
460        Self::default()
461    }
462
463    /// Record a `parent → child` delegation edge.
464    ///
465    /// Returns [`ScaleError::CycleDetected`] if the edge would form a cycle
466    /// (including a self-link), leaving the graph unchanged.
467    pub fn link(&mut self, parent: &str, child: &str) -> Result<(), ScaleError> {
468        if parent == child || self.would_cycle(parent, child) {
469            return Err(ScaleError::CycleDetected {
470                parent: parent.to_string(),
471                child: child.to_string(),
472            });
473        }
474        self.children
475            .entry(parent.to_string())
476            .or_default()
477            .push(child.to_string());
478        Ok(())
479    }
480
481    /// Whether adding `parent → child` would create a cycle — true when `child`
482    /// is already an ancestor of `parent`.
483    pub fn would_cycle(&self, parent: &str, child: &str) -> bool {
484        let mut stack = vec![parent.to_string()];
485        let mut seen = HashSet::new();
486        while let Some(node) = stack.pop() {
487            if node == child {
488                return true;
489            }
490            if !seen.insert(node.clone()) {
491                continue;
492            }
493            for ancestor in self.parents_of(&node) {
494                stack.push(ancestor);
495            }
496        }
497        false
498    }
499
500    /// All parents that list `node` as a child (reverse edges).
501    fn parents_of(&self, node: &str) -> Vec<String> {
502        self.children
503            .iter()
504            .filter(|(_, kids)| kids.iter().any(|k| k == node))
505            .map(|(parent, _)| parent.clone())
506            .collect()
507    }
508
509    /// Whether the whole graph is acyclic (Kahn's algorithm).
510    pub fn is_acyclic(&self) -> bool {
511        let mut in_degree: HashMap<String, usize> = HashMap::new();
512        let mut nodes: HashSet<String> = HashSet::new();
513        for (parent, kids) in &self.children {
514            nodes.insert(parent.clone());
515            in_degree.entry(parent.clone()).or_insert(0);
516            for kid in kids {
517                nodes.insert(kid.clone());
518                *in_degree.entry(kid.clone()).or_insert(0) += 1;
519            }
520        }
521        let mut queue: VecDeque<String> = nodes
522            .iter()
523            .filter(|n| in_degree.get(*n).copied().unwrap_or(0) == 0)
524            .cloned()
525            .collect();
526        let mut processed = 0;
527        while let Some(node) = queue.pop_front() {
528            processed += 1;
529            if let Some(kids) = self.children.get(&node) {
530                for kid in kids {
531                    let degree = in_degree.get_mut(kid).expect("kid is in in_degree");
532                    *degree -= 1;
533                    if *degree == 0 {
534                        queue.push_back(kid.clone());
535                    }
536                }
537            }
538        }
539        processed == nodes.len()
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    fn skill(id: &str, description: &str) -> AgentSkill {
548        AgentSkill::new(id, id, description)
549    }
550
551    #[test]
552    fn skill_index_ranks_relevant_agent_first() {
553        let mut index = SkillIndex::new();
554        index.index_card(
555            &AgentCard::new("retriever", "doc search", "http://retriever").with_skill(skill(
556                "retrieve",
557                "retrieve relevant documents from the corpus",
558            )),
559        );
560        index.index_card(
561            &AgentCard::new("summarizer", "text", "http://summarizer").with_skill(skill(
562                "summarize",
563                "condense long documents into a short summary",
564            )),
565        );
566
567        let results = index.search("retrieve documents", 2);
568        assert_eq!(results.len(), 2);
569        assert_eq!(results[0].agent_url, "http://retriever");
570
571        let limited = index.search("retrieve documents", 1);
572        assert_eq!(limited.len(), 1);
573        assert_eq!(limited[0].agent_url, "http://retriever");
574    }
575
576    #[test]
577    fn skill_index_returns_nothing_for_unmatched_query() {
578        let mut index = SkillIndex::new();
579        index.index_card(
580            &AgentCard::new("a", "a", "http://a").with_skill(skill("s", "transcribe audio")),
581        );
582        assert!(index.search("orbit physics", 5).is_empty());
583        assert!(index.search("", 5).is_empty());
584    }
585
586    #[test]
587    fn delegation_guard_enforces_hop_limit() {
588        let guard = DelegationGuard::default();
589        assert_eq!(guard.max_hops(), 10);
590        guard.check(0).unwrap();
591        guard.check(10).unwrap();
592        let err = guard.check(11).unwrap_err();
593        assert!(matches!(
594            err,
595            ScaleError::HopLimitExceeded { hops: 11, max: 10 }
596        ));
597    }
598
599    #[test]
600    fn sharder_is_stable_and_bounded() {
601        let sharder = TaskSharder::new(4);
602        // Same id always lands on the same shard.
603        assert_eq!(sharder.shard("task-1"), sharder.shard("task-1"));
604        assert_eq!(sharder.shard("task-42"), sharder.shard("task-42"));
605        // Shards are in bounds.
606        for id in ["a", "b", "c", "task-xyz"] {
607            assert!(sharder.shard(id) < 4);
608        }
609        assert_eq!(sharder.shards(["a", "b"]).len(), 2);
610        assert_eq!(sharder.num_shards(), 4);
611    }
612
613    #[test]
614    fn sharder_clamps_to_one_shard() {
615        let sharder = TaskSharder::new(0);
616        assert_eq!(sharder.num_shards(), 1);
617        assert_eq!(sharder.shard("anything"), 0);
618    }
619
620    #[test]
621    fn circuit_breaker_trips_open_then_recovers() {
622        let breaker = CircuitBreaker::new(CircuitBreakerConfig {
623            failure_threshold: 3,
624            open_duration: Duration::from_millis(20),
625        });
626
627        // Closed: admits and counts failures.
628        assert_eq!(breaker.state(), BreakerState::Closed);
629        assert!(breaker.allow_request());
630        breaker.record_failure();
631        breaker.record_failure();
632        assert!(breaker.allow_request());
633        breaker.record_failure();
634        assert_eq!(breaker.state(), BreakerState::Open);
635        assert!(!breaker.allow_request(), "open breaker must reject calls");
636
637        // After open_duration elapses, exactly one trial call is admitted.
638        std::thread::sleep(Duration::from_millis(40));
639        assert!(breaker.allow_request());
640        assert_eq!(breaker.state(), BreakerState::HalfOpen);
641
642        // Success resets to closed.
643        breaker.record_success();
644        assert_eq!(breaker.state(), BreakerState::Closed);
645        assert!(breaker.allow_request());
646    }
647
648    #[test]
649    fn sticky_router_is_deterministic() {
650        let router = StickyRouter::new(3);
651        assert_eq!(router.route("conv-1"), router.route("conv-1"));
652        assert_eq!(router.route("conv-2"), router.route("conv-2"));
653        for key in ["conv-1", "conv-2", "conv-3", "owner:alice"] {
654            assert!(router.route(key) < 3);
655        }
656        assert_eq!(router.slots(), 3);
657    }
658
659    #[test]
660    fn task_graph_accepts_acyclic_chain() {
661        let mut graph = TaskGraph::new();
662        graph.link("root", "child").unwrap();
663        graph.link("child", "grandchild").unwrap();
664        assert!(graph.is_acyclic());
665
666        // Adding root->grandchild is safe: grandchild is a descendant, not an
667        // ancestor, of root.
668        assert!(!graph.would_cycle("root", "grandchild"));
669        // But grandchild->root WOULD close the chain into a cycle.
670        assert!(graph.would_cycle("grandchild", "root"));
671
672        graph.link("root", "grandchild").unwrap();
673        assert!(graph.is_acyclic());
674    }
675
676    #[test]
677    fn task_graph_detects_cycle() {
678        let mut graph = TaskGraph::new();
679        graph.link("a", "b").unwrap();
680        graph.link("b", "c").unwrap();
681
682        // c -> a closes the loop a→b→c→a.
683        let err = graph.link("c", "a").unwrap_err();
684        assert!(
685            matches!(err, ScaleError::CycleDetected { parent, child } if parent == "c" && child == "a")
686        );
687
688        // Self-link is also a cycle.
689        let err = graph.link("x", "x").unwrap_err();
690        assert!(matches!(err, ScaleError::CycleDetected { .. }));
691    }
692
693    #[test]
694    fn task_graph_rejects_cycle_globally() {
695        let mut graph = TaskGraph::new();
696        graph.link("a", "b").unwrap();
697        graph.link("b", "c").unwrap();
698
699        // `link()` already refuses cycle-forming edges, so build a cycle
700        // directly to exercise `is_acyclic`'s global topological sweep.
701        graph
702            .children
703            .entry("c".to_string())
704            .or_default()
705            .push("a".to_string());
706        assert!(graph.would_cycle("c", "a"));
707        assert!(!graph.is_acyclic());
708    }
709
710    #[test]
711    fn hierarchy_allows_orchestrator_but_blocks_worker() {
712        let policy = HierarchyPolicy::new()
713            .with_orchestrator("http://orch")
714            .with_worker("http://worker");
715
716        policy
717            .check_delegation("http://orch", "http://worker")
718            .unwrap();
719        policy
720            .check_delegation("http://orch", "http://orch")
721            .unwrap();
722
723        let err = policy
724            .check_delegation("http://worker", "http://worker")
725            .unwrap_err();
726        assert!(matches!(err, ScaleError::WorkerToWorker));
727        // Unknown agents default to worker (least privilege).
728        assert!(matches!(
729            policy.check_delegation("http://unknown", "http://worker"),
730            Err(ScaleError::WorkerToWorker)
731        ));
732    }
733}