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}