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)]
178mod tests {
179    use super::*;
180    use ito_domain::audit::event::{EventContext, SCHEMA_VERSION};
181
182    fn test_ctx() -> EventContext {
183        EventContext {
184            session_id: "test".to_string(),
185            harness_session_id: None,
186            branch: None,
187            worktree: None,
188            commit: None,
189        }
190    }
191
192    fn make_event(
193        entity: &str,
194        entity_id: &str,
195        scope: Option<&str>,
196        op: &str,
197        from: Option<&str>,
198        to: Option<&str>,
199        ts: &str,
200    ) -> AuditEvent {
201        AuditEvent {
202            v: SCHEMA_VERSION,
203            ts: ts.to_string(),
204            entity: entity.to_string(),
205            entity_id: entity_id.to_string(),
206            scope: scope.map(String::from),
207            op: op.to_string(),
208            from: from.map(String::from),
209            to: to.map(String::from),
210            actor: "cli".to_string(),
211            by: "@test".to_string(),
212            meta: None,
213            ctx: test_ctx(),
214        }
215    }
216
217    #[test]
218    fn no_issues_for_valid_sequence() {
219        let events = vec![
220            make_event(
221                "task",
222                "1.1",
223                Some("ch"),
224                "create",
225                None,
226                Some("pending"),
227                "2026-01-01T00:00:00Z",
228            ),
229            make_event(
230                "task",
231                "1.1",
232                Some("ch"),
233                "status_change",
234                Some("pending"),
235                Some("in-progress"),
236                "2026-01-01T00:01:00Z",
237            ),
238            make_event(
239                "task",
240                "1.1",
241                Some("ch"),
242                "status_change",
243                Some("in-progress"),
244                Some("complete"),
245                "2026-01-01T00:02:00Z",
246            ),
247        ];
248
249        let issues = run_checks(&events);
250        assert!(issues.is_empty());
251    }
252
253    #[test]
254    fn detect_duplicate_create() {
255        let events = vec![
256            make_event(
257                "task",
258                "1.1",
259                Some("ch"),
260                "create",
261                None,
262                Some("pending"),
263                "2026-01-01T00:00:00Z",
264            ),
265            make_event(
266                "task",
267                "1.1",
268                Some("ch"),
269                "create",
270                None,
271                Some("pending"),
272                "2026-01-01T00:01:00Z",
273            ),
274        ];
275
276        let issues = run_checks(&events);
277        assert_eq!(issues.len(), 1);
278        assert!(issues[0].message.contains("Duplicate"));
279    }
280
281    #[test]
282    fn detect_status_transition_mismatch() {
283        let events = vec![
284            make_event(
285                "task",
286                "1.1",
287                Some("ch"),
288                "create",
289                None,
290                Some("pending"),
291                "2026-01-01T00:00:00Z",
292            ),
293            // from="in-progress" but last known is "pending"
294            make_event(
295                "task",
296                "1.1",
297                Some("ch"),
298                "status_change",
299                Some("in-progress"),
300                Some("complete"),
301                "2026-01-01T00:01:00Z",
302            ),
303        ];
304
305        let issues = run_checks(&events);
306        assert_eq!(issues.len(), 1);
307        assert!(issues[0].message.contains("transition mismatch"));
308    }
309
310    #[test]
311    fn detect_timestamp_ordering_violation() {
312        let events = vec![
313            make_event(
314                "task",
315                "1.1",
316                Some("ch"),
317                "create",
318                None,
319                Some("pending"),
320                "2026-01-01T00:02:00Z",
321            ),
322            make_event(
323                "task",
324                "1.2",
325                Some("ch"),
326                "create",
327                None,
328                Some("pending"),
329                "2026-01-01T00:01:00Z",
330            ),
331        ];
332
333        let issues = run_checks(&events);
334        assert_eq!(issues.len(), 1);
335        assert!(issues[0].message.contains("Timestamp ordering"));
336    }
337
338    #[test]
339    fn empty_events_no_issues() {
340        let issues = run_checks(&[]);
341        assert!(issues.is_empty());
342    }
343
344    #[test]
345    fn different_scopes_are_independent() {
346        let events = vec![
347            make_event(
348                "task",
349                "1.1",
350                Some("ch-1"),
351                "create",
352                None,
353                Some("pending"),
354                "2026-01-01T00:00:00Z",
355            ),
356            make_event(
357                "task",
358                "1.1",
359                Some("ch-2"),
360                "create",
361                None,
362                Some("pending"),
363                "2026-01-01T00:01:00Z",
364            ),
365        ];
366
367        let issues = run_checks(&events);
368        assert!(issues.is_empty()); // Different scopes, not duplicates
369    }
370}