Skip to main content

edda_postmortem/
rules.rs

1//! Rules store with immune-system lifecycle and TTL decay.
2//!
3//! Rules are learned from post-mortem analysis and enforced via hooks.
4//! Each rule follows an immune-system lifecycle:
5//!
6//!   Proposed -> Active -> Dormant -> Settled -> Dead
7//!                                           |
8//!                              Superseded --+
9//!
10//! Three decay mechanisms:
11//! - **Time decay**: TTL (default 30 days), reset on each trigger hit
12//! - **Anchor decay**: Rule anchored to file; file changes -> stale
13//! - **Contradiction detection**: Same trigger, different action -> supersede
14
15use serde::{Deserialize, Serialize};
16use sha2::{Digest as Sha2Digest, Sha256};
17use std::fs;
18use std::path::{Path, PathBuf};
19use time::OffsetDateTime;
20
21/// Default TTL in days for new rules.
22const DEFAULT_TTL_DAYS: u32 = 30;
23
24/// Maximum number of active rules enforced simultaneously.
25const MAX_ACTIVE_RULES: usize = 15;
26
27/// Days after last_hit before a rule transitions from Active -> Dormant.
28const DORMANT_THRESHOLD_DAYS: i64 = 30;
29
30/// Days after last_hit before Dormant -> Settled.
31const SETTLED_THRESHOLD_DAYS: i64 = 60;
32
33/// Days after last_hit before Settled -> Dead.
34const DEAD_THRESHOLD_DAYS: i64 = 90;
35
36/// Minimum confirmations to promote Proposed -> Active.
37const MIN_CONFIRMATIONS: u64 = 2;
38
39/// Rule lifecycle status (immune system model).
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42pub enum RuleStatus {
43    /// First observation, needs confirmation (pattern repeated 2x to activate).
44    Proposed,
45    /// Pattern confirmed, rule is enforced.
46    Active,
47    /// TTL window passed without trigger; rule is suspended.
48    Dormant,
49    /// Long dormant, near death.
50    Settled,
51    /// TTL expired completely; rule is archived.
52    Dead,
53    /// Contradicted by a newer rule with the same trigger.
54    Superseded,
55}
56
57impl std::fmt::Display for RuleStatus {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            Self::Proposed => write!(f, "proposed"),
61            Self::Active => write!(f, "active"),
62            Self::Dormant => write!(f, "dormant"),
63            Self::Settled => write!(f, "settled"),
64            Self::Dead => write!(f, "dead"),
65            Self::Superseded => write!(f, "superseded"),
66        }
67    }
68}
69
70/// What kind of rule this is — determines enforcement mechanism.
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(rename_all = "snake_case")]
73pub enum RuleCategory {
74    /// Check before commit (PreCommit hook).
75    PreCommit,
76    /// Check before push (PrePush hook).
77    PrePush,
78    /// Code pattern to avoid/enforce.
79    CodePattern,
80    /// Workflow pattern to follow.
81    Workflow,
82}
83
84impl std::fmt::Display for RuleCategory {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        match self {
87            Self::PreCommit => write!(f, "pre_commit"),
88            Self::PrePush => write!(f, "pre_push"),
89            Self::CodePattern => write!(f, "code_pattern"),
90            Self::Workflow => write!(f, "workflow"),
91        }
92    }
93}
94
95/// A learned rule with TTL decay.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Rule {
98    pub id: String,
99    pub trigger: String,
100    pub action: String,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub anchor_file: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub anchor_hash: Option<String>,
105    pub created: String,
106    pub last_hit: String,
107    pub hits: u64,
108    pub ttl_days: u32,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub superseded_by: Option<String>,
111    pub status: RuleStatus,
112    pub source_session: String,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub source_event: Option<String>,
115    pub category: RuleCategory,
116}
117
118impl Rule {
119    /// Check if this rule is enforceable (Active status).
120    pub fn is_enforceable(&self) -> bool {
121        self.status == RuleStatus::Active
122    }
123
124    /// Check if this rule is alive (not Dead or Superseded).
125    pub fn is_alive(&self) -> bool {
126        !matches!(self.status, RuleStatus::Dead | RuleStatus::Superseded)
127    }
128
129    /// Record a trigger hit: increment counter and reset TTL.
130    pub fn record_hit(&mut self) {
131        self.hits += 1;
132        self.last_hit = now_rfc3339();
133        // If proposed and enough hits, promote to active
134        if self.status == RuleStatus::Proposed && self.hits >= MIN_CONFIRMATIONS {
135            self.status = RuleStatus::Active;
136        }
137        // If dormant/settled, reactivate on hit
138        if matches!(self.status, RuleStatus::Dormant | RuleStatus::Settled) {
139            self.status = RuleStatus::Active;
140        }
141    }
142
143    /// Compute days since last hit.
144    pub fn days_since_last_hit(&self) -> Option<i64> {
145        let last = parse_rfc3339(&self.last_hit)?;
146        let now = OffsetDateTime::now_utc();
147        Some((now - last).whole_days())
148    }
149
150    /// Apply time-based decay to this rule's status.
151    pub fn apply_time_decay(&mut self) {
152        if matches!(
153            self.status,
154            RuleStatus::Dead | RuleStatus::Superseded | RuleStatus::Proposed
155        ) {
156            return;
157        }
158
159        let days = match self.days_since_last_hit() {
160            Some(d) => d,
161            None => return,
162        };
163
164        if days >= DEAD_THRESHOLD_DAYS {
165            self.status = RuleStatus::Dead;
166        } else if days >= SETTLED_THRESHOLD_DAYS {
167            self.status = RuleStatus::Settled;
168        } else if days >= DORMANT_THRESHOLD_DAYS {
169            self.status = RuleStatus::Dormant;
170        }
171        // else: still Active, no change
172    }
173}
174
175/// The rules store: manages rules.json persistence and lifecycle.
176#[derive(Debug, Clone, Serialize, Deserialize, Default)]
177pub struct RulesStore {
178    pub rules: Vec<Rule>,
179    #[serde(default)]
180    pub last_decay_run: Option<String>,
181}
182
183impl RulesStore {
184    /// Load rules store from disk. Returns default if file doesn't exist.
185    pub fn load(path: &Path) -> Self {
186        match fs::read_to_string(path) {
187            Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
188            Err(_) => Self::default(),
189        }
190    }
191
192    /// Persist rules store to disk atomically.
193    pub fn save(&self, path: &Path) -> anyhow::Result<()> {
194        let json = serde_json::to_string_pretty(self)?;
195        edda_store::write_atomic(path, json.as_bytes())
196    }
197
198    /// Resolve the rules.json path for a project.
199    pub fn project_rules_path(project_id: &str) -> PathBuf {
200        edda_store::project_dir(project_id)
201            .join("state")
202            .join("rules.json")
203    }
204
205    /// Resolve the global rules.json path (~/.edda/rules.json).
206    pub fn global_rules_path() -> PathBuf {
207        edda_store::store_root().join("rules.json")
208    }
209
210    /// Load project-scoped rules.
211    pub fn load_project(project_id: &str) -> Self {
212        Self::load(&Self::project_rules_path(project_id))
213    }
214
215    /// Save project-scoped rules.
216    pub fn save_project(&self, project_id: &str) -> anyhow::Result<()> {
217        self.save(&Self::project_rules_path(project_id))
218    }
219
220    /// Get all active (enforceable) rules.
221    pub fn active_rules(&self) -> Vec<&Rule> {
222        self.rules.iter().filter(|r| r.is_enforceable()).collect()
223    }
224
225    /// Get all alive rules (not dead/superseded).
226    pub fn alive_rules(&self) -> Vec<&Rule> {
227        self.rules.iter().filter(|r| r.is_alive()).collect()
228    }
229
230    /// Add a new rule proposal. If a rule with the same trigger already exists
231    /// and is alive, increment its hits instead (confirmation).
232    pub fn propose_rule(
233        &mut self,
234        trigger: String,
235        action: String,
236        anchor_file: Option<String>,
237        category: RuleCategory,
238        source_session: String,
239        source_event: Option<String>,
240    ) -> String {
241        // Check for contradiction: same trigger, different action -> supersede old
242        let mut superseded_ids = Vec::new();
243        for rule in &self.rules {
244            if rule.trigger == trigger && rule.is_alive() {
245                if rule.action == action {
246                    // Same trigger + same action: confirmation, not new rule.
247                    // Find the mutable reference and record hit.
248                    let rule_id = rule.id.clone();
249                    if let Some(existing) = self.rules.iter_mut().find(|r| r.id == rule_id) {
250                        existing.record_hit();
251                    }
252                    return rule_id;
253                }
254                // Same trigger, different action -> contradiction
255                superseded_ids.push(rule.id.clone());
256            }
257        }
258
259        // Supersede contradicting rules
260        let new_id = new_rule_id();
261        for sid in &superseded_ids {
262            if let Some(old_rule) = self.rules.iter_mut().find(|r| r.id == *sid) {
263                old_rule.status = RuleStatus::Superseded;
264                old_rule.superseded_by = Some(new_id.clone());
265            }
266        }
267
268        // Compute anchor hash if anchor file provided
269        let anchor_hash = anchor_file.as_ref().and_then(|f| file_sha256(f));
270
271        let now = now_rfc3339();
272        let rule = Rule {
273            id: new_id.clone(),
274            trigger,
275            action,
276            anchor_file,
277            anchor_hash,
278            created: now.clone(),
279            last_hit: now,
280            hits: 1,
281            ttl_days: DEFAULT_TTL_DAYS,
282            superseded_by: None,
283            status: RuleStatus::Proposed,
284            source_session,
285            source_event,
286            category,
287        };
288
289        self.rules.push(rule);
290        new_id
291    }
292
293    /// Run the full decay cycle on all rules.
294    ///
295    /// 1. Time decay: check TTL against last_hit
296    /// 2. Anchor decay: check if anchored file changed
297    /// 3. Enforce active window cap (~15)
298    pub fn run_decay_cycle(&mut self) {
299        // 1. Time decay
300        for rule in &mut self.rules {
301            rule.apply_time_decay();
302        }
303
304        // 2. Anchor decay: mark rules stale if anchored file changed
305        for rule in &mut self.rules {
306            if !rule.is_alive() {
307                continue;
308            }
309            if let (Some(ref anchor_file), Some(ref stored_hash)) =
310                (&rule.anchor_file, &rule.anchor_hash)
311            {
312                if let Some(current_hash) = file_sha256(anchor_file) {
313                    if current_hash != *stored_hash && rule.status == RuleStatus::Active {
314                        rule.status = RuleStatus::Dormant;
315                    }
316                } else if !Path::new(anchor_file).exists() && rule.status == RuleStatus::Active {
317                    rule.status = RuleStatus::Dormant;
318                }
319            }
320        }
321
322        // 3. Enforce active window cap: keep top N by hits, demote rest
323        let mut active_ids: Vec<(String, u64)> = self
324            .rules
325            .iter()
326            .filter(|r| r.status == RuleStatus::Active)
327            .map(|r| (r.id.clone(), r.hits))
328            .collect();
329        active_ids.sort_by_key(|entry| std::cmp::Reverse(entry.1)); // Sort by hits descending
330        if active_ids.len() > MAX_ACTIVE_RULES {
331            let demote_ids: Vec<String> = active_ids[MAX_ACTIVE_RULES..]
332                .iter()
333                .map(|(id, _)| id.clone())
334                .collect();
335            for rule in &mut self.rules {
336                if demote_ids.contains(&rule.id) {
337                    rule.status = RuleStatus::Dormant;
338                }
339            }
340        }
341
342        self.last_decay_run = Some(now_rfc3339());
343    }
344
345    /// Garbage-collect dead rules (remove from store entirely).
346    pub fn gc_dead_rules(&mut self) -> usize {
347        let before = self.rules.len();
348        self.rules.retain(|r| !matches!(r.status, RuleStatus::Dead));
349        before - self.rules.len()
350    }
351
352    /// Find rules matching a given trigger pattern (substring match).
353    pub fn find_by_trigger(&self, trigger_pattern: &str) -> Vec<&Rule> {
354        self.rules
355            .iter()
356            .filter(|r| r.trigger.contains(trigger_pattern))
357            .collect()
358    }
359
360    /// Get a rule by ID.
361    pub fn get(&self, id: &str) -> Option<&Rule> {
362        self.rules.iter().find(|r| r.id == id)
363    }
364
365    /// Get a mutable rule by ID.
366    pub fn get_mut(&mut self, id: &str) -> Option<&mut Rule> {
367        self.rules.iter_mut().find(|r| r.id == id)
368    }
369
370    /// Summary statistics.
371    pub fn stats(&self) -> StoreStats {
372        let mut stats = StoreStats::default();
373        for rule in &self.rules {
374            match rule.status {
375                RuleStatus::Proposed => stats.proposed += 1,
376                RuleStatus::Active => stats.active += 1,
377                RuleStatus::Dormant => stats.dormant += 1,
378                RuleStatus::Settled => stats.settled += 1,
379                RuleStatus::Dead => stats.dead += 1,
380                RuleStatus::Superseded => stats.superseded += 1,
381            }
382        }
383        stats.total = self.rules.len();
384        stats
385    }
386}
387
388/// Summary statistics for the rules store.
389#[derive(Debug, Clone, Default, Serialize, Deserialize)]
390pub struct StoreStats {
391    pub total: usize,
392    pub proposed: usize,
393    pub active: usize,
394    pub dormant: usize,
395    pub settled: usize,
396    pub dead: usize,
397    pub superseded: usize,
398}
399
400// -- Helpers --
401
402fn new_rule_id() -> String {
403    format!("rule_{}", ulid::Ulid::new().to_string().to_lowercase())
404}
405
406fn now_rfc3339() -> String {
407    let now = OffsetDateTime::now_utc();
408    now.format(&time::format_description::well_known::Rfc3339)
409        .expect("RFC3339 formatting should not fail")
410}
411
412fn parse_rfc3339(s: &str) -> Option<OffsetDateTime> {
413    OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
414}
415
416/// Compute SHA-256 of a file's contents. Returns None if file unreadable.
417fn file_sha256(path: &str) -> Option<String> {
418    let data = fs::read(path).ok()?;
419    let hash = Sha256::digest(&data);
420    Some(hex::encode(hash))
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    fn make_rule(trigger: &str, action: &str, status: RuleStatus) -> Rule {
428        Rule {
429            id: new_rule_id(),
430            trigger: trigger.to_string(),
431            action: action.to_string(),
432            anchor_file: None,
433            anchor_hash: None,
434            created: now_rfc3339(),
435            last_hit: now_rfc3339(),
436            hits: 2,
437            ttl_days: DEFAULT_TTL_DAYS,
438            superseded_by: None,
439            status,
440            source_session: "test-session".to_string(),
441            source_event: None,
442            category: RuleCategory::PreCommit,
443        }
444    }
445
446    #[test]
447    fn new_store_is_empty() {
448        let store = RulesStore::default();
449        assert!(store.rules.is_empty());
450        assert!(store.active_rules().is_empty());
451    }
452
453    #[test]
454    fn propose_creates_proposed_rule() {
455        let mut store = RulesStore::default();
456        let id = store.propose_rule(
457            "test failure".into(),
458            "run tests before commit".into(),
459            None,
460            RuleCategory::PreCommit,
461            "s1".into(),
462            None,
463        );
464        assert!(!id.is_empty());
465        let rule = store.get(&id).unwrap();
466        assert_eq!(rule.status, RuleStatus::Proposed);
467        assert_eq!(rule.hits, 1);
468    }
469
470    #[test]
471    fn duplicate_proposal_confirms_existing() {
472        let mut store = RulesStore::default();
473        let id1 = store.propose_rule(
474            "test failure".into(),
475            "run tests before commit".into(),
476            None,
477            RuleCategory::PreCommit,
478            "s1".into(),
479            None,
480        );
481        let id2 = store.propose_rule(
482            "test failure".into(),
483            "run tests before commit".into(),
484            None,
485            RuleCategory::PreCommit,
486            "s2".into(),
487            None,
488        );
489        // Same rule ID returned (confirmation, not new rule)
490        assert_eq!(id1, id2);
491        let rule = store.get(&id1).unwrap();
492        assert_eq!(rule.hits, 2);
493        // 2 hits -> promoted to Active
494        assert_eq!(rule.status, RuleStatus::Active);
495    }
496
497    #[test]
498    fn contradiction_supersedes_old_rule() {
499        let mut store = RulesStore::default();
500        let id1 = store.propose_rule(
501            "test failure".into(),
502            "run tests before commit".into(),
503            None,
504            RuleCategory::PreCommit,
505            "s1".into(),
506            None,
507        );
508        // Same trigger, different action
509        let id2 = store.propose_rule(
510            "test failure".into(),
511            "run linter before commit".into(),
512            None,
513            RuleCategory::PreCommit,
514            "s2".into(),
515            None,
516        );
517        assert_ne!(id1, id2);
518        let old = store.get(&id1).unwrap();
519        assert_eq!(old.status, RuleStatus::Superseded);
520        assert_eq!(old.superseded_by.as_deref(), Some(id2.as_str()));
521    }
522
523    #[test]
524    fn record_hit_resets_ttl() {
525        let mut rule = make_rule("trigger", "action", RuleStatus::Active);
526        let before = rule.last_hit.clone();
527        std::thread::sleep(std::time::Duration::from_millis(10));
528        rule.record_hit();
529        assert_ne!(rule.last_hit, before);
530        assert_eq!(rule.hits, 3);
531    }
532
533    #[test]
534    fn dormant_reactivates_on_hit() {
535        let mut rule = make_rule("trigger", "action", RuleStatus::Dormant);
536        rule.record_hit();
537        assert_eq!(rule.status, RuleStatus::Active);
538    }
539
540    #[test]
541    fn active_window_cap_enforced() {
542        let mut store = RulesStore::default();
543        // Create 20 active rules
544        for i in 0..20 {
545            let mut rule = make_rule(
546                &format!("trigger_{i}"),
547                &format!("action_{i}"),
548                RuleStatus::Active,
549            );
550            rule.hits = 20 - i;
551            store.rules.push(rule);
552        }
553        store.run_decay_cycle();
554        let active_count = store.active_rules().len();
555        assert!(
556            active_count <= MAX_ACTIVE_RULES,
557            "active_count={active_count} exceeds cap={MAX_ACTIVE_RULES}"
558        );
559    }
560
561    #[test]
562    fn gc_removes_dead_rules() {
563        let mut store = RulesStore::default();
564        store.rules.push(make_rule("a", "b", RuleStatus::Active));
565        store.rules.push(make_rule("c", "d", RuleStatus::Dead));
566        store
567            .rules
568            .push(make_rule("e", "f", RuleStatus::Superseded));
569        let removed = store.gc_dead_rules();
570        assert_eq!(removed, 1);
571        assert_eq!(store.rules.len(), 2);
572    }
573
574    #[test]
575    fn store_round_trip() {
576        let tmp = tempfile::tempdir().unwrap();
577        let path = tmp.path().join("rules.json");
578
579        let mut store = RulesStore::default();
580        store.propose_rule(
581            "trigger".into(),
582            "action".into(),
583            None,
584            RuleCategory::Workflow,
585            "s1".into(),
586            None,
587        );
588        store.save(&path).unwrap();
589
590        let loaded = RulesStore::load(&path);
591        assert_eq!(loaded.rules.len(), 1);
592        assert_eq!(loaded.rules[0].trigger, "trigger");
593    }
594
595    #[test]
596    fn stats_counts_correctly() {
597        let mut store = RulesStore::default();
598        store.rules.push(make_rule("a", "b", RuleStatus::Active));
599        store.rules.push(make_rule("c", "d", RuleStatus::Proposed));
600        store.rules.push(make_rule("e", "f", RuleStatus::Dormant));
601        store.rules.push(make_rule("g", "h", RuleStatus::Dead));
602        let stats = store.stats();
603        assert_eq!(stats.total, 4);
604        assert_eq!(stats.active, 1);
605        assert_eq!(stats.proposed, 1);
606        assert_eq!(stats.dormant, 1);
607        assert_eq!(stats.dead, 1);
608    }
609}