Skip to main content

plexus_core/plexus/
forward_registry.rs

1//! `ForwardPolicyRegistry` — per-hub map from callee namespace to the
2//! [`ForwardPolicy`] that the framework consults when dispatching across the
3//! boundary into that callee.
4//!
5//! AUTHLANG-3 wires this into the canonical edge-crossing point
6//! ([`super::plexus::route_to_child`]). The registry holds
7//! `Arc<dyn ForwardPolicy>` keyed by the lowercased callee namespace string
8//! (matching the `child_routers` key convention on
9//! [`super::plexus::DynamicHub`]). Without an entry, the framework treats the
10//! lookup as "no opinion declared" and falls back to
11//! [`plexus_auth_core::IdentityOnly`] — the safe default per the spike.
12//!
13//! AUTHLANG-4 (the `#[plexus::activation(forward_policy = ...)]` macro) is
14//! the supported declarative path that populates the registry. Imperative
15//! registration is intentionally exposed (via [`Self::register`]) so
16//! integration tests and hand-rolled wiring can drive the same code path
17//! without going through the macro.
18//!
19//! # Vocabulary
20//!
21//! Per AUTHZ-0: `caller` and `callee`, not `parent` and `child`. The
22//! existing `ChildRouter` API name is preserved as legacy; the spike's
23//! decision to keep that name is documented in
24//! `plans/AUTHLANG/AUTHLANG-S01-output.md`.
25
26use plexus_auth_core::ForwardPolicy;
27use std::collections::HashMap;
28use std::sync::Arc;
29
30/// Registry of [`ForwardPolicy`] implementations keyed by callee namespace.
31///
32/// `Clone` is cheap: every policy is wrapped in `Arc`, and the underlying
33/// `HashMap` clones the key strings + the `Arc` handles. The registry is
34/// designed to be built at hub-construction time and read at dispatch time;
35/// mutating after the hub is shared across tasks requires going through
36/// [`DynamicHub::with_forward_policy`](super::plexus::DynamicHub) at builder
37/// time.
38///
39/// [`DynamicHub::with_forward_policy`]: super::plexus::DynamicHub::with_forward_policy
40#[derive(Clone, Default)]
41pub struct ForwardPolicyRegistry {
42    inner: HashMap<String, Arc<dyn ForwardPolicy>>,
43}
44
45impl ForwardPolicyRegistry {
46    /// Construct an empty registry.
47    pub fn new() -> Self {
48        Self {
49            inner: HashMap::new(),
50        }
51    }
52
53    /// Register a policy for a callee namespace.
54    ///
55    /// The namespace is the same lowercased token used to look up
56    /// [`super::plexus::ChildRouter::get_child`] (e.g. `"solar"`,
57    /// `"echo"`). If a policy is already registered for the namespace, it
58    /// is replaced.
59    pub fn register(&mut self, callee_ns: impl Into<String>, policy: Arc<dyn ForwardPolicy>) {
60        self.inner.insert(callee_ns.into(), policy);
61    }
62
63    /// Look up the policy declared for a callee namespace.
64    ///
65    /// Returns `None` when no policy is registered; the dispatch path
66    /// interprets that as "fall back to [`plexus_auth_core::IdentityOnly`]"
67    /// — the spike-pinned safe default.
68    pub fn get(&self, callee_ns: &str) -> Option<Arc<dyn ForwardPolicy>> {
69        self.inner.get(callee_ns).cloned()
70    }
71
72    /// Returns `true` when the registry contains no entries.
73    pub fn is_empty(&self) -> bool {
74        self.inner.is_empty()
75    }
76
77    /// Number of registered policies.
78    pub fn len(&self) -> usize {
79        self.inner.len()
80    }
81}
82
83impl std::fmt::Debug for ForwardPolicyRegistry {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        // Avoid printing the trait-object pointer; surface only the keys so
86        // logs/snapshots stay deterministic.
87        let mut keys: Vec<&String> = self.inner.keys().collect();
88        keys.sort();
89        f.debug_struct("ForwardPolicyRegistry")
90            .field("entries", &keys)
91            .finish()
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use plexus_auth_core::{Anonymous, IdentityOnly, PassThrough};
99
100    #[test]
101    fn empty_registry_returns_none_for_any_key() {
102        let r = ForwardPolicyRegistry::new();
103        assert!(r.is_empty());
104        assert!(r.get("solar").is_none());
105        assert!(r.get("nonexistent").is_none());
106    }
107
108    #[test]
109    fn register_then_lookup_returns_arc() {
110        let mut r = ForwardPolicyRegistry::new();
111        r.register("solar", Arc::new(PassThrough));
112        r.register("echo", Arc::new(Anonymous));
113        assert_eq!(r.len(), 2);
114        assert_eq!(r.get("solar").unwrap().name().as_str(), "pass_through");
115        assert_eq!(r.get("echo").unwrap().name().as_str(), "anonymous");
116        assert!(r.get("missing").is_none());
117    }
118
119    #[test]
120    fn register_replaces_existing_entry() {
121        let mut r = ForwardPolicyRegistry::new();
122        r.register("solar", Arc::new(IdentityOnly));
123        r.register("solar", Arc::new(PassThrough));
124        assert_eq!(r.len(), 1);
125        assert_eq!(r.get("solar").unwrap().name().as_str(), "pass_through");
126    }
127
128    #[test]
129    fn default_is_empty() {
130        let r = ForwardPolicyRegistry::default();
131        assert!(r.is_empty());
132    }
133
134    #[test]
135    fn debug_lists_keys_alphabetically() {
136        let mut r = ForwardPolicyRegistry::new();
137        r.register("solar", Arc::new(PassThrough));
138        r.register("echo", Arc::new(Anonymous));
139        let dbg = format!("{:?}", r);
140        // Keys are sorted in Debug so snapshots are stable.
141        assert!(dbg.contains("echo"));
142        assert!(dbg.contains("solar"));
143        let echo_pos = dbg.find("echo").unwrap();
144        let solar_pos = dbg.find("solar").unwrap();
145        assert!(echo_pos < solar_pos);
146    }
147}