Skip to main content

oxicode_sdk/observability/
audit_trail.rs

1//! Tamper-evident audit trail with cryptographic hash chain (blake3).
2//!
3//! Each entry is cryptographically linked to the previous entry,
4//! making tampering detectable. Provides rich querying, JSON export,
5//! and persistence via the `AuditPersistence` trait.
6//!
7//! Migrated from oxios-kernel — this is the canonical implementation.
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::sync::atomic::{AtomicU64, Ordering};
12
13/// Type alias for hash digest (blake3 hex output, 64 chars).
14pub type HashDigest = String;
15
16/// Unique identifier for an agent (String for flexibility).
17pub type AgentId = String;
18
19// ─── Error Types ─────────────────────────────────────────────────────────────
20
21/// Errors that can occur during audit trail operations.
22#[derive(Debug, Clone)]
23pub enum AuditError {
24    /// Chain link broken at given sequence number.
25    ChainBroken {
26        /// Sequence number of the broken link.
27        seq: u64,
28        /// Hash expected at this link.
29        expected: String,
30        /// Hash actually found at this link.
31        found: String,
32    },
33    /// Invalid timestamp detected.
34    InvalidTimestamp {
35        /// Sequence number of the entry with the bad timestamp.
36        seq: u64,
37    },
38    /// Failed to export audit log.
39    ExportFailed(String),
40}
41
42impl std::fmt::Display for AuditError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            AuditError::ChainBroken {
46                seq,
47                expected,
48                found,
49            } => {
50                write!(
51                    f,
52                    "chain broken at seq {seq}: expected hash '{expected}', found '{found}'"
53                )
54            }
55            AuditError::InvalidTimestamp { seq } => {
56                write!(f, "invalid timestamp at seq {seq}")
57            }
58            AuditError::ExportFailed(msg) => {
59                write!(f, "export failed: {msg}")
60            }
61        }
62    }
63}
64
65impl std::error::Error for AuditError {}
66
67// ─── Audit Action ────────────────────────────────────────────────────────────
68
69/// Types of actions that can be audited.
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
71#[serde(tag = "type", content = "data")]
72pub enum AuditAction {
73    /// Agent spawned with task type.
74    AgentSpawn {
75        /// Type/category of the task the agent was spawned for.
76        task_type: String,
77    },
78    /// Agent exited with reason.
79    AgentExit {
80        /// Reason the agent exited.
81        reason: String,
82    },
83    /// Tool was called.
84    ToolCall {
85        /// Name of the tool that was invoked.
86        tool: String,
87        /// JSON-encoded arguments passed to the tool.
88        args_json: String,
89    },
90    /// Tool returned a result.
91    ToolResult {
92        /// Name of the tool that produced the result.
93        tool: String,
94        /// Whether the tool call succeeded.
95        success: bool,
96    },
97    /// Memory entry written.
98    MemoryWrite {
99        /// Identifier of the memory entry that was written.
100        entry_id: String,
101    },
102    /// Memory entry read.
103    MemoryRead {
104        /// Identifier of the memory entry that was read.
105        entry_id: String,
106    },
107    /// Configuration changed.
108    ConfigChange {
109        /// Configuration key that changed.
110        key: String,
111    },
112    /// Program installed.
113    ProgramInstall {
114        /// Name of the program that was installed.
115        program: String,
116        /// Version of the program that was installed.
117        version: String,
118    },
119    /// Cron job triggered.
120    CronTrigger {
121        /// Identifier of the cron job that fired.
122        job_id: String,
123    },
124    /// Git commit created.
125    GitCommit {
126        /// Commit message of the created commit.
127        message: String,
128    },
129    /// Access was denied.
130    AccessDenied {
131        /// Permission that was denied.
132        permission: String,
133    },
134    /// Other/unclassified action.
135    Other {
136        /// Free-form description of the unclassified action.
137        detail: String,
138    },
139}
140
141// ─── Audit Entry ─────────────────────────────────────────────────────────────
142
143/// A single entry in the audit trail.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct TrailEntry {
146    /// Sequential entry number.
147    pub seq: u64,
148    /// Timestamp of the entry.
149    pub timestamp: DateTime<Utc>,
150    /// Agent ID that performed the action.
151    pub actor: AgentId,
152    /// The action that was performed.
153    pub action: AuditAction,
154    /// Resource affected by the action.
155    pub resource: String,
156    /// Hash of the previous entry ("genesis" for first, "pruned" after auto-pruning).
157    pub prev_hash: HashDigest,
158    /// Hash of this entry.
159    pub hash: HashDigest,
160    /// Optional arbitrary metadata.
161    pub metadata: Option<serde_json::Value>,
162}
163
164// ─── Persistence Trait ───────────────────────────────────────────────────────
165
166/// Trait for persisting audit trail entries.
167///
168/// Implement this to integrate with your storage backend
169/// (filesystem, database, object store, etc.).
170pub trait AuditPersistence: Send + Sync {
171    /// Save entries to persistent storage.
172    fn save(&self, entries: &[TrailEntry]) -> anyhow::Result<()>;
173    /// Load entries from persistent storage.
174    fn load(&self) -> anyhow::Result<Vec<TrailEntry>>;
175}
176
177// ─── Hash Computation ────────────────────────────────────────────────────────
178
179/// Compute the hash for an audit entry using blake3.
180fn compute_entry_hash(
181    seq: u64,
182    ts: &DateTime<Utc>,
183    actor: &str,
184    action: &AuditAction,
185    resource: &str,
186    prev: &str,
187) -> HashDigest {
188    let mut h = blake3::Hasher::new();
189    h.update(b"oxios-audit-v1");
190    h.update(&seq.to_be_bytes());
191    h.update(ts.to_rfc3339().as_bytes());
192    h.update(actor.as_bytes());
193    let action_bytes = serde_json::to_vec(action).unwrap_or_default();
194    h.update(&action_bytes);
195    h.update(prev.as_bytes());
196    h.update(resource.as_bytes());
197    h.finalize().to_hex().to_string()
198}
199
200// ─── Audit Trail ─────────────────────────────────────────────────────────────
201
202/// A tamper-evident audit trail with cryptographic hash chain.
203///
204/// Each entry is cryptographically linked to the previous entry using
205/// blake3 hashing. This makes it possible to detect any tampering with
206/// historical entries.
207pub struct AuditTrail {
208    entries: parking_lot::RwLock<Vec<TrailEntry>>,
209    seq_counter: AtomicU64,
210    #[allow(dead_code)]
211    chain_hasher: parking_lot::Mutex<blake3::Hasher>,
212    max_entries: usize,
213}
214
215impl AuditTrail {
216    /// Create a new audit trail with the given maximum entry count.
217    pub fn new(max_entries: usize) -> Self {
218        Self {
219            entries: parking_lot::RwLock::new(Vec::new()),
220            seq_counter: AtomicU64::new(1),
221            chain_hasher: parking_lot::Mutex::new(blake3::Hasher::new()),
222            max_entries,
223        }
224    }
225
226    /// Get the current number of entries.
227    pub fn len(&self) -> usize {
228        self.entries.read().len()
229    }
230
231    /// Check if the trail is empty.
232    pub fn is_empty(&self) -> bool {
233        self.len() == 0
234    }
235
236    /// Get the last hash in the chain.
237    fn last_hash(&self) -> HashDigest {
238        let entries = self.entries.read();
239        entries
240            .last()
241            .map(|e| e.hash.clone())
242            .unwrap_or_else(|| "genesis".to_string())
243    }
244
245    /// Append an audit entry. Computes hash chain automatically.
246    pub fn append(&self, actor: AgentId, action: AuditAction, resource: String) -> HashDigest {
247        self.append_with_meta(actor, action, resource, None)
248    }
249
250    /// Append an audit entry with optional metadata.
251    pub fn append_with_meta(
252        &self,
253        actor: AgentId,
254        action: AuditAction,
255        resource: String,
256        metadata: Option<serde_json::Value>,
257    ) -> HashDigest {
258        let seq = self.seq_counter.fetch_add(1, Ordering::SeqCst);
259        let timestamp = Utc::now();
260        let prev_hash = self.last_hash();
261        let hash = compute_entry_hash(seq, &timestamp, &actor, &action, &resource, &prev_hash);
262
263        let entry = TrailEntry {
264            seq,
265            timestamp,
266            actor,
267            action,
268            resource,
269            prev_hash,
270            hash,
271            metadata,
272        };
273
274        let entry_hash = entry.hash.clone();
275
276        {
277            let mut entries = self.entries.write();
278            entries.push(entry);
279            if entries.len() > self.max_entries {
280                let excess = entries.len() - self.max_entries;
281                entries.drain(0..excess);
282                if let Some(first) = entries.first_mut() {
283                    first.prev_hash = "pruned".to_string();
284                }
285            }
286        }
287
288        entry_hash
289    }
290
291    /// Verify the integrity of the hash chain.
292    pub fn verify(&self) -> Result<bool, AuditError> {
293        let entries = self.entries.read();
294        let mut prev_hash = "genesis".to_string();
295
296        for (i, entry) in entries.iter().enumerate() {
297            if entry.seq == 0 {
298                return Err(AuditError::ChainBroken {
299                    seq: 0,
300                    expected: "non-zero sequence".to_string(),
301                    found: "0".to_string(),
302                });
303            }
304
305            if i == 0 && entry.prev_hash == "pruned" {
306                prev_hash = entry.hash.clone();
307                continue;
308            } else if entry.prev_hash != prev_hash {
309                return Err(AuditError::ChainBroken {
310                    seq: entry.seq,
311                    expected: prev_hash,
312                    found: entry.prev_hash.clone(),
313                });
314            }
315
316            let now = Utc::now();
317            if entry.timestamp > now {
318                return Err(AuditError::InvalidTimestamp { seq: entry.seq });
319            }
320
321            let computed = compute_entry_hash(
322                entry.seq,
323                &entry.timestamp,
324                &entry.actor,
325                &entry.action,
326                &entry.resource,
327                &entry.prev_hash,
328            );
329
330            if computed != entry.hash {
331                return Err(AuditError::ChainBroken {
332                    seq: entry.seq,
333                    expected: computed,
334                    found: entry.hash.clone(),
335                });
336            }
337
338            prev_hash = entry.hash.clone();
339        }
340
341        Ok(true)
342    }
343
344    /// Get entries within a sequence range (inclusive).
345    pub fn entries(&self, from_seq: u64, to_seq: u64) -> Vec<TrailEntry> {
346        let entries = self.entries.read();
347        entries
348            .iter()
349            .filter(|e| e.seq >= from_seq && e.seq <= to_seq)
350            .cloned()
351            .collect()
352    }
353
354    /// Get all entries.
355    pub fn all_entries(&self) -> Vec<TrailEntry> {
356        self.entries.read().clone()
357    }
358
359    /// Query entries by agent ID.
360    pub fn by_agent(&self, agent_id: &str) -> Vec<TrailEntry> {
361        let entries = self.entries.read();
362        entries
363            .iter()
364            .filter(|e| e.actor == agent_id)
365            .cloned()
366            .collect()
367    }
368
369    /// Query entries by exact action match.
370    pub fn by_action(&self, action: &AuditAction) -> Vec<TrailEntry> {
371        let entries = self.entries.read();
372        entries
373            .iter()
374            .filter(|e| &e.action == action)
375            .cloned()
376            .collect()
377    }
378
379    /// Query entries by action discriminant name (e.g., "ToolCall", "AgentSpawn").
380    pub fn by_action_type(&self, type_name: &str) -> Vec<TrailEntry> {
381        let entries = self.entries.read();
382        entries
383            .iter()
384            .filter(|e| {
385                let action_name = match &e.action {
386                    AuditAction::AgentSpawn { .. } => "AgentSpawn",
387                    AuditAction::AgentExit { .. } => "AgentExit",
388                    AuditAction::ToolCall { .. } => "ToolCall",
389                    AuditAction::ToolResult { .. } => "ToolResult",
390                    AuditAction::MemoryWrite { .. } => "MemoryWrite",
391                    AuditAction::MemoryRead { .. } => "MemoryRead",
392                    AuditAction::ConfigChange { .. } => "ConfigChange",
393                    AuditAction::ProgramInstall { .. } => "ProgramInstall",
394                    AuditAction::CronTrigger { .. } => "CronTrigger",
395                    AuditAction::GitCommit { .. } => "GitCommit",
396                    AuditAction::AccessDenied { .. } => "AccessDenied",
397                    AuditAction::Other { .. } => "Other",
398                };
399                action_name == type_name
400            })
401            .cloned()
402            .collect()
403    }
404
405    /// Export entries from a sequence number as pretty JSON.
406    pub fn export_json(&self, from_seq: u64) -> Result<String, AuditError> {
407        let entries = self.entries.read();
408        let filtered: Vec<&TrailEntry> = entries.iter().filter(|e| e.seq >= from_seq).collect();
409        serde_json::to_string_pretty(&filtered).map_err(|e| AuditError::ExportFailed(e.to_string()))
410    }
411
412    /// Export all entries as pretty JSON.
413    pub fn export_all_json(&self) -> Result<String, AuditError> {
414        let entries = self.entries.read();
415        serde_json::to_string_pretty(&*entries).map_err(|e| AuditError::ExportFailed(e.to_string()))
416    }
417
418    /// Flush entries to a persistence backend.
419    pub fn flush_to(&self, store: &dyn AuditPersistence) -> anyhow::Result<()> {
420        let entries = self.all_entries();
421        store.save(&entries)
422    }
423
424    /// Restore entries from a persistence backend.
425    pub fn restore_from_store(&self, store: &dyn AuditPersistence) -> anyhow::Result<()> {
426        let entries = store.load()?;
427        self.restore_from(entries);
428        Ok(())
429    }
430
431    /// Restore previously persisted entries directly.
432    ///
433    /// Sets `seq_counter` to `max(entries.seq) + 1` so new entries
434    /// don't collide with restored ones. Trims to `max_entries` if needed.
435    pub fn restore_from(&self, entries: Vec<TrailEntry>) {
436        if entries.is_empty() {
437            return;
438        }
439
440        let max_seq = entries.iter().map(|e| e.seq).max().unwrap_or(0);
441        self.seq_counter.store(max_seq + 1, Ordering::SeqCst);
442
443        let mut current = self.entries.write();
444        *current = entries;
445
446        if current.len() > self.max_entries {
447            let excess = current.len() - self.max_entries;
448            current.drain(0..excess);
449            if let Some(first) = current.first_mut() {
450                first.prev_hash = "pruned".to_string();
451            }
452        }
453
454        tracing::info!(
455            restored = current.len(),
456            next_seq = max_seq + 1,
457            "Audit trail restored from persistence"
458        );
459    }
460}
461
462impl Default for AuditTrail {
463    fn default() -> Self {
464        Self::new(100_000)
465    }
466}
467
468impl std::fmt::Debug for AuditTrail {
469    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
470        f.debug_struct("AuditTrail")
471            .field("entries", &self.len())
472            .field("seq_counter", &self.seq_counter)
473            .field("max_entries", &self.max_entries)
474            .finish()
475    }
476}
477
478// ─── Tests ───────────────────────────────────────────────────────────────────
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    fn create_test_trail() -> AuditTrail {
485        AuditTrail::new(1000)
486    }
487
488    #[test]
489    fn test_append_generates_hash() {
490        let trail = create_test_trail();
491        let hash = trail.append(
492            "agent-001".into(),
493            AuditAction::AgentSpawn {
494                task_type: "test".into(),
495            },
496            "/test/resource".into(),
497        );
498        assert!(!hash.is_empty());
499        assert_eq!(hash.len(), 64);
500    }
501
502    #[test]
503    fn test_append_increments_seq() {
504        let trail = create_test_trail();
505        let h1 = trail.append(
506            "agent-001".into(),
507            AuditAction::AgentSpawn {
508                task_type: "test".into(),
509            },
510            "/test/resource".into(),
511        );
512        let h2 = trail.append(
513            "agent-002".into(),
514            AuditAction::ToolCall {
515                tool: "bash".into(),
516                args_json: "{}".into(),
517            },
518            "/test/resource2".into(),
519        );
520        assert_ne!(h1, h2);
521        let entries = trail.all_entries();
522        assert_eq!(entries.len(), 2);
523        assert_eq!(entries[0].seq, 1);
524        assert_eq!(entries[1].seq, 2);
525    }
526
527    #[test]
528    fn test_hash_chain_linked() {
529        let trail = create_test_trail();
530        trail.append(
531            "agent-001".into(),
532            AuditAction::AgentSpawn {
533                task_type: "test".into(),
534            },
535            "/test/resource".into(),
536        );
537        trail.append(
538            "agent-001".into(),
539            AuditAction::AgentExit {
540                reason: "done".into(),
541            },
542            "/test/resource".into(),
543        );
544        let entries = trail.all_entries();
545        assert_eq!(entries[0].prev_hash, "genesis");
546        assert_eq!(entries[1].prev_hash, entries[0].hash);
547    }
548
549    #[test]
550    fn test_verify_passes_clean_chain() {
551        let trail = create_test_trail();
552        trail.append(
553            "agent-001".into(),
554            AuditAction::AgentSpawn {
555                task_type: "test".into(),
556            },
557            "/test/resource".into(),
558        );
559        trail.append(
560            "agent-001".into(),
561            AuditAction::ToolCall {
562                tool: "bash".into(),
563                args_json: "{}".into(),
564            },
565            "/test/resource".into(),
566        );
567        trail.append(
568            "agent-001".into(),
569            AuditAction::ToolResult {
570                tool: "bash".into(),
571                success: true,
572            },
573            "/test/resource".into(),
574        );
575        assert!(trail.verify().is_ok());
576    }
577
578    #[test]
579    fn test_verify_detects_tampering() {
580        let trail = create_test_trail();
581        trail.append(
582            "agent-001".into(),
583            AuditAction::AgentSpawn {
584                task_type: "test".into(),
585            },
586            "/test/resource".into(),
587        );
588        trail.append(
589            "agent-001".into(),
590            AuditAction::ToolCall {
591                tool: "bash".into(),
592                args_json: "{}".into(),
593            },
594            "/test/resource".into(),
595        );
596        {
597            let mut entries = trail.entries.write();
598            entries[0].actor = "hacker-001".into();
599        }
600        let result = trail.verify();
601        assert!(result.is_err());
602        match result {
603            Err(AuditError::ChainBroken { seq, .. }) => {
604                assert_eq!(seq, 1);
605            }
606            _ => panic!("expected ChainBroken error"),
607        }
608    }
609
610    #[test]
611    fn test_verify_detects_prev_hash_tampering() {
612        let trail = create_test_trail();
613        trail.append(
614            "agent-001".into(),
615            AuditAction::AgentSpawn {
616                task_type: "test".into(),
617            },
618            "/test/resource".into(),
619        );
620        trail.append(
621            "agent-001".into(),
622            AuditAction::ToolCall {
623                tool: "bash".into(),
624                args_json: "{}".into(),
625            },
626            "/test/resource".into(),
627        );
628        {
629            let mut entries = trail.entries.write();
630            entries[1].prev_hash = "fake-hash".into();
631        }
632        assert!(trail.verify().is_err());
633    }
634
635    #[test]
636    fn test_export_json_format() {
637        let trail = create_test_trail();
638        trail.append(
639            "agent-001".into(),
640            AuditAction::AgentSpawn {
641                task_type: "test".into(),
642            },
643            "/test/resource".into(),
644        );
645        let json = trail.export_json(0).unwrap();
646        let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
647        assert_eq!(parsed.len(), 1);
648        assert!(parsed[0].get("seq").is_some());
649        assert!(parsed[0].get("hash").is_some());
650    }
651
652    #[test]
653    fn test_by_agent_query() {
654        let trail = create_test_trail();
655        trail.append(
656            "agent-001".into(),
657            AuditAction::AgentSpawn {
658                task_type: "test".into(),
659            },
660            "/test/resource".into(),
661        );
662        trail.append(
663            "agent-002".into(),
664            AuditAction::AgentSpawn {
665                task_type: "test".into(),
666            },
667            "/test/resource".into(),
668        );
669        trail.append(
670            "agent-001".into(),
671            AuditAction::AgentExit {
672                reason: "done".into(),
673            },
674            "/test/resource".into(),
675        );
676        assert_eq!(trail.by_agent("agent-001").len(), 2);
677        assert_eq!(trail.by_agent("agent-002").len(), 1);
678    }
679
680    #[test]
681    fn test_by_action_query() {
682        let trail = create_test_trail();
683        trail.append(
684            "agent-001".into(),
685            AuditAction::AgentSpawn {
686                task_type: "test".into(),
687            },
688            "/test/resource".into(),
689        );
690        trail.append(
691            "agent-001".into(),
692            AuditAction::ToolCall {
693                tool: "bash".into(),
694                args_json: "{}".into(),
695            },
696            "/test/resource".into(),
697        );
698        trail.append(
699            "agent-001".into(),
700            AuditAction::ToolCall {
701                tool: "grep".into(),
702                args_json: "{}".into(),
703            },
704            "/test/resource".into(),
705        );
706        assert_eq!(
707            trail
708                .by_action(&AuditAction::AgentSpawn {
709                    task_type: "test".into()
710                })
711                .len(),
712            1
713        );
714        assert_eq!(trail.by_action_type("ToolCall").len(), 2);
715    }
716
717    #[test]
718    fn test_entries_range() {
719        let trail = create_test_trail();
720        for i in 0..10 {
721            trail.append(
722                "agent-001".into(),
723                AuditAction::Other {
724                    detail: format!("action-{i}"),
725                },
726                "/test/resource".into(),
727            );
728        }
729        let range = trail.entries(3, 7);
730        assert_eq!(range.len(), 5);
731        assert_eq!(range[0].seq, 3);
732        assert_eq!(range[4].seq, 7);
733    }
734
735    #[test]
736    fn test_auto_prune() {
737        let trail = AuditTrail::new(5);
738        for i in 0..10 {
739            trail.append(
740                "agent-001".into(),
741                AuditAction::Other {
742                    detail: format!("action-{i}"),
743                },
744                "/test/resource".into(),
745            );
746        }
747        assert_eq!(trail.len(), 5);
748        let entries = trail.all_entries();
749        assert_eq!(entries[0].seq, 6);
750        assert_eq!(entries[4].seq, 10);
751        assert!(trail.verify().is_ok(), "Pruned trail should still verify");
752    }
753
754    #[test]
755    fn test_append_with_metadata() {
756        let trail = create_test_trail();
757        let metadata = serde_json::json!({"duration_ms": 150, "memory_mb": 32});
758        trail.append_with_meta(
759            "agent-001".into(),
760            AuditAction::MemoryWrite {
761                entry_id: "mem-001".into(),
762            },
763            "/memory/entries".into(),
764            Some(metadata.clone()),
765        );
766        let entries = trail.all_entries();
767        assert_eq!(entries[0].metadata.as_ref().unwrap(), &metadata);
768    }
769
770    #[test]
771    fn test_genesis_hash() {
772        let trail = create_test_trail();
773        trail.append(
774            "agent-001".into(),
775            AuditAction::AgentSpawn {
776                task_type: "test".into(),
777            },
778            "/test/resource".into(),
779        );
780        assert_eq!(trail.all_entries()[0].prev_hash, "genesis");
781    }
782
783    #[test]
784    fn test_deterministic_hash() {
785        let trail = create_test_trail();
786        let action = AuditAction::AgentSpawn {
787            task_type: "test".into(),
788        };
789        trail.append("agent-001".into(), action.clone(), "/test/resource".into());
790        let hash = compute_entry_hash(
791            1,
792            &trail.all_entries()[0].timestamp,
793            "agent-001",
794            &action,
795            "/test/resource",
796            "genesis",
797        );
798        assert_eq!(hash, trail.all_entries()[0].hash);
799    }
800
801    #[test]
802    fn test_empty_trail_verify() {
803        assert!(create_test_trail().verify().is_ok());
804    }
805
806    #[test]
807    fn test_all_action_types() {
808        let trail = create_test_trail();
809        let actions: Vec<AuditAction> = vec![
810            AuditAction::AgentSpawn {
811                task_type: "test".into(),
812            },
813            AuditAction::AgentExit {
814                reason: "done".into(),
815            },
816            AuditAction::ToolCall {
817                tool: "bash".into(),
818                args_json: "{}".into(),
819            },
820            AuditAction::ToolResult {
821                tool: "bash".into(),
822                success: true,
823            },
824            AuditAction::MemoryWrite {
825                entry_id: "mem-001".into(),
826            },
827            AuditAction::MemoryRead {
828                entry_id: "mem-001".into(),
829            },
830            AuditAction::ConfigChange {
831                key: "max_agents".into(),
832            },
833            AuditAction::ProgramInstall {
834                program: "test-program".into(),
835                version: "1.0.0".into(),
836            },
837            AuditAction::CronTrigger {
838                job_id: "job-001".into(),
839            },
840            AuditAction::GitCommit {
841                message: "test commit".into(),
842            },
843            AuditAction::AccessDenied {
844                permission: "write".into(),
845            },
846            AuditAction::Other {
847                detail: "misc".into(),
848            },
849        ];
850        for (i, action) in actions.into_iter().enumerate() {
851            trail.append("agent-001".into(), action, format!("/resource/{i}"));
852        }
853        assert_eq!(trail.len(), 12);
854        assert!(trail.verify().is_ok());
855    }
856
857    #[test]
858    fn test_hash_different_for_different_inputs() {
859        let ts = Utc::now();
860        let h1 = compute_entry_hash(
861            1,
862            &ts,
863            "agent-001",
864            &AuditAction::AgentSpawn {
865                task_type: "test".into(),
866            },
867            "/resource",
868            "genesis",
869        );
870        let h2 = compute_entry_hash(
871            2,
872            &ts,
873            "agent-001",
874            &AuditAction::AgentSpawn {
875                task_type: "test".into(),
876            },
877            "/resource",
878            "genesis",
879        );
880        assert_ne!(h1, h2);
881        let h3 = compute_entry_hash(
882            1,
883            &ts,
884            "agent-002",
885            &AuditAction::AgentSpawn {
886                task_type: "test".into(),
887            },
888            "/resource",
889            "genesis",
890        );
891        assert_ne!(h1, h3);
892    }
893
894    #[test]
895    fn test_restore_from_empty() {
896        let trail = create_test_trail();
897        trail.restore_from(Vec::new());
898        assert!(trail.is_empty());
899    }
900
901    #[test]
902    fn test_restore_from_advances_seq_counter() {
903        let trail = create_test_trail();
904        let ts = Utc::now();
905        let mut entries = Vec::new();
906        let mut prev = "genesis".to_string();
907        for i in 1..=5u64 {
908            let hash = compute_entry_hash(
909                i,
910                &ts,
911                "agent-001",
912                &AuditAction::Other {
913                    detail: format!("action-{i}"),
914                },
915                "/resource",
916                &prev,
917            );
918            entries.push(TrailEntry {
919                seq: i,
920                timestamp: ts,
921                actor: "agent-001".into(),
922                action: AuditAction::Other {
923                    detail: format!("action-{i}"),
924                },
925                resource: "/resource".into(),
926                prev_hash: prev.clone(),
927                hash: hash.clone(),
928                metadata: None,
929            });
930            prev = hash;
931        }
932        trail.restore_from(entries);
933        assert_eq!(trail.len(), 5);
934        let new_hash = trail.append(
935            "agent-001".into(),
936            AuditAction::Other {
937                detail: "new".into(),
938            },
939            "/resource".into(),
940        );
941        assert!(!new_hash.is_empty());
942        assert_eq!(trail.len(), 6);
943        assert_eq!(trail.all_entries()[5].seq, 6);
944    }
945
946    #[test]
947    fn test_restore_from_trims_to_max() {
948        let trail = AuditTrail::new(3);
949        let ts = Utc::now();
950        let mut entries = Vec::new();
951        let mut prev = "genesis".to_string();
952        for i in 1..=5u64 {
953            let hash = compute_entry_hash(
954                i,
955                &ts,
956                "agent-001",
957                &AuditAction::Other {
958                    detail: format!("action-{i}"),
959                },
960                "/resource",
961                &prev,
962            );
963            entries.push(TrailEntry {
964                seq: i,
965                timestamp: ts,
966                actor: "agent-001".into(),
967                action: AuditAction::Other {
968                    detail: format!("action-{i}"),
969                },
970                resource: "/resource".into(),
971                prev_hash: prev.clone(),
972                hash: hash.clone(),
973                metadata: None,
974            });
975            prev = hash;
976        }
977        trail.restore_from(entries);
978        assert_eq!(trail.len(), 3);
979        let all = trail.all_entries();
980        assert_eq!(all[0].seq, 3);
981        assert_eq!(all[2].seq, 5);
982        assert!(trail.verify().is_ok());
983    }
984
985    #[test]
986    fn test_persistence_roundtrip() {
987        use std::sync::Mutex;
988
989        struct MemStore {
990            data: Mutex<Vec<TrailEntry>>,
991        }
992        impl AuditPersistence for MemStore {
993            fn save(&self, entries: &[TrailEntry]) -> anyhow::Result<()> {
994                *self.data.lock().unwrap() = entries.to_vec();
995                Ok(())
996            }
997            fn load(&self) -> anyhow::Result<Vec<TrailEntry>> {
998                Ok(self.data.lock().unwrap().clone())
999            }
1000        }
1001
1002        let store = MemStore {
1003            data: Mutex::new(Vec::new()),
1004        };
1005        let trail = create_test_trail();
1006        trail.append(
1007            "agent-001".into(),
1008            AuditAction::ToolCall {
1009                tool: "bash".into(),
1010                args_json: "{}".into(),
1011            },
1012            "/test".into(),
1013        );
1014        trail.flush_to(&store).unwrap();
1015
1016        let trail2 = create_test_trail();
1017        trail2.restore_from_store(&store).unwrap();
1018        assert_eq!(trail2.len(), 1);
1019        assert_eq!(trail2.all_entries()[0].actor, "agent-001");
1020    }
1021}