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            count: 1,
214            ctx: test_ctx(),
215        }
216    }
217
218    #[test]
219    fn no_issues_for_valid_sequence() {
220        let events = vec![
221            make_event(
222                "task",
223                "1.1",
224                Some("ch"),
225                "create",
226                None,
227                Some("pending"),
228                "2026-01-01T00:00:00Z",
229            ),
230            make_event(
231                "task",
232                "1.1",
233                Some("ch"),
234                "status_change",
235                Some("pending"),
236                Some("in-progress"),
237                "2026-01-01T00:01:00Z",
238            ),
239            make_event(
240                "task",
241                "1.1",
242                Some("ch"),
243                "status_change",
244                Some("in-progress"),
245                Some("complete"),
246                "2026-01-01T00:02:00Z",
247            ),
248        ];
249
250        let issues = run_checks(&events);
251        assert!(issues.is_empty());
252    }
253
254    #[test]
255    fn detect_duplicate_create() {
256        let events = vec![
257            make_event(
258                "task",
259                "1.1",
260                Some("ch"),
261                "create",
262                None,
263                Some("pending"),
264                "2026-01-01T00:00:00Z",
265            ),
266            make_event(
267                "task",
268                "1.1",
269                Some("ch"),
270                "create",
271                None,
272                Some("pending"),
273                "2026-01-01T00:01:00Z",
274            ),
275        ];
276
277        let issues = run_checks(&events);
278        assert_eq!(issues.len(), 1);
279        assert!(issues[0].message.contains("Duplicate"));
280    }
281
282    #[test]
283    fn detect_status_transition_mismatch() {
284        let events = vec![
285            make_event(
286                "task",
287                "1.1",
288                Some("ch"),
289                "create",
290                None,
291                Some("pending"),
292                "2026-01-01T00:00:00Z",
293            ),
294            // from="in-progress" but last known is "pending"
295            make_event(
296                "task",
297                "1.1",
298                Some("ch"),
299                "status_change",
300                Some("in-progress"),
301                Some("complete"),
302                "2026-01-01T00:01:00Z",
303            ),
304        ];
305
306        let issues = run_checks(&events);
307        assert_eq!(issues.len(), 1);
308        assert!(issues[0].message.contains("transition mismatch"));
309    }
310
311    #[test]
312    fn detect_timestamp_ordering_violation() {
313        let events = vec![
314            make_event(
315                "task",
316                "1.1",
317                Some("ch"),
318                "create",
319                None,
320                Some("pending"),
321                "2026-01-01T00:02:00Z",
322            ),
323            make_event(
324                "task",
325                "1.2",
326                Some("ch"),
327                "create",
328                None,
329                Some("pending"),
330                "2026-01-01T00:01:00Z",
331            ),
332        ];
333
334        let issues = run_checks(&events);
335        assert_eq!(issues.len(), 1);
336        assert!(issues[0].message.contains("Timestamp ordering"));
337    }
338
339    #[test]
340    fn empty_events_no_issues() {
341        let issues = run_checks(&[]);
342        assert!(issues.is_empty());
343    }
344
345    #[test]
346    fn different_scopes_are_independent() {
347        let events = vec![
348            make_event(
349                "task",
350                "1.1",
351                Some("ch-1"),
352                "create",
353                None,
354                Some("pending"),
355                "2026-01-01T00:00:00Z",
356            ),
357            make_event(
358                "task",
359                "1.1",
360                Some("ch-2"),
361                "create",
362                None,
363                Some("pending"),
364                "2026-01-01T00:01:00Z",
365            ),
366        ];
367
368        let issues = run_checks(&events);
369        assert!(issues.is_empty()); // Different scopes, not duplicates
370    }
371}