1use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::sync::atomic::{AtomicU64, Ordering};
12
13pub type HashDigest = String;
15
16pub type AgentId = String;
18
19#[derive(Debug, Clone)]
23pub enum AuditError {
24 ChainBroken {
26 seq: u64,
28 expected: String,
30 found: String,
32 },
33 InvalidTimestamp {
35 seq: u64,
37 },
38 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
71#[serde(tag = "type", content = "data")]
72pub enum AuditAction {
73 AgentSpawn {
75 task_type: String,
77 },
78 AgentExit {
80 reason: String,
82 },
83 ToolCall {
85 tool: String,
87 args_json: String,
89 },
90 ToolResult {
92 tool: String,
94 success: bool,
96 },
97 MemoryWrite {
99 entry_id: String,
101 },
102 MemoryRead {
104 entry_id: String,
106 },
107 ConfigChange {
109 key: String,
111 },
112 ProgramInstall {
114 program: String,
116 version: String,
118 },
119 CronTrigger {
121 job_id: String,
123 },
124 GitCommit {
126 message: String,
128 },
129 AccessDenied {
131 permission: String,
133 },
134 Other {
136 detail: String,
138 },
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct TrailEntry {
146 pub seq: u64,
148 pub timestamp: DateTime<Utc>,
150 pub actor: AgentId,
152 pub action: AuditAction,
154 pub resource: String,
156 pub prev_hash: HashDigest,
158 pub hash: HashDigest,
160 pub metadata: Option<serde_json::Value>,
162}
163
164pub trait AuditPersistence: Send + Sync {
171 fn save(&self, entries: &[TrailEntry]) -> anyhow::Result<()>;
173 fn load(&self) -> anyhow::Result<Vec<TrailEntry>>;
175}
176
177fn 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
200pub 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 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 pub fn len(&self) -> usize {
228 self.entries.read().len()
229 }
230
231 pub fn is_empty(&self) -> bool {
233 self.len() == 0
234 }
235
236 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 pub fn append(&self, actor: AgentId, action: AuditAction, resource: String) -> HashDigest {
247 self.append_with_meta(actor, action, resource, None)
248 }
249
250 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, ×tamp, &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 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 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 pub fn all_entries(&self) -> Vec<TrailEntry> {
356 self.entries.read().clone()
357 }
358
359 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 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 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 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 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 pub fn flush_to(&self, store: &dyn AuditPersistence) -> anyhow::Result<()> {
420 let entries = self.all_entries();
421 store.save(&entries)
422 }
423
424 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 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#[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}