Skip to main content

ito_core/audit/
validate.rs

1//! Semantic validation for audit event logs.
2//!
3//! Checks for structural and semantic issues beyond basic field presence:
4//! - Duplicate create events for the same entity
5//! - Orphaned events (referencing non-existent scopes)
6//! - Invalid status transitions (e.g., completing an already-completed task)
7
8use std::collections::{HashMap, HashSet};
9use std::path::Path;
10
11use ito_domain::audit::event::AuditEvent;
12
13use super::reader::{EventFilter, read_audit_events, read_audit_events_filtered};
14
15/// A semantic validation issue found in the audit log.
16#[derive(Debug, Clone)]
17pub struct AuditIssue {
18    /// Issue severity: "error" or "warning".
19    pub level: String,
20    /// Human-readable description.
21    pub message: String,
22    /// Index of the event that triggered the issue (0-based).
23    pub event_index: usize,
24}
25
26/// Result of semantic validation.
27#[derive(Debug)]
28pub struct AuditValidationReport {
29    /// Total number of events examined.
30    pub event_count: usize,
31    /// Issues found.
32    pub issues: Vec<AuditIssue>,
33    /// Whether all checks passed (no errors; warnings are acceptable).
34    pub valid: bool,
35}
36
37/// Run semantic validation on the audit log for a specific change or the whole project.
38pub fn validate_audit_log(ito_path: &Path, change_id: Option<&str>) -> AuditValidationReport {
39    let events = if let Some(change_id) = change_id {
40        let filter = EventFilter {
41            scope: Some(change_id.to_string()),
42            ..Default::default()
43        };
44        read_audit_events_filtered(ito_path, &filter)
45    } else {
46        read_audit_events(ito_path)
47    };
48
49    let issues = run_checks(&events);
50
51    let has_errors = issues.iter().any(|i| i.level == "error");
52
53    AuditValidationReport {
54        event_count: events.len(),
55        valid: !has_errors,
56        issues,
57    }
58}
59
60/// Run all semantic checks against a sequence of events.
61fn run_checks(events: &[AuditEvent]) -> Vec<AuditIssue> {
62    let mut issues = Vec::new();
63
64    issues.extend(check_duplicate_creates(events));
65    issues.extend(check_invalid_status_transitions(events));
66    issues.extend(check_timestamp_ordering(events));
67
68    // Sort by event index for stable output
69    issues.sort_by_key(|i| i.event_index);
70
71    issues
72}
73
74/// Check for duplicate `create` events for the same entity.
75fn check_duplicate_creates(events: &[AuditEvent]) -> Vec<AuditIssue> {
76    let mut issues = Vec::new();
77    let mut seen: HashSet<(String, String, Option<String>)> = HashSet::new();
78
79    for (i, event) in events.iter().enumerate() {
80        if event.op != "create" && event.op != "add" {
81            continue;
82        }
83        let key = (
84            event.entity.clone(),
85            event.entity_id.clone(),
86            event.scope.clone(),
87        );
88        if !seen.insert(key.clone()) {
89            issues.push(AuditIssue {
90                level: "warning".to_string(),
91                message: format!(
92                    "Duplicate {} event for {}/{} (scope: {:?})",
93                    event.op, event.entity, event.entity_id, event.scope
94                ),
95                event_index: i,
96            });
97        }
98    }
99
100    issues
101}
102
103/// Check for invalid status transitions (e.g., completing an already-complete task).
104fn check_invalid_status_transitions(events: &[AuditEvent]) -> Vec<AuditIssue> {
105    let mut issues = Vec::new();
106
107    // Track last known status for each entity
108    let mut last_status: HashMap<(String, String, Option<String>), String> = HashMap::new();
109
110    for (i, event) in events.iter().enumerate() {
111        if event.op != "status_change" {
112            // For create/add, set initial status
113            if let Some(to) = &event.to {
114                let key = (
115                    event.entity.clone(),
116                    event.entity_id.clone(),
117                    event.scope.clone(),
118                );
119                last_status.insert(key, to.clone());
120            }
121            continue;
122        }
123
124        let key = (
125            event.entity.clone(),
126            event.entity_id.clone(),
127            event.scope.clone(),
128        );
129
130        // Check if `from` matches the last known status
131        if let Some(from) = &event.from
132            && let Some(last) = last_status.get(&key)
133            && last != from
134        {
135            issues.push(AuditIssue {
136                level: "warning".to_string(),
137                message: format!(
138                    "Status transition mismatch for {}/{}: expected from='{}' but event says from='{}'",
139                    event.entity, event.entity_id, last, from
140                ),
141                event_index: i,
142            });
143        }
144
145        // Update the tracked status
146        if let Some(to) = &event.to {
147            last_status.insert(key, to.clone());
148        }
149    }
150
151    issues
152}
153
154/// Check that timestamps are in non-decreasing order.
155fn check_timestamp_ordering(events: &[AuditEvent]) -> Vec<AuditIssue> {
156    let mut issues = Vec::new();
157
158    for i in 1..events.len() {
159        if events[i].ts < events[i - 1].ts {
160            issues.push(AuditIssue {
161                level: "warning".to_string(),
162                message: format!(
163                    "Timestamp ordering violation: event {} ({}) is earlier than event {} ({})",
164                    i + 1,
165                    events[i].ts,
166                    i,
167                    events[i - 1].ts
168                ),
169                event_index: i,
170            });
171        }
172    }
173
174    issues
175}
176
177#[cfg(test)]
178#[path = "validate_tests.rs"]
179mod validate_tests;