use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::governance::{GovernanceDecision, GovernanceRule, RuleSeverity};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionedRule {
pub version: u64,
pub updated_at_unix: u64,
pub origin_node: String,
pub rule: GovernanceRule,
}
impl VersionedRule {
pub fn new(rule: GovernanceRule, origin_node: impl Into<String>, now_unix: u64) -> Self {
Self {
version: 1,
updated_at_unix: now_unix,
origin_node: origin_node.into(),
rule,
}
}
pub fn bump(&mut self, origin_node: impl Into<String>, now_unix: u64) {
self.version = self.version.saturating_add(1);
self.updated_at_unix = now_unix;
self.origin_node = origin_node.into();
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleGossipEnvelope {
pub from_node: String,
pub rules: Vec<VersionedRule>,
pub set_version: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EscalationOutcome {
LocalStand,
Forward {
peer: String,
rule_id: String,
peer_version: u64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EscalationRecord {
pub local_node: String,
pub action: String,
pub agent_id: String,
pub outcome: String,
pub at_unix: u64,
}
#[derive(Debug, Default)]
pub struct RuleDistribution {
local_node: String,
rules: HashMap<String, VersionedRule>,
remote_hints: HashMap<String, (String, u64)>,
escalations: Vec<EscalationRecord>,
}
impl RuleDistribution {
pub fn new(local_node: impl Into<String>) -> Self {
Self {
local_node: local_node.into(),
rules: HashMap::new(),
remote_hints: HashMap::new(),
escalations: Vec::new(),
}
}
pub fn upsert_local(&mut self, rule: GovernanceRule, now_unix: u64) {
let id = rule.id.clone();
match self.rules.get_mut(&id) {
Some(existing) => {
existing.rule = rule;
existing.bump(self.local_node.clone(), now_unix);
}
None => {
self.rules
.insert(id, VersionedRule::new(rule, self.local_node.clone(), now_unix));
}
}
}
pub fn active_rules(&self) -> Vec<GovernanceRule> {
self.rules
.values()
.filter(|v| v.rule.active)
.map(|v| v.rule.clone())
.collect()
}
pub fn set_version(&self) -> u64 {
self.rules.values().map(|v| v.version).sum()
}
pub fn gossip_full(&self) -> RuleGossipEnvelope {
RuleGossipEnvelope {
from_node: self.local_node.clone(),
rules: self.rules.values().cloned().collect(),
set_version: self.set_version(),
}
}
pub fn merge(&mut self, envelope: &RuleGossipEnvelope) -> usize {
let mut accepted = 0usize;
for remote in &envelope.rules {
self.remote_hints.insert(
remote.rule.id.clone(),
(envelope.from_node.clone(), remote.version),
);
match self.rules.get(&remote.rule.id) {
None => {
self.rules
.insert(remote.rule.id.clone(), remote.clone());
accepted += 1;
}
Some(local) => {
if Self::remote_wins(local, remote) {
self.rules
.insert(remote.rule.id.clone(), remote.clone());
accepted += 1;
}
}
}
}
accepted
}
fn remote_wins(local: &VersionedRule, remote: &VersionedRule) -> bool {
use std::cmp::Ordering;
match remote.version.cmp(&local.version) {
Ordering::Greater => true,
Ordering::Less => false,
Ordering::Equal => match remote.updated_at_unix.cmp(&local.updated_at_unix) {
Ordering::Greater => true,
Ordering::Less => false,
Ordering::Equal => remote.origin_node > local.origin_node,
},
}
}
pub fn escalate(
&mut self,
agent_id: &str,
action: &str,
local_decision: &GovernanceDecision,
now_unix: u64,
) -> EscalationOutcome {
let is_deny = matches!(local_decision, GovernanceDecision::Deny(_));
if !is_deny {
return EscalationOutcome::LocalStand;
}
let mut best: Option<(String, String, u64)> = None;
for (rule_id, (peer, ver)) in &self.remote_hints {
let local_ver = self.rules.get(rule_id).map(|r| r.version).unwrap_or(0);
if *ver <= local_ver {
continue;
}
let relevant = self
.rules
.get(rule_id)
.map(|r| {
r.rule.severity == RuleSeverity::Blocking
|| r.rule.severity == RuleSeverity::Critical
|| r.rule.id.contains(action)
|| action.contains(&r.rule.id)
})
.unwrap_or(true);
if !relevant {
continue;
}
let replace = match &best {
None => true,
Some((_, _, bv)) => ver > bv,
};
if replace {
best = Some((peer.clone(), rule_id.clone(), *ver));
}
}
let outcome = match best {
Some((peer, rule_id, peer_version)) => EscalationOutcome::Forward {
peer,
rule_id,
peer_version,
},
None => EscalationOutcome::LocalStand,
};
self.escalations.push(EscalationRecord {
local_node: self.local_node.clone(),
action: action.into(),
agent_id: agent_id.into(),
outcome: format!("{outcome:?}"),
at_unix: now_unix,
});
outcome
}
pub fn escalation_log(&self) -> &[EscalationRecord] {
&self.escalations
}
pub fn len(&self) -> usize {
self.rules.len()
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::governance::{GovernanceBranch, GovernanceRule, RuleSeverity};
fn rule(id: &str, severity: RuleSeverity) -> GovernanceRule {
GovernanceRule {
id: id.into(),
description: format!("test {id}"),
severity,
branch: GovernanceBranch::Judicial,
active: true,
reference_url: None,
sop_category: None,
rule_type: Default::default(),
action_selector: None,
tool_selector: None,
force_on_match: false,
}
}
#[test]
fn split_merge_lww() {
let mut a = RuleDistribution::new("node-a");
let mut b = RuleDistribution::new("node-b");
a.upsert_local(rule("exec-guard", RuleSeverity::Blocking), 100);
b.upsert_local(rule("exec-guard", RuleSeverity::Warning), 100);
b.upsert_local(rule("exec-guard", RuleSeverity::Critical), 200);
let gossip = b.gossip_full();
let accepted = a.merge(&gossip);
assert_eq!(accepted, 1);
let active = a.active_rules();
assert_eq!(active.len(), 1);
assert_eq!(active[0].severity, RuleSeverity::Critical);
assert_eq!(a.rules.get("exec-guard").unwrap().version, 2);
}
#[test]
fn merge_ignores_stale() {
let mut a = RuleDistribution::new("node-a");
let mut b = RuleDistribution::new("node-b");
a.upsert_local(rule("r1", RuleSeverity::Blocking), 100);
a.upsert_local(rule("r1", RuleSeverity::Critical), 200); b.upsert_local(rule("r1", RuleSeverity::Advisory), 50); let accepted = a.merge(&b.gossip_full());
assert_eq!(accepted, 0);
assert_eq!(a.rules.get("r1").unwrap().version, 2);
}
#[test]
fn escalation_forwards_when_peer_newer() {
let mut local = RuleDistribution::new("node-a");
local.upsert_local(rule("cron-guard", RuleSeverity::Blocking), 100);
local.remote_hints.insert(
"cron-guard".into(),
("node-b".into(), 5),
);
let outcome = local.escalate(
"agent-1",
"cron.add",
&GovernanceDecision::Deny("blocked".into()),
999,
);
match outcome {
EscalationOutcome::Forward {
peer,
rule_id,
peer_version,
} => {
assert_eq!(peer, "node-b");
assert_eq!(rule_id, "cron-guard");
assert_eq!(peer_version, 5);
}
other => panic!("expected Forward, got {other:?}"),
}
assert_eq!(local.escalation_log().len(), 1);
}
#[test]
fn escalation_stands_on_permit() {
let mut local = RuleDistribution::new("node-a");
let outcome = local.escalate(
"agent-1",
"tool.read",
&GovernanceDecision::Permit,
1,
);
assert_eq!(outcome, EscalationOutcome::LocalStand);
}
#[test]
fn serde_gossip_roundtrip() {
let mut a = RuleDistribution::new("n1");
a.upsert_local(rule("x", RuleSeverity::Warning), 1);
let env = a.gossip_full();
let json = serde_json::to_string(&env).unwrap();
let back: RuleGossipEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(back.from_node, "n1");
assert_eq!(back.rules.len(), 1);
}
}