1use std::collections::HashMap;
18use std::path::{Component, Path, PathBuf};
19use std::sync::{Arc, Mutex as StdMutex, OnceLock};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22use anyhow::Result;
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25
26use super::db::Store;
27
28pub const SCHEMA_VERSION: u8 = 2;
38
39pub const HASH_ALGORITHM: &str = "sha256";
42
43const SEQ_KEY: &str = "enforcement:seq";
45
46pub const INSTALLATION_ID_KEY: &str = "system:installation_id";
48
49pub const EVENT_PREFIX: &str = "enforcement:event:";
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct EnforcementEvent {
63 pub event_id: String,
65
66 pub schema_version: u8,
68
69 pub seq_no: u64,
73
74 pub recorded_at_ms: u64,
76
77 pub event_type: EnforcementEventType,
79
80 pub event_hash: String,
83
84 pub prev_hash: String,
87
88 pub installation_id: String,
91
92 pub actor_local: Option<ActorLocal>,
95
96 pub agent_type: String,
98
99 pub subject_kind: SubjectKind,
101
102 pub subject_key: String,
106
107 pub canonical_subject_hash: Option<String>,
110
111 pub receipt_id: Option<String>,
113
114 pub decision_reason_code: String,
118
119 pub decision_basis_hash: Option<String>,
122
123 pub agent_session: Option<String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ActorLocal {
133 pub username: String,
135 pub uid: Option<u32>,
137 pub verified: bool, }
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum SubjectKind {
144 File,
145 Control,
146 Config,
147 System,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(tag = "type", rename_all = "snake_case")]
152pub enum EnforcementEventType {
153 Deny,
154 AllowAfterReceipt,
155 ReceiptMinted,
156 BypassDetected,
157 ControlChanged {
158 change_kind: ControlChangeKind,
159 },
160 EnforcementConfigChanged {
161 setting: String,
162 old_value: String,
163 new_value: String,
164 },
165 RecordingGap {
166 gap_start_ms: u64,
167 gap_end_ms: u64,
168 cause: GapCause,
169 enforcement_mode_during_gap: EnforcementMode,
170 missed_event_count: MissedEventCount,
171 certainty: GapCertainty,
172 },
173 RetentionPruned {
174 pruned_count: u64,
175 oldest_pruned_seq: u64,
176 newest_pruned_seq: u64,
177 },
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "snake_case")]
182pub enum ControlChangeKind {
183 Created,
184 Confirmed,
185 Updated,
186 Deleted,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub enum GapCause {
192 DaemonUnreachable,
193 StoreWriteFailure,
194 StoreLocked,
195 CorruptionRecovery,
196 Unknown,
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "snake_case")]
201pub enum EnforcementMode {
202 Advisory,
203 Strict,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(rename_all = "snake_case")]
208pub enum MissedEventCount {
209 Known(u64),
210 Zero,
211 Unknown,
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "snake_case")]
216pub enum GapCertainty {
217 Exact,
218 Inferred,
219}
220
221#[derive(Serialize)]
232struct CanonicalEvent<'a> {
233 event_id: &'a str,
234 schema_version: u8,
235 seq_no: u64,
236 recorded_at_ms: u64,
237 event_type: &'a EnforcementEventType,
238 prev_hash: &'a str,
239 installation_id: &'a str,
240 actor_local: &'a Option<ActorLocal>,
241 agent_type: &'a str,
242 subject_kind: SubjectKind,
243 subject_key: &'a str,
244 canonical_subject_hash: Option<&'a str>,
245 receipt_id: Option<&'a str>,
246 decision_reason_code: &'a str,
247 decision_basis_hash: Option<&'a str>,
248}
249
250#[derive(Serialize)]
254struct CanonicalEventV2<'a> {
255 event_id: &'a str,
256 schema_version: u8,
257 seq_no: u64,
258 recorded_at_ms: u64,
259 event_type: &'a EnforcementEventType,
260 prev_hash: &'a str,
261 installation_id: &'a str,
262 actor_local: &'a Option<ActorLocal>,
263 agent_type: &'a str,
264 subject_kind: SubjectKind,
265 subject_key: &'a str,
266 canonical_subject_hash: Option<&'a str>,
267 receipt_id: Option<&'a str>,
268 decision_reason_code: &'a str,
269 decision_basis_hash: Option<&'a str>,
270 agent_session: Option<&'a str>,
271}
272
273impl EnforcementEvent {
274 pub fn compute_hash(&self) -> String {
280 let json = if self.schema_version >= 2 {
284 let canonical = CanonicalEventV2 {
285 event_id: &self.event_id,
286 schema_version: self.schema_version,
287 seq_no: self.seq_no,
288 recorded_at_ms: self.recorded_at_ms,
289 event_type: &self.event_type,
290 prev_hash: &self.prev_hash,
291 installation_id: &self.installation_id,
292 actor_local: &self.actor_local,
293 agent_type: &self.agent_type,
294 subject_kind: self.subject_kind,
295 subject_key: &self.subject_key,
296 canonical_subject_hash: self.canonical_subject_hash.as_deref(),
297 receipt_id: self.receipt_id.as_deref(),
298 decision_reason_code: &self.decision_reason_code,
299 decision_basis_hash: self.decision_basis_hash.as_deref(),
300 agent_session: self.agent_session.as_deref(),
301 };
302 serde_json::to_string(&canonical).expect("canonical serialization must not fail")
303 } else {
304 let canonical = CanonicalEvent {
305 event_id: &self.event_id,
306 schema_version: self.schema_version,
307 seq_no: self.seq_no,
308 recorded_at_ms: self.recorded_at_ms,
309 event_type: &self.event_type,
310 prev_hash: &self.prev_hash,
311 installation_id: &self.installation_id,
312 actor_local: &self.actor_local,
313 agent_type: &self.agent_type,
314 subject_kind: self.subject_kind,
315 subject_key: &self.subject_key,
316 canonical_subject_hash: self.canonical_subject_hash.as_deref(),
317 receipt_id: self.receipt_id.as_deref(),
318 decision_reason_code: &self.decision_reason_code,
319 decision_basis_hash: self.decision_basis_hash.as_deref(),
320 };
321 serde_json::to_string(&canonical).expect("canonical serialization must not fail")
322 };
323
324 let mut hasher = Sha256::new();
325 hasher.update(json.as_bytes());
326 format!("{:x}", hasher.finalize())
327 }
328}
329
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(rename_all = "snake_case")]
337pub enum ChainBreakKind {
338 Linkage,
343 Tampered,
346 UnknownSchema,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356pub struct ChainBreak {
357 pub kind: ChainBreakKind,
358 pub seq_no: u64,
360 pub recorded_at_ms: u64,
361 pub event_type: String,
362 pub prev_seq_no: Option<u64>,
364 pub prev_recorded_at_ms: Option<u64>,
365 pub prev_event_type: Option<String>,
366}
367
368#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
385pub struct ChainVerification {
386 pub checked: usize,
388 pub tampered_events: usize,
391 pub linkage_breaks: usize,
395 pub unknown_schema: usize,
398 pub breaks: Vec<ChainBreak>,
400}
401
402impl ChainVerification {
403 pub fn is_valid(&self) -> bool {
406 self.tampered_events == 0 && self.linkage_breaks == 0 && self.unknown_schema == 0
407 }
408}
409
410pub fn verify_chain(events: &[EnforcementEvent]) -> ChainVerification {
421 let mut sorted: Vec<&EnforcementEvent> = events.iter().collect();
422 sorted.sort_by_key(|e| e.seq_no);
423
424 let mut result = ChainVerification::default();
425 let mut prev: Option<&EnforcementEvent> = None;
426
427 for e in sorted {
428 if let Some(p) = prev {
430 if e.prev_hash != p.event_hash {
431 result.linkage_breaks += 1;
432 result.breaks.push(ChainBreak {
433 kind: ChainBreakKind::Linkage,
434 seq_no: e.seq_no,
435 recorded_at_ms: e.recorded_at_ms,
436 event_type: event_type_label(&e.event_type).to_string(),
437 prev_seq_no: Some(p.seq_no),
438 prev_recorded_at_ms: Some(p.recorded_at_ms),
439 prev_event_type: Some(event_type_label(&p.event_type).to_string()),
440 });
441 }
442 }
443
444 if e.schema_version > SCHEMA_VERSION {
447 result.unknown_schema += 1;
448 result.breaks.push(ChainBreak {
449 kind: ChainBreakKind::UnknownSchema,
450 seq_no: e.seq_no,
451 recorded_at_ms: e.recorded_at_ms,
452 event_type: event_type_label(&e.event_type).to_string(),
453 prev_seq_no: None,
454 prev_recorded_at_ms: None,
455 prev_event_type: None,
456 });
457 } else {
458 result.checked += 1;
459 if e.event_hash != e.compute_hash() {
460 result.tampered_events += 1;
461 result.breaks.push(ChainBreak {
462 kind: ChainBreakKind::Tampered,
463 seq_no: e.seq_no,
464 recorded_at_ms: e.recorded_at_ms,
465 event_type: event_type_label(&e.event_type).to_string(),
466 prev_seq_no: None,
467 prev_recorded_at_ms: None,
468 prev_event_type: None,
469 });
470 }
471 }
472
473 prev = Some(e);
474 }
475
476 result
477}
478
479pub struct SeqAllocator {
489 current: u64,
490}
491
492impl SeqAllocator {
493 pub async fn load(store: &Store) -> Self {
495 let current = match store.get_raw_bytes(SEQ_KEY).await {
496 Ok(Some(bytes)) if bytes.len() == 8 => {
497 u64::from_be_bytes(bytes[..8].try_into().unwrap_or([0; 8]))
498 }
499 _ => 0,
500 };
501 Self { current }
502 }
503
504 pub async fn next(&mut self, store: &Store) -> Result<u64> {
509 self.current += 1;
510 store.put_raw(SEQ_KEY, &self.current.to_be_bytes()).await?;
511 Ok(self.current)
512 }
513
514 pub fn current(&self) -> u64 {
516 self.current
517 }
518}
519
520pub async fn get_or_create_installation_id(store: &Store) -> Result<String> {
529 if let Ok(Some(bytes)) = store.get_raw_bytes(INSTALLATION_ID_KEY).await {
530 if let Ok(id) = std::str::from_utf8(&bytes) {
531 if !id.is_empty() {
532 return Ok(id.to_string());
533 }
534 }
535 }
536 let id = uuid::Uuid::new_v4().to_string();
537 store.put_raw(INSTALLATION_ID_KEY, id.as_bytes()).await?;
538 Ok(id)
539}
540
541pub fn get_local_actor() -> Option<ActorLocal> {
547 let username = std::env::var("USER")
548 .or_else(|_| std::env::var("USERNAME"))
549 .ok()?;
550
551 #[cfg(unix)]
552 let uid = Some(unsafe { libc::getuid() } as u32);
553 #[cfg(not(unix))]
554 let uid = None;
555
556 Some(ActorLocal {
557 username,
558 uid,
559 verified: false,
560 })
561}
562
563pub fn canonicalize_file_key(path: &str, repo_root: &Path) -> String {
585 let abs_path = if Path::new(path).is_relative() {
587 repo_root.join(path)
588 } else {
589 PathBuf::from(path)
590 };
591
592 let normalized = normalize_components(&abs_path);
594
595 let resolved = std::fs::canonicalize(&normalized).unwrap_or(normalized);
597
598 let repo_root_canonical =
600 std::fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
601 let relative = resolved
602 .strip_prefix(&repo_root_canonical)
603 .unwrap_or(&resolved);
604
605 let mut key = relative
607 .components()
608 .map(|c| c.as_os_str().to_string_lossy().to_string())
609 .collect::<Vec<_>>()
610 .join("/");
611
612 if is_case_insensitive() {
614 key = key.to_lowercase();
615 }
616
617 key
618}
619
620fn normalize_components(path: &Path) -> PathBuf {
623 let mut components = Vec::new();
624 for component in path.components() {
625 match component {
626 Component::CurDir => {} Component::ParentDir => {
628 if matches!(components.last(), Some(Component::Normal(_))) {
630 components.pop();
631 } else {
632 components.push(component);
633 }
634 }
635 _ => components.push(component),
636 }
637 }
638 components.iter().collect()
639}
640
641fn is_case_insensitive() -> bool {
646 cfg!(target_os = "macos") || cfg!(target_os = "windows")
647}
648
649pub fn canonical_subject_hash(canonical_key: &str) -> String {
653 let mut hasher = Sha256::new();
654 hasher.update(canonical_key.as_bytes());
655 format!("{:x}", hasher.finalize())
656}
657
658fn uuid7_string() -> String {
667 uuid::Uuid::now_v7().to_string()
668}
669
670fn now_ms() -> u64 {
672 SystemTime::now()
673 .duration_since(UNIX_EPOCH)
674 .unwrap_or_default()
675 .as_millis() as u64
676}
677
678pub struct EnforcementEventWriter {
688 seq: SeqAllocator,
689 installation_id: String,
690 prev_hash: String,
691 agent_session: Option<String>,
694}
695
696impl EnforcementEventWriter {
697 pub async fn new(store: &Store) -> Result<Self> {
702 let seq = SeqAllocator::load(store).await;
703 let installation_id = get_or_create_installation_id(store).await?;
704 let prev_hash = Self::load_last_hash(store).await;
705
706 Ok(Self {
707 seq,
708 installation_id,
709 prev_hash,
710 agent_session: None,
711 })
712 }
713
714 async fn load_last_hash(store: &Store) -> String {
719 let keys = match store.scan_keys(EVENT_PREFIX).await {
722 Ok(k) => k,
723 Err(_) => return String::new(),
724 };
725
726 if keys.is_empty() {
727 return String::new();
728 }
729
730 let last_key = keys
732 .iter()
733 .max_by_key(|k| {
734 k.strip_prefix(EVENT_PREFIX)
735 .and_then(|s| s.parse::<u64>().ok())
736 .unwrap_or(0)
737 })
738 .cloned();
739
740 if let Some(key) = last_key {
741 if let Ok(Some(bytes)) = store.get_raw_bytes(&key).await {
742 if let Ok(event) = serde_json::from_slice::<EnforcementEvent>(&bytes) {
743 return event.event_hash;
744 }
745 }
746 }
747
748 String::new()
749 }
750
751 #[allow(clippy::too_many_arguments)]
758 pub async fn write(
759 &mut self,
760 store: &Store,
761 event_type: EnforcementEventType,
762 subject_kind: SubjectKind,
763 subject_key: String,
764 agent_type: String,
765 receipt_id: Option<String>,
766 decision_reason_code: String,
767 decision_basis_hash: Option<String>,
768 ) -> Result<EnforcementEvent> {
769 let seq_no = self.seq.next(store).await?;
770
771 let canonical_subject_hash_value = if subject_kind == SubjectKind::File {
772 Some(canonical_subject_hash(&subject_key))
773 } else {
774 None
775 };
776
777 let mut event = EnforcementEvent {
778 event_id: uuid7_string(),
779 schema_version: SCHEMA_VERSION,
780 seq_no,
781 recorded_at_ms: now_ms(),
782 event_type,
783 event_hash: String::new(), prev_hash: self.prev_hash.clone(),
785 installation_id: self.installation_id.clone(),
786 actor_local: get_local_actor(),
787 agent_type,
788 subject_kind,
789 subject_key,
790 canonical_subject_hash: canonical_subject_hash_value,
791 receipt_id,
792 decision_reason_code,
793 decision_basis_hash,
794 agent_session: self.agent_session.clone(),
795 };
796
797 event.event_hash = event.compute_hash();
799
800 let key = format!("{EVENT_PREFIX}{:020}", seq_no);
802 let json = serde_json::to_vec(&event)?;
803 store.put_raw(&key, &json).await?;
804
805 self.prev_hash = event.event_hash.clone();
807
808 Ok(event)
809 }
810
811 pub fn installation_id(&self) -> &str {
813 &self.installation_id
814 }
815
816 pub fn current_seq(&self) -> u64 {
818 self.seq.current()
819 }
820
821 pub fn prev_hash(&self) -> &str {
823 &self.prev_hash
824 }
825
826 pub async fn detect_and_record_gap(
832 &mut self,
833 store: &Store,
834 gap_start_ms: u64,
835 gap_end_ms: u64,
836 cause: GapCause,
837 ) -> Result<EnforcementEvent> {
838 self.write(
839 store,
840 EnforcementEventType::RecordingGap {
841 gap_start_ms,
842 gap_end_ms,
843 cause,
844 enforcement_mode_during_gap: EnforcementMode::Advisory,
845 missed_event_count: MissedEventCount::Unknown,
846 certainty: GapCertainty::Inferred,
847 },
848 SubjectKind::System,
849 "enforcement:stream".to_string(),
850 "system".to_string(),
851 None,
852 "recording_gap_detected".to_string(),
853 None,
854 )
855 .await
856 }
857}
858
859pub async fn scan_enforcement_events(
868 store: &Store,
869 since_seq: u64,
870 until_seq: u64,
871) -> Result<Vec<EnforcementEvent>> {
872 let keys = store.scan_keys(EVENT_PREFIX).await?;
873 let mut events = Vec::new();
874
875 for key in &keys {
876 let seq = match key
877 .strip_prefix(EVENT_PREFIX)
878 .and_then(|s| s.parse::<u64>().ok())
879 {
880 Some(s) => s,
881 None => continue,
882 };
883 if seq < since_seq || seq > until_seq {
884 continue;
885 }
886 if let Ok(Some(bytes)) = store.get_raw_bytes(key).await {
887 match serde_json::from_slice::<EnforcementEvent>(&bytes) {
888 Ok(event) => events.push(event),
889 Err(e) => {
890 tracing::warn!(key, "skipping corrupt enforcement event: {e}");
891 }
892 }
893 }
894 }
895
896 events.sort_by_key(|e| e.seq_no);
897 Ok(events)
898}
899
900const ENFORCEMENT_MODE_KEY: &str = "enforcement:mode";
906
907const DEFAULT_RETENTION_DAYS: u64 = 365;
909
910const RETENTION_DAYS_KEY: &str = "enforcement:retention_days";
912
913pub async fn get_enforcement_mode(store: &Store) -> EnforcementMode {
916 match store.get_raw_bytes(ENFORCEMENT_MODE_KEY).await {
917 Ok(Some(bytes)) => match std::str::from_utf8(&bytes) {
918 Ok("strict") => EnforcementMode::Strict,
919 _ => EnforcementMode::Advisory,
920 },
921 _ => EnforcementMode::Advisory,
922 }
923}
924
925pub async fn set_enforcement_mode(store: &Store, mode: EnforcementMode) -> Result<EnforcementMode> {
928 let old = get_enforcement_mode(store).await;
929 let value = match mode {
930 EnforcementMode::Advisory => "advisory",
931 EnforcementMode::Strict => "strict",
932 };
933 store
934 .put_raw(ENFORCEMENT_MODE_KEY, value.as_bytes())
935 .await?;
936
937 if old != mode {
942 let user_label = |m: EnforcementMode| match m {
943 EnforcementMode::Advisory => "best_effort",
944 EnforcementMode::Strict => "strict",
945 };
946 let _ = record_event(
948 store,
949 EnforcementEventType::EnforcementConfigChanged {
950 setting: "audit.write_durability".to_string(),
951 old_value: user_label(old).to_string(),
952 new_value: user_label(mode).to_string(),
953 },
954 SubjectKind::Config,
955 "enforcement:mode".to_string(),
956 "developer".to_string(),
957 None,
958 "config_changed".to_string(),
959 None,
960 )
961 .await;
962 }
963 Ok(old)
964}
965
966pub async fn get_retention_days(store: &Store) -> u64 {
968 match store.get_raw_bytes(RETENTION_DAYS_KEY).await {
969 Ok(Some(bytes)) => std::str::from_utf8(&bytes)
970 .ok()
971 .and_then(|s| s.parse::<u64>().ok())
972 .unwrap_or(DEFAULT_RETENTION_DAYS),
973 _ => DEFAULT_RETENTION_DAYS,
974 }
975}
976
977pub async fn set_retention_days(store: &Store, days: u64) -> Result<()> {
979 store
980 .put_raw(RETENTION_DAYS_KEY, days.to_string().as_bytes())
981 .await
982}
983
984pub fn compute_decision_basis_hash(gotchas: &[(String, serde_json::Value)]) -> String {
993 let mut hasher = Sha256::new();
994 for (key, record_json) in gotchas {
995 hasher.update(key.as_bytes());
996 let rule = record_json
997 .pointer("/value")
998 .and_then(|v| v.as_str())
999 .unwrap_or("");
1000 hasher.update(rule.as_bytes());
1001 let conf = record_json
1002 .pointer("/confidence/value")
1003 .and_then(|v| v.as_f64())
1004 .unwrap_or(0.0);
1005 hasher.update(format!("{conf}").as_bytes());
1006 }
1007 format!("{:x}", hasher.finalize())
1008}
1009
1010static ENFORCEMENT_WRITERS: OnceLock<
1028 StdMutex<HashMap<PathBuf, Arc<tokio::sync::Mutex<EnforcementEventWriter>>>>,
1029> = OnceLock::new();
1030
1031async fn shared_writer(store: &Store) -> Result<Arc<tokio::sync::Mutex<EnforcementEventWriter>>> {
1033 let registry = ENFORCEMENT_WRITERS.get_or_init(|| StdMutex::new(HashMap::new()));
1034
1035 if let Some(writer) = registry
1037 .lock()
1038 .expect("enforcement writer registry poisoned")
1039 .get(&store.root)
1040 .cloned()
1041 {
1042 return Ok(writer);
1043 }
1044
1045 let writer = Arc::new(tokio::sync::Mutex::new(
1049 EnforcementEventWriter::new(store).await?,
1050 ));
1051 Ok(registry
1052 .lock()
1053 .expect("enforcement writer registry poisoned")
1054 .entry(store.root.clone())
1055 .or_insert(writer)
1056 .clone())
1057}
1058
1059#[allow(clippy::too_many_arguments)]
1068pub async fn record_event(
1069 store: &Store,
1070 event_type: EnforcementEventType,
1071 subject_kind: SubjectKind,
1072 subject_key: String,
1073 agent_type: String,
1074 receipt_id: Option<String>,
1075 decision_reason_code: String,
1076 decision_basis_hash: Option<String>,
1077) -> Result<Option<EnforcementEvent>> {
1078 record_event_with_session(
1079 store,
1080 event_type,
1081 subject_kind,
1082 subject_key,
1083 agent_type,
1084 receipt_id,
1085 decision_reason_code,
1086 decision_basis_hash,
1087 None,
1088 )
1089 .await
1090}
1091
1092#[allow(clippy::too_many_arguments)]
1096pub async fn record_event_with_session(
1097 store: &Store,
1098 event_type: EnforcementEventType,
1099 subject_kind: SubjectKind,
1100 subject_key: String,
1101 agent_type: String,
1102 receipt_id: Option<String>,
1103 decision_reason_code: String,
1104 decision_basis_hash: Option<String>,
1105 agent_session: Option<String>,
1106) -> Result<Option<EnforcementEvent>> {
1107 let mode = get_enforcement_mode(store).await;
1108
1109 let result = async {
1110 let writer = shared_writer(store).await?;
1111 let mut writer = writer.lock().await;
1112 writer.agent_session = agent_session;
1113 writer
1114 .write(
1115 store,
1116 event_type,
1117 subject_kind,
1118 subject_key,
1119 agent_type,
1120 receipt_id,
1121 decision_reason_code,
1122 decision_basis_hash,
1123 )
1124 .await
1125 }
1126 .await;
1127
1128 match result {
1129 Ok(event) => Ok(Some(event)),
1130 Err(e) => match mode {
1131 EnforcementMode::Advisory => {
1132 tracing::warn!("enforcement event write failed (advisory mode): {e}");
1133 Ok(None)
1134 }
1135 EnforcementMode::Strict => Err(e),
1136 },
1137 }
1138}
1139
1140#[derive(Debug)]
1146pub enum PruneResult {
1147 NothingToPrune,
1148 Pruned {
1149 count: u64,
1150 oldest_seq: u64,
1151 newest_seq: u64,
1152 },
1153}
1154
1155pub async fn enforce_retention(store: &Store) -> Result<PruneResult> {
1160 let retention_days = get_retention_days(store).await;
1161 let cutoff_ms = now_ms().saturating_sub(retention_days * 86_400_000);
1162
1163 let all_events = scan_enforcement_events(store, 0, u64::MAX).await?;
1164 let old_events: Vec<&EnforcementEvent> = all_events
1165 .iter()
1166 .filter(|e| e.recorded_at_ms < cutoff_ms)
1167 .collect();
1168
1169 if old_events.is_empty() {
1170 return Ok(PruneResult::NothingToPrune);
1171 }
1172
1173 let count = old_events.len() as u64;
1174 let oldest_seq = old_events.first().expect("checked non-empty above").seq_no;
1175 let newest_seq = old_events.last().expect("checked non-empty above").seq_no;
1176
1177 for event in &old_events {
1179 let key = format!("{EVENT_PREFIX}{:020}", event.seq_no);
1180 store.delete(&key).await?;
1181 }
1182
1183 record_event(
1185 store,
1186 EnforcementEventType::RetentionPruned {
1187 pruned_count: count,
1188 oldest_pruned_seq: oldest_seq,
1189 newest_pruned_seq: newest_seq,
1190 },
1191 SubjectKind::System,
1192 "enforcement:retention".to_string(),
1193 "system".to_string(),
1194 None,
1195 "retention_policy_enforced".to_string(),
1196 None,
1197 )
1198 .await?;
1199
1200 Ok(PruneResult::Pruned {
1201 count,
1202 oldest_seq,
1203 newest_seq,
1204 })
1205}
1206
1207pub async fn detect_startup_gap(store: &Store, gap_threshold_ms: u64) -> Result<()> {
1218 let events = scan_enforcement_events(store, 0, u64::MAX).await?;
1219 if events.is_empty() {
1220 return Ok(());
1221 }
1222 let last = events.last().expect("checked non-empty above");
1223 let current = now_ms();
1224 let age = current.saturating_sub(last.recorded_at_ms);
1225
1226 if age > gap_threshold_ms {
1227 let writer = shared_writer(store).await?;
1230 writer
1231 .lock()
1232 .await
1233 .detect_and_record_gap(store, last.recorded_at_ms, current, GapCause::Unknown)
1234 .await?;
1235 }
1236 Ok(())
1237}
1238
1239pub async fn scan_events_since(store: &Store, since_ms: u64) -> Result<Vec<EnforcementEvent>> {
1245 let all = scan_enforcement_events(store, 0, u64::MAX).await?;
1246 Ok(all
1247 .into_iter()
1248 .filter(|e| e.recorded_at_ms >= since_ms)
1249 .collect())
1250}
1251
1252pub async fn count_events_by_type(store: &Store, since_ms: u64) -> Result<EnforcementEventCounts> {
1254 let events = scan_events_since(store, since_ms).await?;
1255 Ok(aggregate_event_counts(&events))
1256}
1257
1258pub fn aggregate_event_counts(events: &[EnforcementEvent]) -> EnforcementEventCounts {
1263 let mut counts = EnforcementEventCounts {
1264 total: events.len() as u64,
1265 ..Default::default()
1266 };
1267 for e in events {
1268 match &e.event_type {
1269 EnforcementEventType::Deny => counts.denials += 1,
1270 EnforcementEventType::AllowAfterReceipt => counts.allowed_after_receipt += 1,
1271 EnforcementEventType::ReceiptMinted => counts.receipts_minted += 1,
1272 EnforcementEventType::BypassDetected => counts.bypasses += 1,
1273 EnforcementEventType::ControlChanged { change_kind } => {
1274 counts.controls_changed += 1;
1275 match change_kind {
1276 ControlChangeKind::Created => counts.controls_created += 1,
1277 ControlChangeKind::Confirmed => counts.controls_confirmed += 1,
1278 ControlChangeKind::Updated => counts.controls_updated += 1,
1279 ControlChangeKind::Deleted => counts.controls_removed += 1,
1280 }
1281 }
1282 EnforcementEventType::EnforcementConfigChanged { .. } => counts.config_changes += 1,
1283 EnforcementEventType::RecordingGap { .. } => counts.gaps += 1,
1284 EnforcementEventType::RetentionPruned { .. } => counts.retention_prunes += 1,
1285 }
1286 }
1287 counts
1288}
1289
1290#[derive(Debug, Default)]
1292pub struct EnforcementEventCounts {
1293 pub total: u64,
1294 pub denials: u64,
1295 pub allowed_after_receipt: u64,
1296 pub receipts_minted: u64,
1297 pub bypasses: u64,
1298 pub controls_changed: u64,
1301 pub controls_created: u64,
1305 pub controls_confirmed: u64,
1306 pub controls_updated: u64,
1307 pub controls_removed: u64,
1308 pub config_changes: u64,
1309 pub gaps: u64,
1310 pub retention_prunes: u64,
1311}
1312
1313#[derive(Debug, Default)]
1316pub struct DerivedEnforcementMetrics {
1317 pub blocked_sessions: u64,
1323 pub attributed_denials: u64,
1325 pub blocks_per_session: Option<f64>,
1328 pub median_time_to_consult_ms: Option<u64>,
1336 pub consult_pairs: u64,
1338}
1339
1340pub fn derive_enforcement_metrics(events: &[EnforcementEvent]) -> DerivedEnforcementMetrics {
1344 use std::collections::{BTreeSet, HashMap};
1345
1346 let mut blocked_sessions: BTreeSet<&str> = BTreeSet::new();
1348 let mut attributed_denials = 0u64;
1349 for e in events {
1350 if matches!(e.event_type, EnforcementEventType::Deny) {
1351 if let Some(sid) = e.agent_session.as_deref() {
1352 blocked_sessions.insert(sid);
1353 attributed_denials += 1;
1354 }
1355 }
1356 }
1357 let blocks_per_session = if blocked_sessions.is_empty() {
1358 None
1359 } else {
1360 Some(attributed_denials as f64 / blocked_sessions.len() as f64)
1361 };
1362
1363 let mut receipts_by_subject: HashMap<&str, Vec<u64>> = HashMap::new();
1367 for e in events {
1368 if matches!(e.event_type, EnforcementEventType::ReceiptMinted) {
1369 receipts_by_subject
1370 .entry(e.subject_key.as_str())
1371 .or_default()
1372 .push(e.recorded_at_ms);
1373 }
1374 }
1375 for times in receipts_by_subject.values_mut() {
1376 times.sort_unstable();
1377 }
1378 let window_ms = crate::store::session::CONSULTED_RECENT_TTL_SECS * 1_000;
1382 let mut deltas: Vec<u64> = Vec::new();
1383 for e in events {
1384 if matches!(e.event_type, EnforcementEventType::Deny) {
1385 if let Some(times) = receipts_by_subject.get(e.subject_key.as_str()) {
1386 if let Some(&t) = times.iter().find(|&&t| t >= e.recorded_at_ms) {
1388 let delta = t - e.recorded_at_ms;
1389 if delta <= window_ms {
1390 deltas.push(delta);
1391 }
1392 }
1393 }
1394 }
1395 }
1396 let consult_pairs = deltas.len() as u64;
1397 let median_time_to_consult_ms = median_u64(&mut deltas);
1398
1399 DerivedEnforcementMetrics {
1400 blocked_sessions: blocked_sessions.len() as u64,
1401 attributed_denials,
1402 blocks_per_session,
1403 median_time_to_consult_ms,
1404 consult_pairs,
1405 }
1406}
1407
1408fn median_u64(values: &mut [u64]) -> Option<u64> {
1411 if values.is_empty() {
1412 return None;
1413 }
1414 values.sort_unstable();
1415 let n = values.len();
1416 let mid = n / 2;
1417 if n % 2 == 1 {
1418 Some(values[mid])
1419 } else {
1420 Some((values[mid - 1] + values[mid]) / 2)
1421 }
1422}
1423
1424pub fn event_type_label(event_type: &EnforcementEventType) -> &'static str {
1426 match event_type {
1427 EnforcementEventType::Deny => "deny",
1428 EnforcementEventType::AllowAfterReceipt => "allow_receipt",
1429 EnforcementEventType::ReceiptMinted => "receipt_minted",
1430 EnforcementEventType::BypassDetected => "bypass",
1431 EnforcementEventType::ControlChanged { .. } => "control_changed",
1432 EnforcementEventType::EnforcementConfigChanged { .. } => "config_changed",
1433 EnforcementEventType::RecordingGap { .. } => "gap",
1434 EnforcementEventType::RetentionPruned { .. } => "retention_pruned",
1435 }
1436}
1437
1438#[cfg(test)]
1439mod tests {
1440 use super::*;
1441
1442 fn frozen_test_event() -> EnforcementEvent {
1444 EnforcementEvent {
1445 event_id: "01900000-0000-7000-8000-000000000001".to_string(),
1446 schema_version: 1,
1447 seq_no: 1,
1448 recorded_at_ms: 1700000000000,
1449 event_type: EnforcementEventType::Deny,
1450 event_hash: String::new(),
1451 prev_hash: String::new(),
1452 installation_id: "test-install-id".to_string(),
1453 actor_local: Some(ActorLocal {
1454 username: "testuser".to_string(),
1455 uid: Some(1000),
1456 verified: false,
1457 }),
1458 agent_type: "claude".to_string(),
1459 subject_kind: SubjectKind::File,
1460 subject_key: "file:src/billing/charges.rs".to_string(),
1461 canonical_subject_hash: Some("abc123".to_string()),
1462 receipt_id: None,
1463 decision_reason_code: "gotcha_above_threshold".to_string(),
1464 decision_basis_hash: Some("def456".to_string()),
1465 agent_session: None,
1466 }
1467 }
1468
1469 #[test]
1470 fn canonical_hash_is_deterministic_and_frozen() {
1471 let event = frozen_test_event();
1472 let hash = event.compute_hash();
1473
1474 assert_eq!(
1479 hash,
1480 "e8a42cb3c1c4dde12f807f46678c5d4393466a831007540a85ff84a003203e37"
1481 );
1482
1483 assert_eq!(hash, event.compute_hash());
1485 assert_eq!(hash, event.compute_hash());
1486 }
1487
1488 #[test]
1489 fn hash_changes_when_field_changes() {
1490 let mut event = frozen_test_event();
1491 let hash1 = event.compute_hash();
1492
1493 event.seq_no = 2;
1494 let hash2 = event.compute_hash();
1495
1496 assert_ne!(hash1, hash2, "changing seq_no must change the hash");
1497 }
1498
1499 fn event_of(event_type: EnforcementEventType) -> EnforcementEvent {
1501 EnforcementEvent {
1502 event_type,
1503 ..frozen_test_event()
1504 }
1505 }
1506
1507 fn chained(n: u64) -> Vec<EnforcementEvent> {
1509 let mut out = Vec::new();
1510 let mut prev = String::new();
1511 for i in 1..=n {
1512 let mut e = EnforcementEvent {
1513 seq_no: i,
1514 prev_hash: prev.clone(),
1515 event_hash: String::new(),
1516 ..frozen_test_event()
1517 };
1518 e.event_hash = e.compute_hash();
1519 prev = e.event_hash.clone();
1520 out.push(e);
1521 }
1522 out
1523 }
1524
1525 #[test]
1526 fn verify_chain_accepts_intact_chain() {
1527 let events = chained(4);
1528 let v = verify_chain(&events);
1529 assert!(v.is_valid());
1530 assert_eq!(v.checked, 4);
1531 assert_eq!(v.tampered_events, 0);
1532 assert_eq!(v.linkage_breaks, 0);
1533 assert_eq!(v.unknown_schema, 0);
1534 }
1535
1536 #[test]
1537 fn verify_chain_is_order_independent() {
1538 let mut events = chained(4);
1539 events.reverse();
1540 assert!(verify_chain(&events).is_valid());
1541 }
1542
1543 #[test]
1544 fn verify_chain_detects_content_tamper_without_rehash() {
1545 let mut events = chained(3);
1548 events[1].subject_key = "file:src/evil.rs".to_string();
1549 let v = verify_chain(&events);
1550 assert_eq!(v.tampered_events, 1);
1551 assert_eq!(v.linkage_breaks, 0);
1552 assert!(!v.is_valid());
1553 }
1554
1555 #[test]
1556 fn verify_chain_detects_linkage_break_from_deleted_event() {
1557 let mut events = chained(3);
1558 events.remove(1); let v = verify_chain(&events);
1560 assert_eq!(v.linkage_breaks, 1);
1561 assert_eq!(v.tampered_events, 0);
1562 assert!(!v.is_valid());
1563 }
1564
1565 #[test]
1566 fn verify_chain_ignores_retention_pruned_prefix() {
1567 let mut events = chained(3);
1568 events.remove(0); let v = verify_chain(&events);
1570 assert!(
1571 v.is_valid(),
1572 "pruned prefix must not be a false linkage break"
1573 );
1574 assert_eq!(v.linkage_breaks, 0);
1575 assert_eq!(v.tampered_events, 0);
1576 }
1577
1578 #[test]
1579 fn verify_chain_flags_unknown_schema_version() {
1580 let mut e = frozen_test_event();
1581 e.schema_version = SCHEMA_VERSION + 1;
1582 e.event_hash = e.compute_hash();
1583 let v = verify_chain(&[e]);
1584 assert_eq!(v.unknown_schema, 1);
1585 assert_eq!(v.checked, 0);
1586 assert!(!v.is_valid());
1587 }
1588
1589 #[test]
1590 fn verify_chain_verifies_v2_events_clean() {
1591 let mut e = frozen_test_event();
1592 e.schema_version = 2;
1593 e.agent_session = Some("session-xyz".to_string());
1594 e.event_hash = e.compute_hash();
1595 let v = verify_chain(&[e]);
1596 assert!(v.is_valid());
1597 assert_eq!(v.checked, 1);
1598 }
1599
1600 #[test]
1601 fn verify_chain_empty_is_valid() {
1602 let v = verify_chain(&[]);
1603 assert!(v.is_valid());
1604 assert_eq!(v.checked, 0);
1605 }
1606
1607 #[test]
1608 fn verify_chain_records_tampered_break_location() {
1609 let mut events = chained(3);
1610 events[2].subject_key = "file:src/evil.rs".to_string();
1611 let v = verify_chain(&events);
1612 assert_eq!(v.breaks.len(), 1);
1613 let b = &v.breaks[0];
1614 assert_eq!(b.kind, ChainBreakKind::Tampered);
1615 assert_eq!(b.seq_no, 3);
1616 assert!(b.prev_seq_no.is_none());
1617 }
1618
1619 #[test]
1620 fn verify_chain_records_linkage_break_with_predecessor() {
1621 let mut events = chained(3);
1622 events.remove(1); let v = verify_chain(&events);
1624 assert_eq!(v.breaks.len(), 1);
1625 let b = &v.breaks[0];
1626 assert_eq!(b.kind, ChainBreakKind::Linkage);
1627 assert_eq!(b.seq_no, 3);
1628 assert_eq!(b.prev_seq_no, Some(1));
1629 }
1630
1631 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1637 async fn concurrent_record_event_keeps_chain_intact() {
1638 use std::sync::Arc;
1639 let dir = tempfile::TempDir::new().unwrap();
1640 let store = Arc::new(Store::open(dir.path()).await.unwrap());
1641
1642 let n: u64 = 64;
1643 let mut handles = Vec::new();
1644 for i in 0..n {
1645 let s = store.clone();
1646 handles.push(tokio::spawn(async move {
1647 record_event(
1648 &s,
1649 EnforcementEventType::Deny,
1650 SubjectKind::File,
1651 format!("file:src/f{i}.rs"),
1652 "claude".to_string(),
1653 None,
1654 "gotcha_above_threshold".to_string(),
1655 None,
1656 )
1657 .await
1658 .expect("record_event")
1659 }));
1660 }
1661 for h in handles {
1662 h.await.expect("task join");
1663 }
1664
1665 let events = scan_enforcement_events(&store, 0, u64::MAX).await.unwrap();
1666 assert_eq!(
1668 events.len() as u64,
1669 n,
1670 "all {n} concurrent writes must persist (no seq collision / event loss)"
1671 );
1672 let v = verify_chain(&events);
1674 assert!(
1675 v.is_valid(),
1676 "concurrent writes must yield an intact chain, got {v:?}"
1677 );
1678 }
1679
1680 #[test]
1681 fn aggregate_event_counts_breaks_out_control_lifecycle() {
1682 use EnforcementEventType::*;
1683 let events = vec![
1684 event_of(Deny),
1685 event_of(Deny),
1686 event_of(AllowAfterReceipt),
1687 event_of(ReceiptMinted),
1688 event_of(BypassDetected),
1689 event_of(ControlChanged {
1690 change_kind: ControlChangeKind::Created,
1691 }),
1692 event_of(ControlChanged {
1693 change_kind: ControlChangeKind::Confirmed,
1694 }),
1695 event_of(ControlChanged {
1696 change_kind: ControlChangeKind::Confirmed,
1697 }),
1698 event_of(ControlChanged {
1699 change_kind: ControlChangeKind::Updated,
1700 }),
1701 event_of(ControlChanged {
1702 change_kind: ControlChangeKind::Deleted,
1703 }),
1704 ];
1705
1706 let counts = aggregate_event_counts(&events);
1707
1708 assert_eq!(counts.total, 10);
1709 assert_eq!(counts.denials, 2);
1710 assert_eq!(counts.allowed_after_receipt, 1);
1711 assert_eq!(counts.receipts_minted, 1);
1712 assert_eq!(counts.bypasses, 1);
1713
1714 assert_eq!(counts.controls_created, 1);
1716 assert_eq!(counts.controls_confirmed, 2);
1717 assert_eq!(counts.controls_updated, 1);
1718 assert_eq!(counts.controls_removed, 1);
1719
1720 assert_eq!(counts.controls_changed, 5);
1722 assert_eq!(
1723 counts.controls_changed,
1724 counts.controls_created
1725 + counts.controls_confirmed
1726 + counts.controls_updated
1727 + counts.controls_removed
1728 );
1729 }
1730
1731 #[test]
1732 fn aggregate_event_counts_empty_is_all_zero() {
1733 let counts = aggregate_event_counts(&[]);
1734 assert_eq!(counts.total, 0);
1735 assert_eq!(counts.controls_changed, 0);
1736 assert_eq!(counts.denials, 0);
1737 }
1738
1739 fn ev(
1741 event_type: EnforcementEventType,
1742 subject: &str,
1743 at_ms: u64,
1744 session: Option<&str>,
1745 ) -> EnforcementEvent {
1746 EnforcementEvent {
1747 event_type,
1748 subject_key: subject.to_string(),
1749 recorded_at_ms: at_ms,
1750 agent_session: session.map(str::to_string),
1751 ..frozen_test_event()
1752 }
1753 }
1754
1755 #[test]
1756 fn derive_metrics_blocks_per_session_and_time_to_consult() {
1757 use EnforcementEventType::*;
1758 let events = vec![
1759 ev(Deny, "file:x.rs", 1000, Some("sessA")),
1761 ev(ReceiptMinted, "file:x.rs", 1500, None),
1762 ev(Deny, "file:y.rs", 2000, Some("sessB")),
1764 ev(ReceiptMinted, "file:y.rs", 2300, None),
1765 ev(Deny, "file:y.rs", 3000, Some("sessA")),
1767 ];
1768
1769 let m = derive_enforcement_metrics(&events);
1770
1771 assert_eq!(m.blocked_sessions, 2, "distinct sessions with a deny");
1772 assert_eq!(m.attributed_denials, 3);
1773 assert_eq!(m.blocks_per_session, Some(1.5)); assert_eq!(m.consult_pairs, 2); assert_eq!(m.median_time_to_consult_ms, Some(400)); }
1777
1778 #[test]
1779 fn derive_metrics_no_sessioned_denials_yields_none() {
1780 use EnforcementEventType::*;
1781 let events = vec![
1783 ev(Deny, "file:x.rs", 1000, None),
1784 ev(ReceiptMinted, "file:x.rs", 1200, None),
1785 ];
1786 let m = derive_enforcement_metrics(&events);
1787 assert_eq!(m.blocked_sessions, 0);
1788 assert_eq!(m.attributed_denials, 0);
1789 assert_eq!(m.blocks_per_session, None);
1790 assert_eq!(m.consult_pairs, 1);
1792 assert_eq!(m.median_time_to_consult_ms, Some(200));
1793 }
1794
1795 #[test]
1796 fn derive_metrics_excludes_consults_beyond_window() {
1797 use EnforcementEventType::*;
1798 let window_ms = crate::store::session::CONSULTED_RECENT_TTL_SECS * 1_000;
1799 let events = vec![
1800 ev(Deny, "file:a.rs", 0, Some("s1")),
1802 ev(ReceiptMinted, "file:a.rs", window_ms, None),
1803 ev(Deny, "file:b.rs", 0, Some("s2")),
1805 ev(ReceiptMinted, "file:b.rs", window_ms + 1, None),
1806 ];
1807 let m = derive_enforcement_metrics(&events);
1808 assert_eq!(m.consult_pairs, 1, "only the in-window pair counts");
1809 assert_eq!(m.median_time_to_consult_ms, Some(window_ms));
1810 }
1811
1812 #[test]
1813 fn median_u64_odd_even_and_empty() {
1814 assert_eq!(median_u64(&mut []), None);
1815 assert_eq!(median_u64(&mut [5]), Some(5));
1816 assert_eq!(median_u64(&mut [3, 1, 2]), Some(2)); assert_eq!(median_u64(&mut [4, 1, 3, 2]), Some(2)); }
1819
1820 #[test]
1821 fn hash_excludes_event_hash_field() {
1822 let mut event = frozen_test_event();
1823 let hash1 = event.compute_hash();
1824
1825 event.event_hash = "something_completely_different".to_string();
1827 let hash2 = event.compute_hash();
1828
1829 assert_eq!(
1830 hash1, hash2,
1831 "event_hash field must be excluded from canonical form"
1832 );
1833 }
1834
1835 #[test]
1836 fn canonical_path_aliasing_produces_same_key() {
1837 let repo_root = PathBuf::from("/tmp/test-repo");
1838
1839 let paths = [
1841 "src/billing/charges.rs",
1842 "./src/billing/charges.rs",
1843 "src/billing/../billing/charges.rs",
1844 "src/./billing/charges.rs",
1845 ];
1846
1847 let canonical_keys: Vec<String> = paths
1849 .iter()
1850 .map(|p| {
1851 let abs = repo_root.join(p);
1852 let normalized = normalize_components(&abs);
1853 let relative = normalized
1854 .strip_prefix(&repo_root)
1855 .unwrap_or(&normalized)
1856 .to_string_lossy()
1857 .replace('\\', "/");
1858 if is_case_insensitive() {
1859 relative.to_lowercase()
1860 } else {
1861 relative
1862 }
1863 })
1864 .collect();
1865
1866 for key in &canonical_keys {
1867 assert_eq!(
1868 key, &canonical_keys[0],
1869 "Path aliasing produced different keys"
1870 );
1871 }
1872
1873 assert_eq!(canonical_keys[0], "src/billing/charges.rs");
1874 }
1875
1876 #[test]
1877 fn canonical_subject_hash_is_deterministic() {
1878 let hash1 = canonical_subject_hash("src/billing/charges.rs");
1879 let hash2 = canonical_subject_hash("src/billing/charges.rs");
1880 assert_eq!(hash1, hash2);
1881
1882 let hash3 = canonical_subject_hash("src/billing/other.rs");
1883 assert_ne!(hash1, hash3);
1884 }
1885
1886 #[test]
1887 fn schema_version_is_two() {
1888 assert_eq!(SCHEMA_VERSION, 2);
1889 assert_eq!(HASH_ALGORITHM, "sha256");
1890 }
1891
1892 #[test]
1893 fn v2_hash_includes_agent_session() {
1894 let mut e_none = frozen_test_event();
1898 e_none.schema_version = 2;
1899 e_none.agent_session = None;
1900 let h_none = e_none.compute_hash();
1901
1902 let mut e_session = e_none.clone();
1903 e_session.agent_session = Some("sess-abc".to_string());
1904 let h_session = e_session.compute_hash();
1905
1906 assert_ne!(
1907 h_none, h_session,
1908 "agent_session must be part of the v2 canonical hash"
1909 );
1910 assert_eq!(h_session, e_session.compute_hash());
1912
1913 let mut as_v1 = e_none.clone();
1916 as_v1.schema_version = 1;
1917 assert_ne!(
1918 h_none,
1919 as_v1.compute_hash(),
1920 "v1 and v2 canonical forms must differ (14 vs 15 fields)"
1921 );
1922 }
1923}