Skip to main content

clawft_kernel/
rule_distribution.rs

1//! Cluster-wide [`GovernanceRule`] distribution (WEFT-146).
2//!
3//! Replaces the assumption that every node holds an independent static
4//! `Vec<GovernanceRule>` with a **versioned rule set** that can be
5//! gossiped, merged after split-brain, and used for cross-node
6//! escalation when local policy denies.
7//!
8//! # Wire shape
9//!
10//! Peers exchange [`RuleGossipEnvelope`] messages (JSON over mesh IPC or
11//! any other transport). Merge is last-writer-wins on `(rule_id, version)`
12//! with a deterministic tie-break on `updated_at_unix` then `origin_node`.
13//!
14//! # Escalation
15//!
16//! When local evaluation returns Deny, callers may consult
17//! [`RuleDistribution::escalate`] which records an
18//! [`EscalationRecord`] and — if a peer is known to hold a higher
19//! version of a matching rule — returns [`EscalationOutcome::Forward`].
20
21use std::collections::HashMap;
22
23use serde::{Deserialize, Serialize};
24
25use crate::governance::{GovernanceDecision, GovernanceRule, RuleSeverity};
26
27/// Versioned wrapper around a [`GovernanceRule`].
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct VersionedRule {
30    /// Monotonic per-rule version (origin node increments on edit).
31    pub version: u64,
32    /// Unix seconds of last mutation.
33    pub updated_at_unix: u64,
34    /// Node that last authored this version.
35    pub origin_node: String,
36    /// Rule body.
37    pub rule: GovernanceRule,
38}
39
40impl VersionedRule {
41    /// Construct version 1 of a rule.
42    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    /// Bump version after a local edit.
52    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/// Gossip payload carrying a full or partial rule set.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct RuleGossipEnvelope {
62    /// Sender node id.
63    pub from_node: String,
64    /// Rules being advertised / pushed.
65    pub rules: Vec<VersionedRule>,
66    /// Sender's aggregate set version (sum of rule versions) for cheap
67    /// "do I need a full sync?" checks.
68    pub set_version: u64,
69}
70
71/// Outcome of a cross-node escalation attempt.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum EscalationOutcome {
74    /// No peer has a more recent matching rule — local deny stands.
75    LocalStand,
76    /// Forward the decision request to `peer` which holds a newer rule.
77    Forward {
78        /// Peer node id.
79        peer: String,
80        /// Rule id that motivated the forward.
81        rule_id: String,
82        /// Peer's rule version.
83        peer_version: u64,
84    },
85}
86
87/// Audit trail entry for an escalation.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct EscalationRecord {
90    /// Local node.
91    pub local_node: String,
92    /// Action that was denied.
93    pub action: String,
94    /// Agent that requested it.
95    pub agent_id: String,
96    /// Outcome summary.
97    pub outcome: String,
98    /// Unix seconds.
99    pub at_unix: u64,
100}
101
102/// In-memory cluster rule store with merge + escalation.
103#[derive(Debug, Default)]
104pub struct RuleDistribution {
105    local_node: String,
106    /// rule_id → versioned rule
107    rules: HashMap<String, VersionedRule>,
108    /// Best-known remote version per rule_id → (node, version)
109    remote_hints: HashMap<String, (String, u64)>,
110    escalations: Vec<EscalationRecord>,
111}
112
113impl RuleDistribution {
114    /// Create an empty store for `local_node`.
115    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    /// Insert or replace a locally authored rule (starts at v1 or bumps).
125    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    /// Snapshot of active rules (for engine injection).
140    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    /// Aggregate set version (sum of per-rule versions).
149    pub fn set_version(&self) -> u64 {
150        self.rules.values().map(|v| v.version).sum()
151    }
152
153    /// Build a gossip envelope of the full local set.
154    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    /// Merge a remote gossip envelope (LWW on version, then timestamp, then origin).
163    ///
164    /// Returns the number of rules accepted (inserted or upgraded).
165    pub fn merge(&mut self, envelope: &RuleGossipEnvelope) -> usize {
166        let mut accepted = 0usize;
167        for remote in &envelope.rules {
168            // Track remote hints for escalation even if we don't accept.
169            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    /// After a local Deny, decide whether to escalate to a peer that holds
206    /// a newer version of a rule matching `action`.
207    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        // Prefer remote peers with strictly higher version for a rule
220        // whose id/description mentions the action, or any Blocking rule.
221        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    /// Escalation audit trail.
269    pub fn escalation_log(&self) -> &[EscalationRecord] {
270        &self.escalations
271    }
272
273    /// Number of rules currently held.
274    pub fn len(&self) -> usize {
275        self.rules.len()
276    }
277
278    /// True when no rules are held.
279    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 edits again → higher version
313        b.upsert_local(rule("exec-guard", RuleSeverity::Critical), 200);
314
315        // Simulate split: each has local view; then merge B → A
316        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); // v2
331        b.upsert_local(rule("r1", RuleSeverity::Advisory), 50); // v1 stale
332        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        // Inject remote hint as if we saw a gossip with higher version
342        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}