1use std::collections::HashMap;
22
23use serde::{Deserialize, Serialize};
24
25use crate::governance::{GovernanceDecision, GovernanceRule, RuleSeverity};
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct VersionedRule {
30 pub version: u64,
32 pub updated_at_unix: u64,
34 pub origin_node: String,
36 pub rule: GovernanceRule,
38}
39
40impl VersionedRule {
41 pub fn new(rule: GovernanceRule, origin_node: impl Into<String>, now_unix: u64) -> Self {
43 Self {
44 version: 1,
45 updated_at_unix: now_unix,
46 origin_node: origin_node.into(),
47 rule,
48 }
49 }
50
51 pub fn bump(&mut self, origin_node: impl Into<String>, now_unix: u64) {
53 self.version = self.version.saturating_add(1);
54 self.updated_at_unix = now_unix;
55 self.origin_node = origin_node.into();
56 }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct RuleGossipEnvelope {
62 pub from_node: String,
64 pub rules: Vec<VersionedRule>,
66 pub set_version: u64,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum EscalationOutcome {
74 LocalStand,
76 Forward {
78 peer: String,
80 rule_id: String,
82 peer_version: u64,
84 },
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct EscalationRecord {
90 pub local_node: String,
92 pub action: String,
94 pub agent_id: String,
96 pub outcome: String,
98 pub at_unix: u64,
100}
101
102#[derive(Debug, Default)]
104pub struct RuleDistribution {
105 local_node: String,
106 rules: HashMap<String, VersionedRule>,
108 remote_hints: HashMap<String, (String, u64)>,
110 escalations: Vec<EscalationRecord>,
111}
112
113impl RuleDistribution {
114 pub fn new(local_node: impl Into<String>) -> Self {
116 Self {
117 local_node: local_node.into(),
118 rules: HashMap::new(),
119 remote_hints: HashMap::new(),
120 escalations: Vec::new(),
121 }
122 }
123
124 pub fn upsert_local(&mut self, rule: GovernanceRule, now_unix: u64) {
126 let id = rule.id.clone();
127 match self.rules.get_mut(&id) {
128 Some(existing) => {
129 existing.rule = rule;
130 existing.bump(self.local_node.clone(), now_unix);
131 }
132 None => {
133 self.rules
134 .insert(id, VersionedRule::new(rule, self.local_node.clone(), now_unix));
135 }
136 }
137 }
138
139 pub fn active_rules(&self) -> Vec<GovernanceRule> {
141 self.rules
142 .values()
143 .filter(|v| v.rule.active)
144 .map(|v| v.rule.clone())
145 .collect()
146 }
147
148 pub fn set_version(&self) -> u64 {
150 self.rules.values().map(|v| v.version).sum()
151 }
152
153 pub fn gossip_full(&self) -> RuleGossipEnvelope {
155 RuleGossipEnvelope {
156 from_node: self.local_node.clone(),
157 rules: self.rules.values().cloned().collect(),
158 set_version: self.set_version(),
159 }
160 }
161
162 pub fn merge(&mut self, envelope: &RuleGossipEnvelope) -> usize {
166 let mut accepted = 0usize;
167 for remote in &envelope.rules {
168 self.remote_hints.insert(
170 remote.rule.id.clone(),
171 (envelope.from_node.clone(), remote.version),
172 );
173
174 match self.rules.get(&remote.rule.id) {
175 None => {
176 self.rules
177 .insert(remote.rule.id.clone(), remote.clone());
178 accepted += 1;
179 }
180 Some(local) => {
181 if Self::remote_wins(local, remote) {
182 self.rules
183 .insert(remote.rule.id.clone(), remote.clone());
184 accepted += 1;
185 }
186 }
187 }
188 }
189 accepted
190 }
191
192 fn remote_wins(local: &VersionedRule, remote: &VersionedRule) -> bool {
193 use std::cmp::Ordering;
194 match remote.version.cmp(&local.version) {
195 Ordering::Greater => true,
196 Ordering::Less => false,
197 Ordering::Equal => match remote.updated_at_unix.cmp(&local.updated_at_unix) {
198 Ordering::Greater => true,
199 Ordering::Less => false,
200 Ordering::Equal => remote.origin_node > local.origin_node,
201 },
202 }
203 }
204
205 pub fn escalate(
208 &mut self,
209 agent_id: &str,
210 action: &str,
211 local_decision: &GovernanceDecision,
212 now_unix: u64,
213 ) -> EscalationOutcome {
214 let is_deny = matches!(local_decision, GovernanceDecision::Deny(_));
215 if !is_deny {
216 return EscalationOutcome::LocalStand;
217 }
218
219 let mut best: Option<(String, String, u64)> = None;
222 for (rule_id, (peer, ver)) in &self.remote_hints {
223 let local_ver = self.rules.get(rule_id).map(|r| r.version).unwrap_or(0);
224 if *ver <= local_ver {
225 continue;
226 }
227 let relevant = self
228 .rules
229 .get(rule_id)
230 .map(|r| {
231 r.rule.severity == RuleSeverity::Blocking
232 || r.rule.severity == RuleSeverity::Critical
233 || r.rule.id.contains(action)
234 || action.contains(&r.rule.id)
235 })
236 .unwrap_or(true);
237 if !relevant {
238 continue;
239 }
240 let replace = match &best {
241 None => true,
242 Some((_, _, bv)) => ver > bv,
243 };
244 if replace {
245 best = Some((peer.clone(), rule_id.clone(), *ver));
246 }
247 }
248
249 let outcome = match best {
250 Some((peer, rule_id, peer_version)) => EscalationOutcome::Forward {
251 peer,
252 rule_id,
253 peer_version,
254 },
255 None => EscalationOutcome::LocalStand,
256 };
257
258 self.escalations.push(EscalationRecord {
259 local_node: self.local_node.clone(),
260 action: action.into(),
261 agent_id: agent_id.into(),
262 outcome: format!("{outcome:?}"),
263 at_unix: now_unix,
264 });
265 outcome
266 }
267
268 pub fn escalation_log(&self) -> &[EscalationRecord] {
270 &self.escalations
271 }
272
273 pub fn len(&self) -> usize {
275 self.rules.len()
276 }
277
278 pub fn is_empty(&self) -> bool {
280 self.rules.is_empty()
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::governance::{GovernanceBranch, GovernanceRule, RuleSeverity};
288
289 fn rule(id: &str, severity: RuleSeverity) -> GovernanceRule {
290 GovernanceRule {
291 id: id.into(),
292 description: format!("test {id}"),
293 severity,
294 branch: GovernanceBranch::Judicial,
295 active: true,
296 reference_url: None,
297 sop_category: None,
298 rule_type: Default::default(),
299 action_selector: None,
300 tool_selector: None,
301 force_on_match: false,
302 }
303 }
304
305 #[test]
306 fn split_merge_lww() {
307 let mut a = RuleDistribution::new("node-a");
308 let mut b = RuleDistribution::new("node-b");
309
310 a.upsert_local(rule("exec-guard", RuleSeverity::Blocking), 100);
311 b.upsert_local(rule("exec-guard", RuleSeverity::Warning), 100);
312 b.upsert_local(rule("exec-guard", RuleSeverity::Critical), 200);
314
315 let gossip = b.gossip_full();
317 let accepted = a.merge(&gossip);
318 assert_eq!(accepted, 1);
319 let active = a.active_rules();
320 assert_eq!(active.len(), 1);
321 assert_eq!(active[0].severity, RuleSeverity::Critical);
322 assert_eq!(a.rules.get("exec-guard").unwrap().version, 2);
323 }
324
325 #[test]
326 fn merge_ignores_stale() {
327 let mut a = RuleDistribution::new("node-a");
328 let mut b = RuleDistribution::new("node-b");
329 a.upsert_local(rule("r1", RuleSeverity::Blocking), 100);
330 a.upsert_local(rule("r1", RuleSeverity::Critical), 200); b.upsert_local(rule("r1", RuleSeverity::Advisory), 50); let accepted = a.merge(&b.gossip_full());
333 assert_eq!(accepted, 0);
334 assert_eq!(a.rules.get("r1").unwrap().version, 2);
335 }
336
337 #[test]
338 fn escalation_forwards_when_peer_newer() {
339 let mut local = RuleDistribution::new("node-a");
340 local.upsert_local(rule("cron-guard", RuleSeverity::Blocking), 100);
341 local.remote_hints.insert(
343 "cron-guard".into(),
344 ("node-b".into(), 5),
345 );
346 let outcome = local.escalate(
347 "agent-1",
348 "cron.add",
349 &GovernanceDecision::Deny("blocked".into()),
350 999,
351 );
352 match outcome {
353 EscalationOutcome::Forward {
354 peer,
355 rule_id,
356 peer_version,
357 } => {
358 assert_eq!(peer, "node-b");
359 assert_eq!(rule_id, "cron-guard");
360 assert_eq!(peer_version, 5);
361 }
362 other => panic!("expected Forward, got {other:?}"),
363 }
364 assert_eq!(local.escalation_log().len(), 1);
365 }
366
367 #[test]
368 fn escalation_stands_on_permit() {
369 let mut local = RuleDistribution::new("node-a");
370 let outcome = local.escalate(
371 "agent-1",
372 "tool.read",
373 &GovernanceDecision::Permit,
374 1,
375 );
376 assert_eq!(outcome, EscalationOutcome::LocalStand);
377 }
378
379 #[test]
380 fn serde_gossip_roundtrip() {
381 let mut a = RuleDistribution::new("n1");
382 a.upsert_local(rule("x", RuleSeverity::Warning), 1);
383 let env = a.gossip_full();
384 let json = serde_json::to_string(&env).unwrap();
385 let back: RuleGossipEnvelope = serde_json::from_str(&json).unwrap();
386 assert_eq!(back.from_node, "n1");
387 assert_eq!(back.rules.len(), 1);
388 }
389}