1#![allow(missing_docs)]
4use parking_lot::RwLock;
5use std::future::Future;
6use std::pin::Pin;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11use crate::auth::TenantScope;
12use crate::error::Error;
13use crate::llm::types::TokenUsage;
14
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
23#[serde(rename_all = "snake_case")]
24pub enum AuditMode {
25 Full,
27 #[default]
30 MetadataOnly,
31}
32
33const METADATA_ALLOWLIST: &[&str] = &[
43 "tool_name",
44 "tool_call_id",
45 "tool_call_count",
46 "duration_ms",
47 "latency_ms",
48 "is_error",
49 "turn",
50 "hook",
51 "event_type",
52 "stop_reason",
53 "model",
54 "total_tool_calls",
55 "input_tokens",
56 "output_tokens",
57 "cache_creation_input_tokens",
58 "cache_read_input_tokens",
59 "reasoning_tokens",
60 "agent",
61 "tenant_id",
62 "user_id",
63 "verdict",
64 "guardrail_name",
65 "consecutive_count",
66 "tool_names",
67 "from_tier",
68 "to_tier",
69 "decision",
70 "priority",
71 "spawned_name",
72 "complexity_score",
73 "escalated",
74 "tool_results_pruned",
75 "tool_results_total",
76 "bytes_saved",
77 "success",
78 "reason",
82];
83
84pub fn strip_content(payload: &serde_json::Value) -> serde_json::Value {
94 strip_content_owned(payload.clone())
95}
96
97pub fn strip_content_owned(payload: serde_json::Value) -> serde_json::Value {
105 strip_value_owned(payload)
106}
107
108fn strip_value_owned(value: serde_json::Value) -> serde_json::Value {
109 match value {
110 serde_json::Value::Object(map) => {
111 let mut stripped = serde_json::Map::with_capacity(map.len());
112 for (key, val) in map {
113 if METADATA_ALLOWLIST.contains(&key.as_str()) {
114 stripped.insert(key, strip_scalar_or_recurse_owned(val));
118 } else {
119 stripped.insert(key, redact_marker_owned(val));
123 }
124 }
125 serde_json::Value::Object(stripped)
126 }
127 other => other,
131 }
132}
133
134fn strip_scalar_or_recurse_owned(value: serde_json::Value) -> serde_json::Value {
135 match value {
136 serde_json::Value::Object(_) | serde_json::Value::Array(_) => strip_value_owned(value),
137 other => other,
140 }
141}
142
143fn redact_marker_owned(value: serde_json::Value) -> serde_json::Value {
144 match value {
145 v @ (serde_json::Value::Number(_)
147 | serde_json::Value::Bool(_)
148 | serde_json::Value::Null) => v,
149 _ => serde_json::Value::String("[stripped]".into()),
150 }
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct AuditRecord {
159 pub agent: String,
160 pub turn: usize,
161 pub event_type: String,
162 pub payload: serde_json::Value,
163 pub usage: TokenUsage,
164 pub timestamp: DateTime<Utc>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub user_id: Option<String>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub tenant_id: Option<String>,
171 #[serde(default, skip_serializing_if = "Vec::is_empty")]
173 pub delegation_chain: Vec<String>,
174}
175
176pub trait AuditTrail: Send + Sync {
181 fn record(
183 &self,
184 entry: AuditRecord,
185 ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>>;
186
187 fn entries(
191 &self,
192 scope: &TenantScope,
193 limit: usize,
194 ) -> Pin<Box<dyn Future<Output = Result<Vec<AuditRecord>, Error>> + Send + '_>>;
195
196 fn entries_unscoped(
200 &self,
201 limit: usize,
202 ) -> Pin<Box<dyn Future<Output = Result<Vec<AuditRecord>, Error>> + Send + '_>>;
203
204 fn entries_since(
207 &self,
208 scope: &TenantScope,
209 since: chrono::DateTime<chrono::Utc>,
210 limit: usize,
211 ) -> Pin<Box<dyn Future<Output = Result<Vec<AuditRecord>, Error>> + Send + '_>>;
212
213 fn prune(
215 &self,
216 retain: chrono::Duration,
217 ) -> Pin<Box<dyn Future<Output = Result<usize, Error>> + Send + '_>>;
218
219 fn erase_for_user(
222 &self,
223 _user_id: &str,
224 ) -> Pin<Box<dyn Future<Output = Result<usize, Error>> + Send + '_>> {
225 Box::pin(async { Ok(0) })
226 }
227}
228
229pub struct InMemoryAuditTrail {
237 records: RwLock<Vec<AuditRecord>>,
238}
239
240impl InMemoryAuditTrail {
241 pub fn new() -> Self {
242 Self {
243 records: RwLock::new(Vec::new()),
244 }
245 }
246}
247
248impl Default for InMemoryAuditTrail {
249 fn default() -> Self {
250 Self::new()
251 }
252}
253
254impl AuditTrail for InMemoryAuditTrail {
255 fn record(
256 &self,
257 entry: AuditRecord,
258 ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
259 Box::pin(async move {
260 self.records.write().push(entry);
261 Ok(())
262 })
263 }
264
265 fn entries(
266 &self,
267 scope: &TenantScope,
268 limit: usize,
269 ) -> Pin<Box<dyn Future<Output = Result<Vec<AuditRecord>, Error>> + Send + '_>> {
270 let tid = scope.tenant_id.clone();
271 Box::pin(async move {
272 let records = self.records.read();
273 let matched: Vec<AuditRecord> = records
274 .iter()
275 .filter(|r| r.tenant_id.as_deref().unwrap_or("") == tid.as_str())
276 .cloned()
277 .collect();
278 let start = matched.len().saturating_sub(limit);
279 Ok(matched[start..].to_vec())
280 })
281 }
282
283 fn entries_unscoped(
284 &self,
285 limit: usize,
286 ) -> Pin<Box<dyn Future<Output = Result<Vec<AuditRecord>, Error>> + Send + '_>> {
287 Box::pin(async move {
288 let records = self.records.read();
289 let start = records.len().saturating_sub(limit);
290 Ok(records[start..].to_vec())
291 })
292 }
293
294 fn entries_since(
295 &self,
296 scope: &TenantScope,
297 since: chrono::DateTime<chrono::Utc>,
298 limit: usize,
299 ) -> Pin<Box<dyn Future<Output = Result<Vec<AuditRecord>, Error>> + Send + '_>> {
300 let tid = scope.tenant_id.clone();
301 Box::pin(async move {
302 let records = self.records.read();
303 let matched: Vec<AuditRecord> = records
304 .iter()
305 .filter(|r| {
306 r.tenant_id.as_deref().unwrap_or("") == tid.as_str() && r.timestamp >= since
307 })
308 .cloned()
309 .collect();
310 let start = matched.len().saturating_sub(limit);
311 Ok(matched[start..].to_vec())
312 })
313 }
314
315 fn prune(
316 &self,
317 retain: chrono::Duration,
318 ) -> Pin<Box<dyn Future<Output = Result<usize, Error>> + Send + '_>> {
319 Box::pin(async move {
320 let cutoff = chrono::Utc::now() - retain;
321 let mut records = self.records.write();
322 let before = records.len();
323 records.retain(|r| r.timestamp >= cutoff);
324 Ok(before - records.len())
325 })
326 }
327
328 fn erase_for_user(
329 &self,
330 user_id: &str,
331 ) -> Pin<Box<dyn Future<Output = Result<usize, Error>> + Send + '_>> {
332 let user_id = user_id.to_string();
333 Box::pin(async move {
334 let mut records = self.records.write();
335 let before = records.len();
336 records.retain(|r| r.user_id.as_deref() != Some(user_id.as_str()));
337 Ok(before - records.len())
338 })
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use serde_json::json;
346
347 fn make_record(agent: &str, event_type: &str, payload: serde_json::Value) -> AuditRecord {
348 AuditRecord {
349 agent: agent.into(),
350 turn: 1,
351 event_type: event_type.into(),
352 payload,
353 usage: TokenUsage::default(),
354 timestamp: Utc::now(),
355 user_id: None,
356 tenant_id: None,
357 delegation_chain: Vec::new(),
358 }
359 }
360
361 #[test]
362 fn audit_record_serializes() {
363 let record = make_record("test-agent", "llm_response", json!({"text": "hello"}));
364 let json = serde_json::to_string(&record).expect("serialize");
365 let deserialized: AuditRecord = serde_json::from_str(&json).expect("deserialize");
366 assert_eq!(deserialized.agent, "test-agent");
367 assert_eq!(deserialized.event_type, "llm_response");
368 assert_eq!(deserialized.payload, json!({"text": "hello"}));
369 }
370
371 #[tokio::test]
372 async fn in_memory_trail_stores_and_retrieves() {
373 let trail = InMemoryAuditTrail::new();
374 trail
375 .record(make_record("a", "llm_response", json!({"turn": 1})))
376 .await
377 .unwrap();
378 trail
379 .record(make_record("a", "tool_call", json!({"name": "bash"})))
380 .await
381 .unwrap();
382 trail
383 .record(make_record("a", "tool_result", json!({"ok": true})))
384 .await
385 .unwrap();
386
387 let entries = trail.entries_unscoped(usize::MAX).await.unwrap();
388 assert_eq!(entries.len(), 3);
389 assert_eq!(entries[0].event_type, "llm_response");
390 assert_eq!(entries[1].event_type, "tool_call");
391 assert_eq!(entries[2].event_type, "tool_result");
392 }
393
394 #[tokio::test]
395 async fn in_memory_trail_empty_by_default() {
396 let trail = InMemoryAuditTrail::new();
397 let entries = trail.entries_unscoped(usize::MAX).await.unwrap();
398 assert!(entries.is_empty());
399 }
400
401 fn make_record_with_context(
402 agent: &str,
403 event_type: &str,
404 payload: serde_json::Value,
405 user_id: Option<&str>,
406 tenant_id: Option<&str>,
407 delegation_chain: Vec<String>,
408 ) -> AuditRecord {
409 AuditRecord {
410 agent: agent.into(),
411 turn: 1,
412 event_type: event_type.into(),
413 payload,
414 usage: TokenUsage::default(),
415 timestamp: Utc::now(),
416 user_id: user_id.map(String::from),
417 tenant_id: tenant_id.map(String::from),
418 delegation_chain,
419 }
420 }
421
422 #[test]
423 fn audit_record_with_user_context() {
424 let record = make_record_with_context(
425 "agent-1",
426 "llm_response",
427 json!({"text": "hi"}),
428 Some("user-42"),
429 Some("tenant-a"),
430 vec!["actor-1".into(), "actor-2".into()],
431 );
432 let json_str = serde_json::to_string(&record).expect("serialize");
433 let deserialized: AuditRecord = serde_json::from_str(&json_str).expect("deserialize");
434 assert_eq!(deserialized.user_id.as_deref(), Some("user-42"));
435 assert_eq!(deserialized.tenant_id.as_deref(), Some("tenant-a"));
436 assert_eq!(deserialized.delegation_chain, vec!["actor-1", "actor-2"]);
437 }
438
439 #[test]
440 fn audit_record_backward_compat() {
441 let old_json = json!({
443 "agent": "old-agent",
444 "turn": 3,
445 "event_type": "tool_call",
446 "payload": {"name": "bash"},
447 "usage": {"input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0},
448 "timestamp": "2026-01-01T00:00:00Z"
449 });
450 let record: AuditRecord = serde_json::from_value(old_json).expect("deserialize old format");
451 assert_eq!(record.user_id, None);
452 assert_eq!(record.tenant_id, None);
453 assert!(record.delegation_chain.is_empty());
454 }
455
456 #[test]
457 fn audit_record_delegation_chain_omitted_when_empty() {
458 let record =
459 make_record_with_context("agent-1", "llm_response", json!({}), None, None, vec![]);
460 let json_val = serde_json::to_value(&record).expect("serialize");
461 assert!(
462 !json_val
463 .as_object()
464 .unwrap()
465 .contains_key("delegation_chain")
466 );
467 }
468
469 #[test]
470 fn audit_record_user_id_omitted_when_none() {
471 let record = make_record_with_context(
472 "agent-1",
473 "llm_response",
474 json!({}),
475 None,
476 Some("tenant-a"),
477 vec![],
478 );
479 let json_val = serde_json::to_value(&record).expect("serialize");
480 let obj = json_val.as_object().unwrap();
481 assert!(!obj.contains_key("user_id"));
482 assert!(obj.contains_key("tenant_id"));
483 }
484
485 #[tokio::test]
486 async fn entries_scoped_filters_correctly() {
487 use crate::auth::TenantScope;
488
489 let trail = InMemoryAuditTrail::new();
490 trail
491 .record(make_record_with_context(
492 "a",
493 "llm_response",
494 json!({}),
495 None,
496 Some("tenant-a"),
497 vec![],
498 ))
499 .await
500 .unwrap();
501 trail
502 .record(make_record_with_context(
503 "b",
504 "tool_call",
505 json!({}),
506 None,
507 Some("tenant-b"),
508 vec![],
509 ))
510 .await
511 .unwrap();
512 trail
513 .record(make_record_with_context(
514 "c",
515 "tool_result",
516 json!({}),
517 None,
518 Some("tenant-a"),
519 vec![],
520 ))
521 .await
522 .unwrap();
523
524 let scope = TenantScope::new("tenant-a");
525 let filtered = trail.entries(&scope, usize::MAX).await.unwrap();
526 assert_eq!(filtered.len(), 2);
527 assert!(
528 filtered
529 .iter()
530 .all(|r| r.tenant_id.as_deref() == Some("tenant-a"))
531 );
532 }
533
534 #[tokio::test]
535 async fn entries_unscoped_returns_all() {
536 let trail = InMemoryAuditTrail::new();
537 trail
538 .record(make_record_with_context(
539 "a",
540 "llm_response",
541 json!({}),
542 None,
543 Some("tenant-a"),
544 vec![],
545 ))
546 .await
547 .unwrap();
548 trail
549 .record(make_record_with_context(
550 "b",
551 "tool_call",
552 json!({}),
553 None,
554 Some("tenant-b"),
555 vec![],
556 ))
557 .await
558 .unwrap();
559 trail
560 .record(make_record_with_context(
561 "c",
562 "tool_result",
563 json!({}),
564 None,
565 None,
566 vec![],
567 ))
568 .await
569 .unwrap();
570
571 let all = trail.entries_unscoped(usize::MAX).await.unwrap();
572 assert_eq!(all.len(), 3);
573 }
574
575 #[tokio::test]
576 async fn audit_record_with_large_payload() {
577 let trail = InMemoryAuditTrail::new();
578 let large = "x".repeat(1_000_000);
580 let payload = json!({"data": large});
581 trail
582 .record(make_record("a", "tool_result", payload.clone()))
583 .await
584 .unwrap();
585
586 let entries = trail.entries_unscoped(usize::MAX).await.unwrap();
587 assert_eq!(entries.len(), 1);
588 assert_eq!(entries[0].payload, payload);
589 }
590
591 #[test]
594 fn metadata_only_strips_content_fields() {
595 let payload = json!({
596 "tool_name": "bash",
597 "text": "secret user input",
598 "input": {"command": "ls"},
599 "output": "file list",
600 "duration_ms": 42,
601 "is_error": false
602 });
603 let stripped = strip_content(&payload);
604 let obj = stripped.as_object().unwrap();
605 assert_eq!(obj["tool_name"], "bash");
606 assert_eq!(obj["duration_ms"], 42);
607 assert_eq!(obj["is_error"], false);
608 assert_eq!(obj["text"], "[stripped]");
609 assert_eq!(obj["input"], "[stripped]");
610 assert_eq!(obj["output"], "[stripped]");
611 }
612
613 #[test]
614 fn full_mode_preserves_content() {
615 let payload = json!({
616 "text": "hello world",
617 "tool_name": "bash",
618 "duration_ms": 10
619 });
620 let mode = AuditMode::Full;
622 assert_eq!(mode, AuditMode::Full);
623 assert_eq!(payload["text"], "hello world");
625 assert_eq!(payload["tool_name"], "bash");
626 }
627
628 #[test]
629 fn strip_content_replaces_known_fields_with_marker() {
630 let payload = json!({
631 "text": "some text",
632 "input": "some input",
633 "output": "some output",
634 "data": "some data",
635 "command": "some command",
636 "content": "some content",
637 "result": "some result"
638 });
639 let stripped = strip_content(&payload);
640 let obj = stripped.as_object().unwrap();
641 for key in &[
642 "text", "input", "output", "data", "command", "content", "result",
643 ] {
644 assert_eq!(obj[*key], "[stripped]", "field {key} should be stripped");
645 }
646 }
647
648 #[test]
649 fn strip_content_preserves_metadata_fields() {
650 let payload = json!({
651 "tool_name": "read_file",
652 "duration_ms": 100,
653 "is_error": false,
654 "turn": 3,
655 "hook": "pre_tool",
656 "reason": "policy violation",
657 "event_type": "tool_call",
658 "stop_reason": "end_turn",
659 "tool_call_count": 2
660 });
661 let stripped = strip_content(&payload);
662 assert_eq!(
663 stripped, payload,
664 "metadata-only payload should be unchanged"
665 );
666 }
667
668 #[tokio::test]
669 async fn erase_for_user_removes_matching_records() {
670 let trail = InMemoryAuditTrail::new();
671 trail
672 .record(make_record_with_context(
673 "a",
674 "llm_response",
675 json!({}),
676 Some("user-1"),
677 None,
678 vec![],
679 ))
680 .await
681 .unwrap();
682 trail
683 .record(make_record_with_context(
684 "b",
685 "tool_call",
686 json!({}),
687 Some("user-2"),
688 None,
689 vec![],
690 ))
691 .await
692 .unwrap();
693 trail
694 .record(make_record_with_context(
695 "c",
696 "tool_result",
697 json!({}),
698 Some("user-1"),
699 None,
700 vec![],
701 ))
702 .await
703 .unwrap();
704
705 let removed = trail.erase_for_user("user-1").await.unwrap();
706 assert_eq!(removed, 2);
707
708 let remaining = trail.entries_unscoped(usize::MAX).await.unwrap();
709 assert_eq!(remaining.len(), 1);
710 assert_eq!(remaining[0].user_id.as_deref(), Some("user-2"));
711 }
712
713 #[tokio::test]
714 async fn erase_for_user_no_matches_returns_zero() {
715 let trail = InMemoryAuditTrail::new();
716 trail
717 .record(make_record_with_context(
718 "a",
719 "llm_response",
720 json!({}),
721 Some("user-1"),
722 None,
723 vec![],
724 ))
725 .await
726 .unwrap();
727
728 let removed = trail.erase_for_user("user-999").await.unwrap();
729 assert_eq!(removed, 0);
730
731 let remaining = trail.entries_unscoped(usize::MAX).await.unwrap();
732 assert_eq!(remaining.len(), 1);
733 }
734
735 #[test]
736 fn audit_mode_serde_roundtrip() {
737 let full = AuditMode::Full;
738 let json_full = serde_json::to_string(&full).expect("serialize full");
739 assert_eq!(json_full, "\"full\"");
740 let deserialized: AuditMode = serde_json::from_str(&json_full).expect("deserialize full");
741 assert_eq!(deserialized, AuditMode::Full);
742
743 let meta = AuditMode::MetadataOnly;
744 let json_meta = serde_json::to_string(&meta).expect("serialize metadata_only");
745 assert_eq!(json_meta, "\"metadata_only\"");
746 let deserialized: AuditMode =
747 serde_json::from_str(&json_meta).expect("deserialize metadata_only");
748 assert_eq!(deserialized, AuditMode::MetadataOnly);
749 }
750
751 #[test]
755 fn strip_content_redacts_result_preview_and_error() {
756 let payload = json!({
759 "total_tool_calls": 2,
760 "result_preview": "user secret content here"
761 });
762 let stripped = strip_content(&payload);
763 let obj = stripped.as_object().unwrap();
764 assert_eq!(obj["total_tool_calls"], 2);
765 assert_eq!(
766 obj["result_preview"], "[stripped]",
767 "result_preview MUST be stripped (F-AUTH-3)"
768 );
769
770 let payload = json!({
772 "error": "tool foo failed: <user data was here>"
773 });
774 let stripped = strip_content(&payload);
775 let obj = stripped.as_object().unwrap();
776 assert_eq!(
777 obj["error"], "[stripped]",
778 "error MUST be stripped (F-AUTH-3)"
779 );
780 }
781
782 #[test]
787 fn strip_content_recursive_no_leak_via_nested_object() {
788 let payload = json!({
789 "meta": {
790 "command": "rm -rf /",
791 "input": {"file": "secret.txt"}
792 }
793 });
794 let stripped = strip_content(&payload);
795 let s = serde_json::to_string(&stripped).unwrap();
796 assert!(
797 !s.contains("rm -rf"),
798 "nested command must be redacted (F-AUTH-3): {s}"
799 );
800 assert!(
801 !s.contains("secret.txt"),
802 "nested user content must be redacted: {s}"
803 );
804 }
805
806 #[test]
807 fn strip_content_non_object_passthrough() {
808 let string_val = serde_json::Value::String("hello".into());
809 assert_eq!(strip_content(&string_val), string_val);
810
811 let number_val = json!(42);
812 assert_eq!(strip_content(&number_val), number_val);
813
814 let array_val = json!([1, 2, 3]);
815 assert_eq!(strip_content(&array_val), array_val);
816
817 let null_val = serde_json::Value::Null;
818 assert_eq!(strip_content(&null_val), null_val);
819
820 let bool_val = json!(true);
821 assert_eq!(strip_content(&bool_val), bool_val);
822 }
823
824 #[tokio::test]
825 async fn entries_filters_by_scope() {
826 use crate::auth::TenantScope;
827
828 let trail = InMemoryAuditTrail::new();
829 let acme = TenantScope::new("acme");
830 let globex = TenantScope::new("globex");
831
832 let mk = |tid: Option<&str>| AuditRecord {
833 agent: "a".into(),
834 turn: 0,
835 event_type: "x".into(),
836 payload: serde_json::Value::Null,
837 usage: TokenUsage::default(),
838 timestamp: chrono::Utc::now(),
839 user_id: None,
840 tenant_id: tid.map(|s| s.into()),
841 delegation_chain: vec![],
842 };
843
844 trail.record(mk(Some("acme"))).await.unwrap();
845 trail.record(mk(Some("globex"))).await.unwrap();
846
847 let acme_rows = trail.entries(&acme, 100).await.unwrap();
848 assert_eq!(acme_rows.len(), 1);
849 assert_eq!(acme_rows[0].tenant_id.as_deref(), Some("acme"));
850
851 let globex_rows = trail.entries(&globex, 100).await.unwrap();
852 assert_eq!(globex_rows.len(), 1);
853 assert_eq!(globex_rows[0].tenant_id.as_deref(), Some("globex"));
854
855 let unscoped = trail.entries_unscoped(100).await.unwrap();
856 assert_eq!(unscoped.len(), 2);
857 }
858
859 #[tokio::test]
860 async fn prune_deletes_old_entries() {
861 let trail = InMemoryAuditTrail::new();
862 let now = chrono::Utc::now();
863 let mk = |timestamp| AuditRecord {
864 agent: "a".into(),
865 turn: 0,
866 event_type: "x".into(),
867 payload: serde_json::Value::Null,
868 usage: TokenUsage::default(),
869 timestamp,
870 user_id: None,
871 tenant_id: None,
872 delegation_chain: vec![],
873 };
874
875 trail
876 .record(mk(now - chrono::Duration::days(10)))
877 .await
878 .unwrap();
879 trail.record(mk(now)).await.unwrap();
880
881 let removed = trail.prune(chrono::Duration::days(7)).await.unwrap();
882 assert_eq!(removed, 1);
883
884 let rest = trail.entries_unscoped(100).await.unwrap();
885 assert_eq!(rest.len(), 1);
886 }
887
888 #[tokio::test]
889 async fn entries_since_filters_by_time() {
890 use crate::auth::TenantScope;
891
892 let trail = InMemoryAuditTrail::new();
893 let now = chrono::Utc::now();
894 let scope = TenantScope::new("acme");
895 let mk = |timestamp| AuditRecord {
896 agent: "a".into(),
897 turn: 0,
898 event_type: "x".into(),
899 payload: serde_json::Value::Null,
900 usage: TokenUsage::default(),
901 timestamp,
902 user_id: None,
903 tenant_id: Some("acme".into()),
904 delegation_chain: vec![],
905 };
906
907 trail
908 .record(mk(now - chrono::Duration::hours(48)))
909 .await
910 .unwrap();
911 trail
912 .record(mk(now - chrono::Duration::hours(1)))
913 .await
914 .unwrap();
915
916 let recent = trail
917 .entries_since(&scope, now - chrono::Duration::hours(24), 100)
918 .await
919 .unwrap();
920 assert_eq!(recent.len(), 1);
921 }
922}